An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

Select the types of activity you want to include in your feed.

wah

+669 -1857
+669
DOCS.md
··· 1 + # TypeSpec to Lexicon Reference 2 + 3 + This guide maps atproto Lexicon JSON syntax to TypeSpec (Tylex). It assumes you're familiar with Lexicon and want to understand how to express it in TypeSpec. 4 + 5 + ## Quick Start 6 + 7 + Every TypeSpec file starts with an import and namespace: 8 + 9 + ```typescript 10 + import "@tylex/emitter"; 11 + 12 + /** Common definitions used by other lexicons */ 13 + namespace com.example.defs { 14 + // definitions here 15 + } 16 + ``` 17 + 18 + **Maps to:** 19 + ```json 20 + { 21 + "lexicon": 1, 22 + "id": "com.example.defs", 23 + "description": "Common definitions used by other lexicons", 24 + "defs": { ... } 25 + } 26 + ``` 27 + 28 + Use `/** */` doc comments for descriptions (or `@doc()` decorator as alternative). 29 + 30 + ## Top-Level Lexicon Types 31 + 32 + ### Query (XRPC Query) 33 + 34 + ```typescript 35 + namespace com.example.getRecord { 36 + /** Retrieve a record by ID */ 37 + @query 38 + op main( 39 + /** The record identifier */ 40 + @required id: string 41 + ): { 42 + @required record: com.example.record.Main; 43 + }; 44 + } 45 + ``` 46 + 47 + **Maps to:** `{"type": "query", ...}` with `parameters` and `output` 48 + 49 + ### Procedure (XRPC Procedure) 50 + 51 + ```typescript 52 + namespace com.example.createRecord { 53 + /** Create a new record */ 54 + @procedure 55 + op main(input: { 56 + @required text: string; 57 + }): { 58 + @required uri: atUri; 59 + @required cid: cid; 60 + }; 61 + } 62 + ``` 63 + 64 + **Maps to:** `{"type": "procedure", ...}` with `input` and `output` 65 + 66 + ### Subscription (XRPC Subscription) 67 + 68 + ```typescript 69 + namespace com.example.subscribeRecords { 70 + /** Subscribe to record updates */ 71 + @subscription 72 + op main(cursor?: integer): (Record | Delete); 73 + 74 + model Record { 75 + @required uri: atUri; 76 + @required record: com.example.record.Main; 77 + } 78 + 79 + model Delete { 80 + @required uri: atUri; 81 + } 82 + } 83 + ``` 84 + 85 + **Maps to:** `{"type": "subscription", ...}` with `message` containing union 86 + 87 + ### Record 88 + 89 + ```typescript 90 + namespace com.example.post { 91 + @rec("tid") 92 + /** A post record */ 93 + model Main { 94 + @required text: string; 95 + @required createdAt: datetime; 96 + } 97 + } 98 + ``` 99 + 100 + **Maps to:** `{"type": "record", "key": "tid", "record": {...}}` 101 + 102 + **Record key types:** `@rec("tid")`, `@rec("any")`, `@rec("nsid")` 103 + 104 + ### Object (Plain Definition) 105 + 106 + ```typescript 107 + namespace com.example.defs { 108 + /** User metadata */ 109 + model Metadata { 110 + version?: integer = 1; 111 + tags?: string[]; 112 + } 113 + } 114 + ``` 115 + 116 + **Maps to:** `{"type": "object", "properties": {...}}` 117 + 118 + ## Reserved Keywords 119 + 120 + Use backticks for TypeScript/TypeSpec reserved words: 121 + 122 + ```typescript 123 + namespace app.bsky.feed.post.`record` { ... } 124 + namespace `pub`.leaflet.subscription { ... } 125 + ``` 126 + 127 + ## Inline vs Definitions 128 + 129 + **By default, models become separate defs.** Use `@inline` to prevent this: 130 + 131 + ```typescript 132 + // Without @inline - becomes separate def "statusEnum" 133 + union StatusEnum { 134 + "active", 135 + "inactive", 136 + } 137 + 138 + // With @inline - inlined where used 139 + @inline 140 + union StatusEnum { 141 + "active", 142 + "inactive", 143 + } 144 + ``` 145 + 146 + Use `@inline` when you want the type directly embedded rather than referenced. 147 + 148 + ## Optional vs Required Fields 149 + 150 + **In lexicons, optional fields are the norm.** Required fields are discouraged and need explicit `@required`: 151 + 152 + ```typescript 153 + model Post { 154 + text?: string; // optional (common) 155 + @required createdAt: datetime; // required (discouraged, needs decorator) 156 + } 157 + ``` 158 + 159 + **Maps to:** 160 + ```json 161 + { 162 + "type": "object", 163 + "required": ["createdAt"], 164 + "properties": { 165 + "text": {"type": "string"}, 166 + "createdAt": {"type": "string", "format": "datetime"} 167 + } 168 + } 169 + ``` 170 + 171 + ## Primitive Types 172 + 173 + | TypeSpec | Lexicon JSON | 174 + |----------|--------------| 175 + | `boolean` | `{"type": "boolean"}` | 176 + | `integer` | `{"type": "integer"}` | 177 + | `string` | `{"type": "string"}` | 178 + | `bytes` | `{"type": "bytes"}` | 179 + | `cidLink` | `{"type": "cid-link"}` | 180 + | `unknown` | `{"type": "unknown"}` | 181 + 182 + ## Format Types 183 + 184 + Specialized string formats: 185 + 186 + | TypeSpec | Lexicon Format | 187 + |----------|----------------| 188 + | `atIdentifier` | `at-identifier` - Handle or DID | 189 + | `atUri` | `at-uri` - AT Protocol URI | 190 + | `cid` | `cid` - Content ID | 191 + | `datetime` | `datetime` - ISO 8601 datetime | 192 + | `did` | `did` - DID identifier | 193 + | `handle` | `handle` - Handle identifier | 194 + | `nsid` | `nsid` - Namespaced ID | 195 + | `tid` | `tid` - Timestamp ID | 196 + | `recordKey` | `record-key` - Record key | 197 + | `uri` | `uri` - Generic URI | 198 + | `language` | `language` - Language tag | 199 + 200 + ## Unions 201 + 202 + ### Open Unions (Common Pattern) 203 + 204 + **Open unions are the default and preferred in lexicons.** Add `unknown` to mark as open: 205 + 206 + ```typescript 207 + model Main { 208 + /** Can be any of these types or future additions */ 209 + @required item: TypeA | TypeB | TypeC | unknown; 210 + } 211 + 212 + model TypeA { 213 + @readOnly @required kind: string = "a"; 214 + @required valueA: string; 215 + } 216 + ``` 217 + 218 + **Maps to:** 219 + ```json 220 + { 221 + "properties": { 222 + "item": { 223 + "type": "union", 224 + "refs": ["#typeA", "#typeB", "#typeC"] 225 + } 226 + } 227 + } 228 + ``` 229 + 230 + The `unknown` makes it open but doesn't appear in refs. 231 + 232 + ### Known Values (Open String Enum) 233 + 234 + Suggest values but allow others: 235 + 236 + ```typescript 237 + model Main { 238 + /** Language - suggests common values but allows any */ 239 + lang?: "en" | "es" | "fr" | string; 240 + } 241 + ``` 242 + 243 + **Maps to:** 244 + ```json 245 + { 246 + "properties": { 247 + "lang": { 248 + "type": "string", 249 + "knownValues": ["en", "es", "fr"] 250 + } 251 + } 252 + } 253 + ``` 254 + 255 + ### Closed Unions (Discouraged) 256 + 257 + **⚠️ Closed unions are discouraged in lexicons** as they prevent future additions. Use only when absolutely necessary: 258 + 259 + ```typescript 260 + @closed 261 + @inline 262 + union Action { 263 + Create, 264 + Update, 265 + Delete, 266 + } 267 + 268 + model Main { 269 + @required action: Action; 270 + } 271 + ``` 272 + 273 + **Maps to:** 274 + ```json 275 + { 276 + "properties": { 277 + "action": { 278 + "type": "union", 279 + "refs": ["#create", "#update", "#delete"], 280 + "closed": true 281 + } 282 + } 283 + } 284 + ``` 285 + 286 + ### Closed Enums (Discouraged) 287 + 288 + **⚠️ Closed enums are also discouraged.** Use `@closed @inline union` for fixed value sets: 289 + 290 + ```typescript 291 + @closed 292 + @inline 293 + union Status { 294 + "active", 295 + "inactive", 296 + "pending", 297 + } 298 + ``` 299 + 300 + **Maps to:** 301 + ```json 302 + { 303 + "type": "string", 304 + "enum": ["active", "inactive", "pending"] 305 + } 306 + ``` 307 + 308 + Integer enums work the same way: 309 + 310 + ```typescript 311 + @closed 312 + @inline 313 + union Fibonacci { 314 + 1, 2, 3, 5, 8, 315 + } 316 + ``` 317 + 318 + ## Arrays 319 + 320 + Use `[]` suffix: 321 + 322 + ```typescript 323 + model Main { 324 + /** Array of strings */ 325 + stringArray?: string[]; 326 + 327 + /** Array with size constraints */ 328 + @minItems(1) 329 + @maxItems(10) 330 + limitedArray?: integer[]; 331 + 332 + /** Array of references */ 333 + items?: Item[]; 334 + 335 + /** Array of union types */ 336 + mixed?: (TypeA | TypeB | unknown)[]; 337 + } 338 + ``` 339 + 340 + **Maps to:** `{"type": "array", "items": {...}}` 341 + 342 + **Note:** `@minItems`/`@maxItems` in TypeSpec map to `minLength`/`maxLength` in JSON. 343 + 344 + ## Blobs 345 + 346 + ```typescript 347 + model Main { 348 + /** Basic blob */ 349 + file?: Blob; 350 + 351 + /** Image up to 5MB */ 352 + image?: Blob<#["image/*"], 5000000>; 353 + 354 + /** Specific types up to 2MB */ 355 + photo?: Blob<#["image/png", "image/jpeg"], 2000000>; 356 + } 357 + ``` 358 + 359 + **Maps to:** 360 + ```json 361 + { 362 + "file": {"type": "blob"}, 363 + "image": { 364 + "type": "blob", 365 + "accept": ["image/*"], 366 + "maxSize": 5000000 367 + } 368 + } 369 + ``` 370 + 371 + ## References 372 + 373 + ### Local References 374 + 375 + Same namespace, uses `#`: 376 + 377 + ```typescript 378 + model Main { 379 + metadata?: Metadata; 380 + } 381 + 382 + model Metadata { 383 + @required key: string; 384 + } 385 + ``` 386 + 387 + **Maps to:** `{"type": "ref", "ref": "#metadata"}` 388 + 389 + ### External References 390 + 391 + Different namespace to specific def: 392 + 393 + ```typescript 394 + model Main { 395 + externalRef?: com.example.defs.Metadata; 396 + } 397 + ``` 398 + 399 + **Maps to:** `{"type": "ref", "ref": "com.example.defs#metadata"}` 400 + 401 + Different namespace to main def (no fragment): 402 + 403 + ```typescript 404 + model Main { 405 + mainRef?: com.example.post.Main; 406 + } 407 + ``` 408 + 409 + **Maps to:** `{"type": "ref", "ref": "com.example.post"}` 410 + 411 + ## Tokens 412 + 413 + Empty models marked with `@token`: 414 + 415 + ```typescript 416 + /** Indicates spam content */ 417 + @token 418 + model ReasonSpam {} 419 + 420 + /** Indicates policy violation */ 421 + @token 422 + model ReasonViolation {} 423 + 424 + model Report { 425 + @required reason: (ReasonSpam | ReasonViolation | unknown); 426 + } 427 + ``` 428 + 429 + **Maps to:** 430 + ```json 431 + { 432 + "report": { 433 + "properties": { 434 + "reason": { 435 + "type": "union", 436 + "refs": ["#reasonSpam", "#reasonViolation"] 437 + } 438 + } 439 + }, 440 + "reasonSpam": { 441 + "type": "token", 442 + "description": "Indicates spam content" 443 + } 444 + } 445 + ``` 446 + 447 + ## Operation Details 448 + 449 + ### Query Parameters 450 + 451 + ```typescript 452 + @query 453 + op main( 454 + @required search: string, 455 + limit?: integer = 50, 456 + tags?: string[] 457 + ): { ... }; 458 + ``` 459 + 460 + Parameters can be inline with decorators before each. 461 + 462 + ### Procedure with Input and Parameters 463 + 464 + ```typescript 465 + @procedure 466 + op main( 467 + input: { 468 + @required data: string; 469 + }, 470 + parameters: { 471 + @required repo: atIdentifier; 472 + validate?: boolean = true; 473 + } 474 + ): { ... }; 475 + ``` 476 + 477 + Use `input:` for body, `parameters:` for query params. 478 + 479 + ### No Output 480 + 481 + ```typescript 482 + @procedure 483 + op main(input: { 484 + @required uri: atUri; 485 + }): void; 486 + ``` 487 + 488 + Use `: void` for procedures with no output. 489 + 490 + ### Output Without Schema 491 + 492 + ```typescript 493 + @query 494 + @encoding("application/json") 495 + op main(id?: string): never; 496 + ``` 497 + 498 + Use `: never` with `@encoding()` for output with encoding but no schema. 499 + 500 + ### Errors 501 + 502 + ```typescript 503 + /** The provided text is invalid */ 504 + model InvalidText {} 505 + 506 + /** User not found */ 507 + model NotFound {} 508 + 509 + @procedure 510 + @errors(InvalidText, NotFound) 511 + op main(...): ...; 512 + ``` 513 + 514 + Empty models with descriptions become error definitions. 515 + 516 + ## Constraints 517 + 518 + ### String Constraints 519 + 520 + ```typescript 521 + model Main { 522 + /** Byte length constraints */ 523 + @minLength(1) 524 + @maxLength(100) 525 + text?: string; 526 + 527 + /** Grapheme cluster length constraints */ 528 + @minGraphemes(1) 529 + @maxGraphemes(50) 530 + displayName?: string; 531 + } 532 + ``` 533 + 534 + **Maps to:** `minLength`/`maxLength`, `minGraphemes`/`maxGraphemes` 535 + 536 + ### Integer Constraints 537 + 538 + ```typescript 539 + model Main { 540 + @minValue(1) 541 + @maxValue(100) 542 + score?: integer; 543 + } 544 + ``` 545 + 546 + **Maps to:** `minimum`/`maximum` 547 + 548 + ### Bytes Constraints 549 + 550 + ```typescript 551 + model Main { 552 + @minBytes(1) 553 + @maxBytes(1024) 554 + data?: bytes; 555 + } 556 + ``` 557 + 558 + **Maps to:** `minLength`/`maxLength` 559 + 560 + **Note:** Use `@minBytes`/`@maxBytes` in TypeSpec, but they map to `minLength`/`maxLength` in JSON. 561 + 562 + ### Array Constraints 563 + 564 + ```typescript 565 + model Main { 566 + @minItems(1) 567 + @maxItems(10) 568 + items?: string[]; 569 + } 570 + ``` 571 + 572 + **Maps to:** `minLength`/`maxLength` 573 + 574 + **Note:** Use `@minItems`/`@maxItems` in TypeSpec, but they map to `minLength`/`maxLength` in JSON. 575 + 576 + ## Default and Constant Values 577 + 578 + ### Defaults 579 + 580 + ```typescript 581 + model Main { 582 + version?: integer = 1; 583 + lang?: string = "en"; 584 + } 585 + ``` 586 + 587 + **Maps to:** `{"default": 1}`, `{"default": "en"}` 588 + 589 + ### Constants 590 + 591 + Use `@readOnly` with default value: 592 + 593 + ```typescript 594 + model Main { 595 + @readOnly status?: string = "active"; 596 + } 597 + ``` 598 + 599 + **Maps to:** `{"const": "active"}` 600 + 601 + ## Nullable Fields 602 + 603 + Use `| null` for nullable fields: 604 + 605 + ```typescript 606 + model Main { 607 + @required createdAt: datetime; 608 + updatedAt?: datetime | null; // can be omitted or null 609 + deletedAt?: datetime; // can only be omitted 610 + } 611 + ``` 612 + 613 + **Maps to:** 614 + ```json 615 + { 616 + "required": ["createdAt"], 617 + "nullable": ["updatedAt"], 618 + "properties": { ... } 619 + } 620 + ``` 621 + 622 + ## Common Patterns 623 + 624 + ### Discriminated Unions 625 + 626 + Use `@readOnly` with const for discriminator: 627 + 628 + ```typescript 629 + model Create { 630 + @readOnly @required type: string = "create"; 631 + @required data: string; 632 + } 633 + 634 + model Update { 635 + @readOnly @required type: string = "update"; 636 + @required id: string; 637 + } 638 + ``` 639 + 640 + ### Nested Unions 641 + 642 + ```typescript 643 + model Container { 644 + @required id: string; 645 + @required payload: (PayloadA | PayloadB | unknown); 646 + } 647 + ``` 648 + 649 + Unions can be nested in objects and arrays. 650 + 651 + ## Naming Conventions 652 + 653 + Model names convert from PascalCase to camelCase in defs: 654 + 655 + ```typescript 656 + model StatusEnum { ... } // becomes "statusEnum" 657 + model UserMetadata { ... } // becomes "userMetadata" 658 + model Main { ... } // becomes "main" 659 + ``` 660 + 661 + ## Decorator Style 662 + 663 + - Single `@required` goes on same line: `@required text: string` 664 + - Multiple decorators go on separate lines with blank line after: 665 + ```typescript 666 + @minLength(1) 667 + @maxLength(100) 668 + text?: string; 669 + ```
-115
LEXICON.md
··· 1 - More folks are starting to design Lexicon schemas from scratch, which is great! This is particularly driven by ecosystem projects like Slices and microcosm which make it easier to work with AT network data in a generic way. 2 - 3 - One piece of developer documentation that has been missing is guidance on writing Lexicons themselves: a Lexicon style guide, or Lexinomincon. Below is an early draft of such a guide, which we intend to integrate in to the the atproto.com website. We are also working on linting tools and other resources for developers drafting and publishing lexicon schemas. 4 - 5 - This will probably be a living document, but early questions and feedback are very much welcome. 6 - 7 - Basic Guidelines 8 - Name casing conventions: 9 - 10 - Schemas & attributes: Use lowerCamelCase capitalization for schemas and names (as opposed to UpperCamelCase, snake_case, ALL_CAPS, etc). 11 - API error names: UpperCamelCase 12 - Fixed strings (eg knownValues): kebab-case 13 - Acceptable characters: 14 - 15 - Field names should stick to the same character set as schema names (NSID name segments): ASCII alphanumeric, first character not a digit, no hyphens, case-sensitive 16 - Exceptions may be justifiable in some situations, such as preservation of names in existing external schemas 17 - Data objects should never contain schema-specified field names starting with $ at any level of nesting; these are reserved for future protocol-level extensions 18 - Naming conventions: 19 - 20 - Use singular nouns for record schemas 21 - eg post, like, profile 22 - Use “verb-noun” for query and procedure endpoints 23 - eg getPost, listLikes, putProfile 24 - Common verbs for query endpoints are: get, list, search (for full-text search), query (for flexible matching or filtering filtering) 25 - Common verbs for procedure endpoints: create, update, delete, upsert, put 26 - Use “subscribe-plural-noun” for subscription 27 - eg subscribeLabels 28 - Conventions for permission-set schema naming has not be established yet, but probably has “auth” prefix (eg, authBasic) 29 - If an endpoint is experimental, unstable, or not intended for interoperability, indicate that in the NSID name 30 - eg, include .temp. or .unspecced. in the NSID hierarchy 31 - Avoid generic names which conflict with popular programming language conventions 32 - eg, avoid using default or length as schema names 33 - Documentation and Completeness: 34 - 35 - Add a description to every main schema definition (records, API endpoints, etc) 36 - for API endpoints, mention in the description if authentication is required, and whether responses will be personalized if authentication is optional 37 - Add descriptions to potentially ambiguous fields and properties. This is particularly important for fields with generic names like uri or cid: CID of what? 38 - NSID namespace grouping: 39 - 40 - Many applications and projects will have multiple distinct functions or features, and schemas of all types can have that grouping represented in the NSID hierarchy 41 - eg app.bsky.feed.* , app.bsky.graph.* 42 - Very simple applications can include all endpoints under a single NSID “group” 43 - use a .defs schema for definitions which might be reused by multiple schemas in the same namespace, or by third parties 44 - eg app.bsky.feed.defs 45 - putting these in a separate schema file means that deprecation or removal of other schema files doesn’t impact reuse 46 - Avoid conflicts and confusion between groups, names, and definitions 47 - eg app.bsky.feed.post#main vs app.bsky.feed.post.main, or com.example.record#foo and com.example.record.foo 48 - or defining both app.bsky.feed (as a record) and app.bsky.feed.post (with app.bsky.feed as a group) 49 - Other guidelines: 50 - 51 - Specify the format of string fields when appropriate 52 - String fields in records should almost always have a maximum length if they don’t have a format type 53 - Don’t redundantly specify both a format and length limits 54 - If limiting the length of a string for semantic or visual reasons, grapheme limits should be used to ensure a degree of consistency across human languages. A data size (bytes) limit should also be added in these cases. A ratio of between 10 to 20 bytes to 1 grapheme is recommended. 55 - The string and bytes record data types are intended for constrained data size use-cases. For text or binary data of larger size, blob references should be used. This can include longer-form text and structured data. 56 - Enum sets are “closed” and can not be updated or extended without breaking schema evolution rules. For this reason they should almost always be avoided. 57 - For strings, knownValues provides more flexible alternative 58 - String knownValues may include simple string constants, or may include schema references to a token (eg, the string "com.example.defs#tokenOne") 59 - Tokens provide an extension mechanism, and work well for values that have subjective definitions or may be expanded over time 60 - See com.atproto.moderation.defs#reasonType and com.atproto.sync.defs#hostStatus for two contrasting instances, the former extensible and the later more constrained 61 - Take advantage of re-usable definitions, such as com.atproto.repo.strongRef (for versioned references to records) or com.atproto.label.defs#label (in an array, for hydrated labels) 62 - API endpoints which take an account identifier as an argument (eg, query parameter) should use at-identifier so that clients can avoid calling resolveHandle if they only have an account handle 63 - Record schemas should always use persistent identifiers (DIDs) for references to other accounts, instead of handles 64 - API endpoints should always specify an output with encoding, even if they have no meaningful response data 65 - a good default is application/json with the schema being an object with no defined properties 66 - Optional boolean fields should be phrased such that false is the default and expected value 67 - For example, if an endpoint can return a mix of “foo” and “bar”, and the common behavior is to include “foo” but not “bar”, then controlling parameters should be named excludeFoo (default false) and includeBar (default false), as opposed to excludeBar (default true) 68 - Content hashes (CIDs) may be represented as a string format or in binary encoding (cid-link) 69 - In most situations, including versioned references between records, the string format is recommended. 70 - Binary encoding is mostly used for protocol-level mechanisms, such as the firehose. 71 - Schema Evolution and Extension 72 - All schemas should be flexible to extension and evolution over time, without breaking the Lexicon schema evolution rules. This is particularly true for record schemas. Given the distributed storage model of atproto, developers do not have a reliable mechanism to update all data records in the network. Extensions could come from the original designer, or other developers and projects. 73 - 74 - Experimental schemas and projects can use variant NSIDs (eg, including .temp. in the name hierarchy) to develop in the live network without committing to a stable record data schemas. 75 - 76 - Major non-backwards-compatible schema changes are possible by declaring a new schema. The current naming convention is to append “V2” to the original name (or “V3”, etc). 77 - 78 - Design recommendations to make schemas flexible to future evolution and extension: 79 - 80 - do not mark data fields or API parameters as required unless they are truly required for functionality 81 - required fields can not be made optional or deprecated under the evolution rules 82 - you can add new optional fields to a schema without changing backwards compatibility or requiring a V2 schema, but you can’t add new required fields 83 - use object types containing a single element/field instead of atomic data types in arrays, to allow additional context to be included in the future 84 - for example, in an API response listing accounts (DIDs), return an array of objects each with an account field listing the DID, instead of an array of strings 85 - make unions “open” in almost all situations, to allow future addition of types or values 86 - open unions can be an extension mechanism for third parties to include self-defined data types 87 - Design Patterns 88 - There is a basic convention for pagination of query API endpoints: 89 - query parameters include an optional limit (integer) and optionalcursor (string) 90 - the output body includes optional cursor (string) and a required array of response objects (with context-specific pluralized field name) 91 - the initial client request does not define a cursor. If the response includes a cursor, then more results are available, and the client should query again with the new cursor to get more results 92 - the limit value is an upper limit, and the response may include fewer (or even zero) results, while further results are still available. It is the lack of cursor in responses that indicates pagination is complete. The response set may have items removed if they are tombstoned or have been otherwise filtered from the response set. 93 - There is also a convention for subscription endpoints which support “sequencing” and backfill cursors: 94 - the endpoint has an optional cursor query parameter (integer) 95 - all core message types include a seq field (integer). The seq of messages increases monotonically, though may have gaps. 96 - if the cursor is not provided, the server will start returning new messages from the current point forward 97 - if the cursor is provided, the server will attempt to return historical messages starting with the matching seq, continuing through to the current stream 98 - if the cursor is in the future (higher than the current sequence), an error is returned and the connection closed (TODO: is this the convention?) 99 - if the cursor is older than the earliest available message (or is 0), the server returns an info message of name OutdatedCursor, then returns messages starting from the oldest available 100 - A common pattern in API responses is to include “hydrated views” of data records. For example, when viewing an account’s profile, the response might include CDN or thumbnail URLs for any media files, moderation labels, global aggregations, and viewer-specific social graph context. 101 - For detailed views, a best practice to include the original record verbatim, instead of defining a new schema with a superset of fields. This is easier to maintain (can’t forget to update fields), and ensures any off-schema extension data is included. 102 - Viewer-specific metadata should be optional and either indicated in descriptions or grouped under a sub-object. This makes schemas reusable between “public” and “logged-in” views, and makes it clearer what information will be available when. 103 - A helpful pattern for application developers is to ensure there is an API endpoint that accepts a reference to a record (eg, a AT URI or equivalent; or multiple references) returns the hydrated data object(s). 104 - the app.bsky.richtext.facet system can be used to annotate short text strings in a way that is simpler and safer to work with than full-featured markup languages 105 - for more details see "Why RichText facets in Bluesky" 106 - the feature type system is an open union which can be extended with additional types 107 - more powerful systems like Markdown are more appropriate for long-form text 108 - One pattern for extending or supplementing a record is to define “sidecar” records in the same account repository with the same record key and different types (collections). 109 - Sidecar records can be defined and managed by the original Lexicon designer or by independent developers. 110 - The sidecar records can be updated (mutated) without breaking strong references to the original record. 111 - Sidecar context can be included in API responses. 112 - Because atproto accounts can be used flexibly with any application in the network, it can be ambiguous which accounts are participating in a particular app modality. This can be clarified if there is a known representative record type for the modality, and that clients create such a record for active accounts. Deletion of this record can be a way to indicate the user is no longer active. This works best if the record has a single known instance (fixed record key). 113 - For example, an-app specific “profile” or “declaration” record can indicate that the account has logged in to an associated app at least once, even if the record is “empty”. 114 - Backfill services can enumerate all accounts in the network with the given signaling record, and also process deletion of that record as deactivation of that modality. 115 - This design pattern is strongly recommended for new app modalities.
-1742
SYNTAX.md
··· 1 - # Lexicon TypeSpec Syntax 2 - 3 - Quick reference for converting AT Protocol lexicons to TypeSpec. 4 - 5 - ## File Structure 6 - 7 - Every `.tsp` file starts with imports and a namespace: 8 - 9 - <table> 10 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 11 - <tr><td> 12 - 13 - ```typespec 14 - import "@tylex/emitter"; 15 - 16 - namespace app.bsky.feed.like { 17 - @record("tid") 18 - @doc("Record declaring a 'like' of a piece of subject content.") 19 - model Main { 20 - @required subject: com.atproto.repo.strongRef.Main; 21 - @required createdAt: datetime; 22 - } 23 - } 24 - ``` 25 - 26 - </td><td> 27 - 28 - ```json 29 - { 30 - "lexicon": 1, 31 - "id": "app.bsky.feed.like", 32 - "defs": { 33 - "main": { 34 - "type": "record", 35 - "description": "Record declaring a 'like' of a piece of subject content.", 36 - "key": "tid", 37 - "record": { 38 - "type": "object", 39 - "required": ["subject", "createdAt"], 40 - "properties": { 41 - "subject": { 42 - "type": "ref", 43 - "ref": "com.atproto.repo.strongRef" 44 - }, 45 - "createdAt": { 46 - "type": "string", 47 - "format": "datetime" 48 - } 49 - } 50 - } 51 - } 52 - } 53 - } 54 - ``` 55 - 56 - </td></tr> 57 - </table> 58 - 59 - **Key rules:** 60 - - Models use PascalCase in `.tsp`, auto-convert to camelCase in JSON 61 - - Namespace ending in `.defs` → defs-only file (no main) 62 - - Namespace with `Main` model → lexicon with main def 63 - - Must have one or the other (error otherwise) 64 - 65 - ## Basic Types 66 - 67 - ### Primitives 68 - 69 - <table> 70 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 71 - <tr><td> 72 - 73 - ```typespec 74 - model JobStatus { 75 - @required jobId: string; 76 - @required state: string; 77 - progress?: integer; 78 - @required canUpload: boolean; 79 - } 80 - ``` 81 - 82 - </td><td> 83 - 84 - ```json 85 - { 86 - "type": "object", 87 - "required": ["jobId", "state", "canUpload"], 88 - "properties": { 89 - "jobId": { "type": "string" }, 90 - "state": { "type": "string" }, 91 - "progress": { "type": "integer" }, 92 - "canUpload": { "type": "boolean" } 93 - } 94 - } 95 - ``` 96 - 97 - </td></tr> 98 - </table> 99 - 100 - **Atproto idiom:** Most fields are optional. Only use `@required` when truly necessary. 101 - 102 - ### AT Protocol Formats 103 - 104 - <table> 105 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 106 - <tr><td> 107 - 108 - ```typespec 109 - model ProfileViewBasic { 110 - @required did: did; 111 - @required handle: handle; 112 - displayName?: string; 113 - avatar?: uri; 114 - createdAt?: datetime; 115 - } 116 - ``` 117 - 118 - </td><td> 119 - 120 - ```json 121 - { 122 - "type": "object", 123 - "required": ["did", "handle"], 124 - "properties": { 125 - "did": { "type": "string", "format": "did" }, 126 - "handle": { "type": "string", "format": "handle" }, 127 - "displayName": { "type": "string" }, 128 - "avatar": { "type": "string", "format": "uri" }, 129 - "createdAt": { "type": "string", "format": "datetime" } 130 - } 131 - } 132 - ``` 133 - 134 - </td></tr> 135 - </table> 136 - 137 - **Available formats:** `did`, `handle`, `uri`, `atUri`, `cid`, `tid`, `nsid`, `datetime`, `language`, `recordKey`, `atIdentifier` 138 - 139 - ### Optional vs Required (Atproto Idiom) 140 - 141 - <table> 142 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 143 - <tr><td> 144 - 145 - ```typespec 146 - model ProfileView { 147 - // Identifiers are typically required 148 - @required did: did; 149 - @required handle: handle; 150 - 151 - // Almost everything else is optional 152 - displayName?: string; 153 - description?: string; 154 - avatar?: uri; 155 - banner?: uri; 156 - followersCount?: integer; 157 - followsCount?: integer; 158 - indexedAt?: datetime; 159 - } 160 - ``` 161 - 162 - </td><td> 163 - 164 - ```json 165 - { 166 - "type": "object", 167 - "required": ["did", "handle"], 168 - "properties": { 169 - "did": { "type": "string", "format": "did" }, 170 - "handle": { "type": "string", "format": "handle" }, 171 - "displayName": { "type": "string" }, 172 - "description": { "type": "string" }, 173 - "avatar": { "type": "string", "format": "uri" }, 174 - "banner": { "type": "string", "format": "uri" }, 175 - "followersCount": { "type": "integer" }, 176 - "followsCount": { "type": "integer" }, 177 - "indexedAt": { "type": "string", "format": "datetime" } 178 - } 179 - } 180 - ``` 181 - 182 - </td></tr> 183 - </table> 184 - 185 - **Atproto idiom:** Required fields are rare - use only for core identifiers and timestamps. Everything else is optional for forward compatibility. 186 - 187 - ## Constraints 188 - 189 - ### String Constraints 190 - 191 - <table> 192 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 193 - <tr><td> 194 - 195 - ```typespec 196 - model Post { 197 - @doc("The primary post content.") 198 - @maxGraphemes(300) 199 - @maxLength(3000) 200 - @required 201 - text: string; 202 - 203 - @maxGraphemes(64) 204 - @maxLength(640) 205 - displayName?: string; 206 - } 207 - ``` 208 - 209 - </td><td> 210 - 211 - ```json 212 - { 213 - "type": "object", 214 - "required": ["text"], 215 - "properties": { 216 - "text": { 217 - "type": "string", 218 - "description": "The primary post content.", 219 - "maxLength": 3000, 220 - "maxGraphemes": 300 221 - }, 222 - "displayName": { 223 - "type": "string", 224 - "maxLength": 640, 225 - "maxGraphemes": 64 226 - } 227 - } 228 - } 229 - ``` 230 - 231 - </td></tr> 232 - </table> 233 - 234 - **Available:** `@maxLength` (bytes), `@minLength`, `@maxGraphemes` (characters), `@minGraphemes` 235 - 236 - ### Number Constraints 237 - 238 - <table> 239 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 240 - <tr><td> 241 - 242 - ```typespec 243 - model JobStatus { 244 - @doc("Progress within the current processing state.") 245 - @minValue(0) 246 - @maxValue(100) 247 - progress?: integer; 248 - } 249 - ``` 250 - 251 - </td><td> 252 - 253 - ```json 254 - { 255 - "type": "object", 256 - "properties": { 257 - "progress": { 258 - "type": "integer", 259 - "description": "Progress within the current processing state.", 260 - "minimum": 0, 261 - "maximum": 100 262 - } 263 - } 264 - } 265 - ``` 266 - 267 - </td></tr> 268 - </table> 269 - 270 - ### Default Values 271 - 272 - <table> 273 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 274 - <tr><td> 275 - 276 - ```typespec 277 - @query 278 - op main( 279 - @minValue(1) 280 - @maxValue(100) 281 - limit?: int32 = 50, 282 - 283 - cursor?: string 284 - ): { /* ... */ }; 285 - ``` 286 - 287 - </td><td> 288 - 289 - ```json 290 - { 291 - "type": "query", 292 - "parameters": { 293 - "type": "params", 294 - "properties": { 295 - "limit": { 296 - "type": "integer", 297 - "minimum": 1, 298 - "maximum": 100, 299 - "default": 50 300 - }, 301 - "cursor": { "type": "string" } 302 - } 303 - } 304 - } 305 - ``` 306 - 307 - </td></tr> 308 - </table> 309 - 310 - ### Constant Values 311 - 312 - Use `@readOnly` with a default value to create constant fields: 313 - 314 - <table> 315 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 316 - <tr><td> 317 - 318 - ```typespec 319 - model NotFoundPost { 320 - @required uri: atUri; 321 - 322 - @readOnly 323 - @required 324 - notFound: boolean = true; 325 - } 326 - 327 - model Config { 328 - @readOnly 329 - version: string = "1.0"; 330 - 331 - @readOnly 332 - maxRetries: integer = 3; 333 - } 334 - ``` 335 - 336 - </td><td> 337 - 338 - ```json 339 - { 340 - "notFoundPost": { 341 - "type": "object", 342 - "required": ["uri", "notFound"], 343 - "properties": { 344 - "uri": { "type": "string", "format": "at-uri" }, 345 - "notFound": { "type": "boolean", "const": true } 346 - } 347 - }, 348 - "config": { 349 - "type": "object", 350 - "properties": { 351 - "version": { "type": "string", "const": "1.0" }, 352 - "maxRetries": { "type": "integer", "const": 3 } 353 - } 354 - } 355 - } 356 - ``` 357 - 358 - </td></tr> 359 - </table> 360 - 361 - **Important:** `@readOnly` emits `const` (not `default`). Only valid for `string`, `boolean`, and `integer` types. 362 - 363 - ## Arrays 364 - 365 - <table> 366 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 367 - <tr><td> 368 - 369 - ```typespec 370 - model Post { 371 - @doc("Annotations of text (mentions, URLs, hashtags, etc)") 372 - facets?: app.bsky.richtext.facet.Main[]; 373 - 374 - @doc("Indicates human language of post primary text content.") 375 - @maxItems(3) 376 - langs?: language[]; 377 - 378 - @doc("Additional hashtags.") 379 - @maxItems(8) 380 - tags?: PostTag[]; 381 - } 382 - ``` 383 - 384 - </td><td> 385 - 386 - ```json 387 - { 388 - "type": "object", 389 - "properties": { 390 - "facets": { 391 - "type": "array", 392 - "description": "Annotations of text (mentions, URLs, hashtags, etc)", 393 - "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 394 - }, 395 - "langs": { 396 - "type": "array", 397 - "description": "Indicates human language of post primary text content.", 398 - "maxLength": 3, 399 - "items": { "type": "string", "format": "language" } 400 - }, 401 - "tags": { 402 - "type": "array", 403 - "description": "Additional hashtags.", 404 - "maxLength": 8, 405 - "items": { "type": "string" } 406 - } 407 - } 408 - } 409 - ``` 410 - 411 - </td></tr> 412 - </table> 413 - 414 - ## References 415 - 416 - ### Same Namespace 417 - 418 - <table> 419 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 420 - <tr><td> 421 - 422 - ```typespec 423 - namespace app.bsky.feed.post { 424 - model Main { 425 - reply?: ReplyRef; 426 - @required createdAt: datetime; 427 - } 428 - 429 - model ReplyRef { 430 - @required root: com.atproto.repo.strongRef.Main; 431 - @required parent: com.atproto.repo.strongRef.Main; 432 - } 433 - } 434 - ``` 435 - 436 - </td><td> 437 - 438 - ```json 439 - { 440 - "lexicon": 1, 441 - "id": "app.bsky.feed.post", 442 - "defs": { 443 - "main": { 444 - "type": "object", 445 - "required": ["createdAt"], 446 - "properties": { 447 - "reply": { "type": "ref", "ref": "#replyRef" }, 448 - "createdAt": { "type": "string", "format": "datetime" } 449 - } 450 - }, 451 - "replyRef": { 452 - "type": "object", 453 - "required": ["root", "parent"], 454 - "properties": { 455 - "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 456 - "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 457 - } 458 - } 459 - } 460 - } 461 - ``` 462 - 463 - </td></tr> 464 - </table> 465 - 466 - **Note:** Same-namespace refs use `#defName` format. 467 - 468 - ### Cross Namespace 469 - 470 - <table> 471 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 472 - <tr><td> 473 - 474 - ```typespec 475 - model ProfileViewBasic { 476 - @required did: did; 477 - @required handle: handle; 478 - 479 - // Reference to another namespace 480 - labels?: com.atproto.label.defs.Label[]; 481 - } 482 - ``` 483 - 484 - </td><td> 485 - 486 - ```json 487 - { 488 - "type": "object", 489 - "required": ["did", "handle"], 490 - "properties": { 491 - "did": { "type": "string", "format": "did" }, 492 - "handle": { "type": "string", "format": "handle" }, 493 - "labels": { 494 - "type": "array", 495 - "items": { 496 - "type": "ref", 497 - "ref": "com.atproto.label.defs#label" 498 - } 499 - } 500 - } 501 - } 502 - ``` 503 - 504 - </td></tr> 505 - </table> 506 - 507 - **Rules:** 508 - - `namespace.Main` → `"namespace"` (main def) 509 - - `namespace.defs.Model` → `"namespace.defs#model"` (named def) 510 - 511 - ## Strings with Known Values 512 - 513 - ### Open Enums (Inline, Property Level) 514 - 515 - <table> 516 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 517 - <tr><td> 518 - 519 - ```typespec 520 - model JobStatus { 521 - @doc("The state of the video processing job.") 522 - @required 523 - state: "JOB_STATE_COMPLETED" | "JOB_STATE_FAILED" | string; 524 - } 525 - 526 - model CreateResult { 527 - validationStatus?: "valid" | "unknown" | string; 528 - } 529 - ``` 530 - 531 - </td><td> 532 - 533 - ```json 534 - { 535 - "jobStatus": { 536 - "type": "object", 537 - "required": ["state"], 538 - "properties": { 539 - "state": { 540 - "type": "string", 541 - "description": "The state of the video processing job.", 542 - "knownValues": ["JOB_STATE_COMPLETED", "JOB_STATE_FAILED"] 543 - } 544 - } 545 - }, 546 - "createResult": { 547 - "type": "object", 548 - "properties": { 549 - "validationStatus": { 550 - "type": "string", 551 - "knownValues": ["valid", "unknown"] 552 - } 553 - } 554 - } 555 - } 556 - ``` 557 - 558 - </td></tr> 559 - </table> 560 - 561 - **Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients. 562 - 563 - ### Closed Enums (Inline, Rare) 564 - 565 - For **truly fixed** string sets that will never change: 566 - 567 - <table> 568 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 569 - <tr><td> 570 - 571 - ```typespec 572 - model Config { 573 - @closed 574 - @inline 575 - union Status { 576 - "draft", 577 - "published", 578 - "archived", 579 - } 580 - 581 - @doc("Current status - fixed set") 582 - @required 583 - status: Status; 584 - } 585 - ``` 586 - 587 - </td><td> 588 - 589 - ```json 590 - { 591 - "type": "object", 592 - "required": ["status"], 593 - "properties": { 594 - "status": { 595 - "type": "string", 596 - "enum": ["draft", "published", "archived"], 597 - "description": "Current status - fixed set" 598 - } 599 - } 600 - } 601 - ``` 602 - 603 - </td></tr> 604 - </table> 605 - 606 - **Note:** Closed enums use `enum` (not `knownValues`). Only use for values that will **never** expand. 607 - 608 - ### Named (Def Level) 609 - 610 - <table> 611 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 612 - <tr><td> 613 - 614 - ```typespec 615 - namespace com.atproto.label.defs { 616 - union LabelValue { 617 - "!hide", 618 - "!no-promote", 619 - "!warn", 620 - "porn", 621 - "sexual", 622 - string, 623 - } 624 - } 625 - ``` 626 - 627 - </td><td> 628 - 629 - ```json 630 - { 631 - "lexicon": 1, 632 - "id": "com.atproto.label.defs", 633 - "defs": { 634 - "labelValue": { 635 - "type": "string", 636 - "knownValues": ["!hide", "!no-promote", "!warn", "porn", "sexual"] 637 - } 638 - } 639 - } 640 - ``` 641 - 642 - </td></tr> 643 - </table> 644 - 645 - ## Unions 646 - 647 - ### Open Unions (Atproto Default) 648 - 649 - Open unions are the **standard pattern in atproto** - always include `unknown`: 650 - 651 - <table> 652 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 653 - <tr><td> 654 - 655 - ```typespec 656 - model Post { 657 - // Inline open union - most common pattern 658 - embed?: ( 659 - | app.bsky.embed.images.Main 660 - | app.bsky.embed.video.Main 661 - | app.bsky.embed.external.Main 662 - | app.bsky.embed.record.Main 663 - | app.bsky.embed.recordWithMedia.Main 664 - | unknown 665 - ); 666 - 667 - // Single union member (also needs unknown) 668 - labels?: (com.atproto.label.defs.SelfLabels | unknown); 669 - } 670 - ``` 671 - 672 - </td><td> 673 - 674 - ```json 675 - { 676 - "type": "object", 677 - "properties": { 678 - "embed": { 679 - "type": "union", 680 - "refs": [ 681 - "app.bsky.embed.images", 682 - "app.bsky.embed.video", 683 - "app.bsky.embed.external", 684 - "app.bsky.embed.record", 685 - "app.bsky.embed.recordWithMedia" 686 - ] 687 - }, 688 - "labels": { 689 - "type": "union", 690 - "refs": ["com.atproto.label.defs#selfLabels"] 691 - } 692 - } 693 - } 694 - ``` 695 - 696 - </td></tr> 697 - </table> 698 - 699 - **Note:** `unknown` signals extensibility but doesn't appear in `refs`. This allows future variants without breaking old clients. 700 - 701 - <table> 702 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 703 - <tr><td> 704 - 705 - ```typespec 706 - namespace app.bsky.actor.defs { 707 - // Named open union for reuse 708 - union Preferences { 709 - AdultContentPref, 710 - ContentLabelPref, 711 - SavedFeedsPref, 712 - PersonalDetailsPref, 713 - FeedViewPref, 714 - ThreadViewPref, 715 - InterestsPref, 716 - unknown, 717 - } 718 - 719 - model AdultContentPref { 720 - @required enabled: boolean; 721 - } 722 - 723 - model ContentLabelPref { 724 - @required labelerDid?: did; 725 - @required label: string; 726 - @required visibility: string; 727 - } 728 - 729 - // ... more variants 730 - } 731 - ``` 732 - 733 - </td><td> 734 - 735 - ```json 736 - { 737 - "lexicon": 1, 738 - "id": "app.bsky.actor.defs", 739 - "defs": { 740 - "preferences": { 741 - "type": "array", 742 - "items": { 743 - "type": "union", 744 - "refs": [ 745 - "#adultContentPref", 746 - "#contentLabelPref", 747 - "#savedFeedsPref", 748 - "#personalDetailsPref", 749 - "#feedViewPref", 750 - "#threadViewPref", 751 - "#interestsPref" 752 - ] 753 - } 754 - }, 755 - "adultContentPref": { 756 - "type": "object", 757 - "required": ["enabled"], 758 - "properties": { 759 - "enabled": { "type": "boolean" } 760 - } 761 - }, 762 - "contentLabelPref": { 763 - "type": "object", 764 - "required": ["label", "visibility"], 765 - "properties": { 766 - "labelerDid": { "type": "string", "format": "did" }, 767 - "label": { "type": "string" }, 768 - "visibility": { "type": "string" } 769 - } 770 - } 771 - } 772 - } 773 - ``` 774 - 775 - </td></tr> 776 - </table> 777 - 778 - **Note:** Named unions auto-wrap in array at def level. 779 - 780 - ### Closed Unions (Rare) 781 - 782 - Only use for **fixed internal operations** where the set is guaranteed never to change: 783 - 784 - <table> 785 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 786 - <tr><td> 787 - 788 - ```typespec 789 - namespace com.atproto.repo.applyWrites { 790 - @closed 791 - @inline 792 - union WriteAction { 793 - Create, 794 - Update, 795 - Delete, 796 - } 797 - 798 - @closed 799 - @inline 800 - union WriteResult { 801 - CreateResult, 802 - UpdateResult, 803 - DeleteResult, 804 - } 805 - 806 - @procedure 807 - op main(input: { 808 - @required repo: atIdentifier; 809 - 810 - // Closed union - write operations are fixed 811 - @required 812 - writes: WriteAction[]; 813 - }): { 814 - results?: WriteResult[]; 815 - }; 816 - 817 - model Create { 818 - @required collection: nsid; 819 - rkey?: recordKey; 820 - @required value: unknown; 821 - } 822 - 823 - model Update { 824 - @required collection: nsid; 825 - @required rkey: recordKey; 826 - @required value: unknown; 827 - } 828 - 829 - model Delete { 830 - @required collection: nsid; 831 - @required rkey: recordKey; 832 - } 833 - 834 - model CreateResult { 835 - @required uri: atUri; 836 - @required cid: cid; 837 - } 838 - 839 - model UpdateResult { 840 - @required uri: atUri; 841 - @required cid: cid; 842 - } 843 - 844 - model DeleteResult {} 845 - } 846 - ``` 847 - 848 - </td><td> 849 - 850 - ```json 851 - { 852 - "lexicon": 1, 853 - "id": "com.atproto.repo.applyWrites", 854 - "defs": { 855 - "main": { 856 - "type": "procedure", 857 - "input": { 858 - "encoding": "application/json", 859 - "schema": { 860 - "type": "object", 861 - "required": ["repo", "writes"], 862 - "properties": { 863 - "repo": { "type": "string", "format": "at-identifier" }, 864 - "writes": { 865 - "type": "array", 866 - "items": { 867 - "type": "union", 868 - "closed": true, 869 - "refs": ["#create", "#update", "#delete"] 870 - } 871 - } 872 - } 873 - } 874 - }, 875 - "output": { 876 - "encoding": "application/json", 877 - "schema": { 878 - "type": "object", 879 - "properties": { 880 - "results": { 881 - "type": "array", 882 - "items": { 883 - "type": "union", 884 - "closed": true, 885 - "refs": ["#createResult", "#updateResult", "#deleteResult"] 886 - } 887 - } 888 - } 889 - } 890 - } 891 - }, 892 - "create": { 893 - "type": "object", 894 - "required": ["collection", "value"], 895 - "properties": { 896 - "collection": { "type": "string", "format": "nsid" }, 897 - "rkey": { "type": "string", "format": "record-key" }, 898 - "value": { "type": "unknown" } 899 - } 900 - }, 901 - "update": { 902 - "type": "object", 903 - "required": ["collection", "rkey", "value"], 904 - "properties": { 905 - "collection": { "type": "string", "format": "nsid" }, 906 - "rkey": { "type": "string", "format": "record-key" }, 907 - "value": { "type": "unknown" } 908 - } 909 - }, 910 - "delete": { 911 - "type": "object", 912 - "required": ["collection", "rkey"], 913 - "properties": { 914 - "collection": { "type": "string", "format": "nsid" }, 915 - "rkey": { "type": "string", "format": "record-key" } 916 - } 917 - } 918 - } 919 - } 920 - ``` 921 - 922 - </td></tr> 923 - </table> 924 - 925 - **When to use closed unions:** 926 - - Internal server operations (like applyWrites) 927 - - Batch operations with fixed types 928 - - NOT for user-facing content or records 929 - 930 - **Note:** Use `@closed @inline` together to create a closed union that stays inline instead of becoming a separate def. 931 - 932 - ### Empty Union 933 - 934 - No current refs, but open to future types: 935 - 936 - <table> 937 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 938 - <tr><td> 939 - 940 - ```typespec 941 - model SubjectView { 942 - @required subject: string; 943 - // Empty union - no known types yet 944 - profile?: (never | unknown); 945 - } 946 - ``` 947 - 948 - </td><td> 949 - 950 - ```json 951 - { 952 - "type": "object", 953 - "required": ["subject"], 954 - "properties": { 955 - "subject": { "type": "string" }, 956 - "profile": { 957 - "type": "union", 958 - "refs": [] 959 - } 960 - } 961 - } 962 - ``` 963 - 964 - </td></tr> 965 - </table> 966 - 967 - **Why `never | unknown`?** Single `unknown` emits `{ type: "unknown" }`. Adding `never` forces union type with no refs. 968 - 969 - ### Single Reference (Not a Union) 970 - 971 - <table> 972 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 973 - <tr><td> 974 - 975 - ```typespec 976 - model ReplyRef { 977 - // Single type - not a union 978 - @required root: com.atproto.repo.strongRef.Main; 979 - @required parent: com.atproto.repo.strongRef.Main; 980 - } 981 - ``` 982 - 983 - </td><td> 984 - 985 - ```json 986 - { 987 - "type": "object", 988 - "required": ["root", "parent"], 989 - "properties": { 990 - "root": { 991 - "type": "ref", 992 - "ref": "com.atproto.repo.strongRef" 993 - }, 994 - "parent": { 995 - "type": "ref", 996 - "ref": "com.atproto.repo.strongRef" 997 - } 998 - } 999 - } 1000 - ``` 1001 - 1002 - </td></tr> 1003 - </table> 1004 - 1005 - **Note:** Single type without `|` creates a ref, not a union. 1006 - 1007 - ## Binary Data 1008 - 1009 - ### Blobs 1010 - 1011 - <table> 1012 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1013 - <tr><td> 1014 - 1015 - ```typespec 1016 - model Image { 1017 - @required image: Blob<#["image/*"], 1000000>; 1018 - thumb?: Blob<#["image/png", "image/jpeg"], 256000>; 1019 - } 1020 - 1021 - model Video { 1022 - @required video: Blob<#["video/mp4"], 50000000>; 1023 - // 0 = no size limit 1024 - captions?: Blob<#["text/vtt"], 0>; 1025 - } 1026 - ``` 1027 - 1028 - </td><td> 1029 - 1030 - ```json 1031 - { 1032 - "image": { 1033 - "type": "object", 1034 - "required": ["image"], 1035 - "properties": { 1036 - "image": { 1037 - "type": "blob", 1038 - "accept": ["image/*"], 1039 - "maxSize": 1000000 1040 - }, 1041 - "thumb": { 1042 - "type": "blob", 1043 - "accept": ["image/png", "image/jpeg"], 1044 - "maxSize": 256000 1045 - } 1046 - } 1047 - }, 1048 - "video": { 1049 - "type": "object", 1050 - "required": ["video"], 1051 - "properties": { 1052 - "video": { 1053 - "type": "blob", 1054 - "accept": ["video/mp4"], 1055 - "maxSize": 50000000 1056 - }, 1057 - "captions": { 1058 - "type": "blob", 1059 - "accept": ["text/vtt"], 1060 - "maxSize": 0 1061 - } 1062 - } 1063 - } 1064 - } 1065 - ``` 1066 - 1067 - </td></tr> 1068 - </table> 1069 - 1070 - **Syntax:** `Blob<#[mimeType1, mimeType2, ...], maxSizeInBytes>` 1071 - 1072 - ### Bytes 1073 - 1074 - <table> 1075 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1076 - <tr><td> 1077 - 1078 - ```typespec 1079 - model Label { 1080 - @required val: string; 1081 - sig?: bytes; 1082 - } 1083 - ``` 1084 - 1085 - </td><td> 1086 - 1087 - ```json 1088 - { 1089 - "type": "object", 1090 - "required": ["val"], 1091 - "properties": { 1092 - "val": { "type": "string" }, 1093 - "sig": { "type": "bytes" } 1094 - } 1095 - } 1096 - ``` 1097 - 1098 - </td></tr> 1099 - </table> 1100 - 1101 - ## Records 1102 - 1103 - <table> 1104 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1105 - <tr><td> 1106 - 1107 - ```typespec 1108 - namespace app.bsky.feed.post { 1109 - @record("tid") 1110 - @doc("Record containing a Bluesky post.") 1111 - model Main { 1112 - @doc("The primary post content.") 1113 - @maxGraphemes(300) 1114 - @maxLength(3000) 1115 - @required 1116 - text: string; 1117 - 1118 - @doc("Annotations of text (mentions, URLs, hashtags, etc)") 1119 - facets?: app.bsky.richtext.facet.Main[]; 1120 - 1121 - reply?: ReplyRef; 1122 - 1123 - embed?: ( 1124 - | app.bsky.embed.images.Main 1125 - | app.bsky.embed.external.Main 1126 - | app.bsky.embed.record.Main 1127 - | unknown 1128 - ); 1129 - 1130 - @doc("Client-declared timestamp when this post was originally created.") 1131 - @required 1132 - createdAt: datetime; 1133 - } 1134 - 1135 - model ReplyRef { 1136 - @required root: com.atproto.repo.strongRef.Main; 1137 - @required parent: com.atproto.repo.strongRef.Main; 1138 - } 1139 - } 1140 - ``` 1141 - 1142 - </td><td> 1143 - 1144 - ```json 1145 - { 1146 - "lexicon": 1, 1147 - "id": "app.bsky.feed.post", 1148 - "defs": { 1149 - "main": { 1150 - "type": "record", 1151 - "description": "Record containing a Bluesky post.", 1152 - "key": "tid", 1153 - "record": { 1154 - "type": "object", 1155 - "required": ["text", "createdAt"], 1156 - "properties": { 1157 - "text": { 1158 - "type": "string", 1159 - "description": "The primary post content.", 1160 - "maxLength": 3000, 1161 - "maxGraphemes": 300 1162 - }, 1163 - "facets": { 1164 - "type": "array", 1165 - "description": "Annotations of text (mentions, URLs, hashtags, etc)", 1166 - "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 1167 - }, 1168 - "reply": { "type": "ref", "ref": "#replyRef" }, 1169 - "embed": { 1170 - "type": "union", 1171 - "refs": [ 1172 - "app.bsky.embed.images", 1173 - "app.bsky.embed.external", 1174 - "app.bsky.embed.record" 1175 - ] 1176 - }, 1177 - "createdAt": { 1178 - "type": "string", 1179 - "format": "datetime", 1180 - "description": "Client-declared timestamp when this post was originally created." 1181 - } 1182 - } 1183 - } 1184 - }, 1185 - "replyRef": { 1186 - "type": "object", 1187 - "required": ["root", "parent"], 1188 - "properties": { 1189 - "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 1190 - "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 1191 - } 1192 - } 1193 - } 1194 - } 1195 - ``` 1196 - 1197 - </td></tr> 1198 - </table> 1199 - 1200 - **Valid key types:** `"tid"`, `"self"`, `"nsid"` 1201 - 1202 - ## Operations 1203 - 1204 - ### Query (HTTP GET) 1205 - 1206 - <table> 1207 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1208 - <tr><td> 1209 - 1210 - ```typespec 1211 - namespace app.bsky.bookmark.getBookmarks { 1212 - @doc("Gets views of records bookmarked by the authenticated user.") 1213 - @query 1214 - op main( 1215 - @minValue(1) 1216 - @maxValue(100) 1217 - limit?: int32 = 50, 1218 - 1219 - cursor?: string 1220 - ): { 1221 - cursor?: string; 1222 - @required bookmarks: app.bsky.bookmark.defs.BookmarkView[]; 1223 - }; 1224 - } 1225 - ``` 1226 - 1227 - </td><td> 1228 - 1229 - ```json 1230 - { 1231 - "lexicon": 1, 1232 - "id": "app.bsky.bookmark.getBookmarks", 1233 - "defs": { 1234 - "main": { 1235 - "type": "query", 1236 - "description": "Gets views of records bookmarked by the authenticated user.", 1237 - "parameters": { 1238 - "type": "params", 1239 - "properties": { 1240 - "limit": { 1241 - "type": "integer", 1242 - "minimum": 1, 1243 - "maximum": 100, 1244 - "default": 50 1245 - }, 1246 - "cursor": { "type": "string" } 1247 - } 1248 - }, 1249 - "output": { 1250 - "encoding": "application/json", 1251 - "schema": { 1252 - "type": "object", 1253 - "required": ["bookmarks"], 1254 - "properties": { 1255 - "cursor": { "type": "string" }, 1256 - "bookmarks": { 1257 - "type": "array", 1258 - "items": { 1259 - "type": "ref", 1260 - "ref": "app.bsky.bookmark.defs#bookmarkView" 1261 - } 1262 - } 1263 - } 1264 - } 1265 - } 1266 - } 1267 - } 1268 - } 1269 - ``` 1270 - 1271 - </td></tr> 1272 - </table> 1273 - 1274 - **Atproto idiom:** Queries typically have optional `limit` (with default) and `cursor` params for pagination. 1275 - 1276 - ### Procedure (HTTP POST) 1277 - 1278 - <table> 1279 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1280 - <tr><td> 1281 - 1282 - ```typespec 1283 - namespace app.bsky.actor.putPreferences { 1284 - @doc("Set the private preferences attached to the account.") 1285 - @procedure 1286 - op main(input: { 1287 - @required preferences: app.bsky.actor.defs.Preferences; 1288 - }): void; 1289 - } 1290 - ``` 1291 - 1292 - </td><td> 1293 - 1294 - ```json 1295 - { 1296 - "lexicon": 1, 1297 - "id": "app.bsky.actor.putPreferences", 1298 - "defs": { 1299 - "main": { 1300 - "type": "procedure", 1301 - "description": "Set the private preferences attached to the account.", 1302 - "input": { 1303 - "encoding": "application/json", 1304 - "schema": { 1305 - "type": "object", 1306 - "required": ["preferences"], 1307 - "properties": { 1308 - "preferences": { 1309 - "type": "ref", 1310 - "ref": "app.bsky.actor.defs#preferences" 1311 - } 1312 - } 1313 - } 1314 - } 1315 - } 1316 - } 1317 - } 1318 - ``` 1319 - 1320 - </td></tr> 1321 - </table> 1322 - 1323 - **Rules:** 1324 - - 1 param: must be named `input` 1325 - - 2 params: must be named `input` and `parameters` (in order) 1326 - - 3+ params: error 1327 - 1328 - ### Subscription (WebSocket) 1329 - 1330 - <table> 1331 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1332 - <tr><td> 1333 - 1334 - ```typespec 1335 - namespace com.atproto.sync.subscribeRepos { 1336 - @doc("Subscribe to repo updates.") 1337 - @subscription 1338 - op main(cursor?: integer): (Commit | Handle | Identity | Tombstone | Info); 1339 - 1340 - model Commit { 1341 - @required seq: integer; 1342 - @required rebase: boolean; 1343 - @required repo: did; 1344 - @required commit: cid; 1345 - } 1346 - 1347 - model Handle { 1348 - @required seq: integer; 1349 - @required did: did; 1350 - @required handle: handle; 1351 - } 1352 - 1353 - // ... other message types 1354 - } 1355 - ``` 1356 - 1357 - </td><td> 1358 - 1359 - ```json 1360 - { 1361 - "lexicon": 1, 1362 - "id": "com.atproto.sync.subscribeRepos", 1363 - "defs": { 1364 - "main": { 1365 - "type": "subscription", 1366 - "description": "Subscribe to repo updates.", 1367 - "parameters": { 1368 - "type": "params", 1369 - "properties": { 1370 - "cursor": { "type": "integer" } 1371 - } 1372 - }, 1373 - "message": { 1374 - "schema": { 1375 - "type": "union", 1376 - "refs": ["#commit", "#handle", "#identity", "#tombstone", "#info"] 1377 - } 1378 - } 1379 - }, 1380 - "commit": { 1381 - "type": "object", 1382 - "required": ["seq", "rebase", "repo", "commit"], 1383 - "properties": { 1384 - "seq": { "type": "integer" }, 1385 - "rebase": { "type": "boolean" }, 1386 - "repo": { "type": "string", "format": "did" }, 1387 - "commit": { "type": "string", "format": "cid" } 1388 - } 1389 - }, 1390 - "handle": { 1391 - "type": "object", 1392 - "required": ["seq", "did", "handle"], 1393 - "properties": { 1394 - "seq": { "type": "integer" }, 1395 - "did": { "type": "string", "format": "did" }, 1396 - "handle": { "type": "string", "format": "handle" } 1397 - } 1398 - } 1399 - } 1400 - } 1401 - ``` 1402 - 1403 - </td></tr> 1404 - </table> 1405 - 1406 - **Note:** Return type must be a union. 1407 - 1408 - ### Custom Encodings 1409 - 1410 - <table> 1411 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1412 - <tr><td> 1413 - 1414 - ```typespec 1415 - namespace app.bsky.video.uploadVideo { 1416 - @doc("Upload a video to be processed.") 1417 - @procedure 1418 - op main( 1419 - @encoding("video/mp4") 1420 - input: void 1421 - ): { 1422 - @required jobStatus: app.bsky.video.defs.JobStatus; 1423 - }; 1424 - } 1425 - ``` 1426 - 1427 - </td><td> 1428 - 1429 - ```json 1430 - { 1431 - "lexicon": 1, 1432 - "id": "app.bsky.video.uploadVideo", 1433 - "defs": { 1434 - "main": { 1435 - "type": "procedure", 1436 - "description": "Upload a video to be processed.", 1437 - "input": { 1438 - "encoding": "video/mp4" 1439 - }, 1440 - "output": { 1441 - "encoding": "application/json", 1442 - "schema": { 1443 - "type": "object", 1444 - "required": ["jobStatus"], 1445 - "properties": { 1446 - "jobStatus": { 1447 - "type": "ref", 1448 - "ref": "app.bsky.video.defs#jobStatus" 1449 - } 1450 - } 1451 - } 1452 - } 1453 - } 1454 - } 1455 - } 1456 - ``` 1457 - 1458 - </td></tr> 1459 - </table> 1460 - 1461 - **Rules:** 1462 - - `@encoding` on `input` param → input encoding 1463 - - `@encoding` on operation → output encoding 1464 - - `void` type → no schema field (encoding only) 1465 - - Default encoding: `application/json` 1466 - 1467 - ### Operation Errors 1468 - 1469 - <table> 1470 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1471 - <tr><td> 1472 - 1473 - ```typespec 1474 - namespace com.atproto.repo.applyWrites { 1475 - @doc("Indicates that the 'swapCommit' parameter did not match current commit.") 1476 - model InvalidSwap {} 1477 - 1478 - @procedure 1479 - @errors(InvalidSwap) 1480 - op main(input: { /* ... */ }): { /* ... */ }; 1481 - } 1482 - ``` 1483 - 1484 - </td><td> 1485 - 1486 - ```json 1487 - { 1488 - "lexicon": 1, 1489 - "id": "com.atproto.repo.applyWrites", 1490 - "defs": { 1491 - "main": { 1492 - "type": "procedure", 1493 - "errors": [ 1494 - { 1495 - "name": "InvalidSwap", 1496 - "description": "Indicates that the 'swapCommit' parameter did not match current commit." 1497 - } 1498 - ] 1499 - } 1500 - } 1501 - } 1502 - ``` 1503 - 1504 - </td></tr> 1505 - </table> 1506 - 1507 - **Note:** Error models are empty (only name and optional `@doc`). They're not emitted as defs. 1508 - 1509 - ## Tokens 1510 - 1511 - <table> 1512 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1513 - <tr><td> 1514 - 1515 - ```typespec 1516 - namespace com.atproto.moderation.defs { 1517 - @doc("Spam: frequent unwanted promotion, replies, mentions.") 1518 - @token 1519 - model ReasonSpam {} 1520 - 1521 - @doc("Direct violation of server rules, laws, terms of service.") 1522 - @token 1523 - model ReasonViolation {} 1524 - } 1525 - ``` 1526 - 1527 - </td><td> 1528 - 1529 - ```json 1530 - { 1531 - "lexicon": 1, 1532 - "id": "com.atproto.moderation.defs", 1533 - "defs": { 1534 - "reasonSpam": { 1535 - "type": "token", 1536 - "description": "Spam: frequent unwanted promotion, replies, mentions." 1537 - }, 1538 - "reasonViolation": { 1539 - "type": "token", 1540 - "description": "Direct violation of server rules, laws, terms of service." 1541 - } 1542 - } 1543 - } 1544 - ``` 1545 - 1546 - </td></tr> 1547 - </table> 1548 - 1549 - **Note:** Tokens become string constants in TypeScript, not types. 1550 - 1551 - ## Custom Scalars 1552 - 1553 - Define reusable constrained types: 1554 - 1555 - <table> 1556 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1557 - <tr><td> 1558 - 1559 - ```typespec 1560 - @maxGraphemes(64) 1561 - @maxLength(640) 1562 - scalar PostTag extends string; 1563 - 1564 - model Post { 1565 - @maxItems(8) 1566 - tags?: PostTag[]; 1567 - } 1568 - ``` 1569 - 1570 - </td><td> 1571 - 1572 - ```json 1573 - { 1574 - "post": { 1575 - "type": "object", 1576 - "properties": { 1577 - "tags": { 1578 - "type": "array", 1579 - "maxLength": 8, 1580 - "items": { 1581 - "type": "string", 1582 - "maxLength": 640, 1583 - "maxGraphemes": 64 1584 - } 1585 - } 1586 - } 1587 - } 1588 - } 1589 - ``` 1590 - 1591 - </td></tr> 1592 - </table> 1593 - 1594 - **Note:** Constraints are inlined at usage site - no separate def is created. 1595 - 1596 - ## Documentation 1597 - 1598 - <table> 1599 - <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1600 - <tr><td> 1601 - 1602 - ```typespec 1603 - @doc("A URI with a content-hash fingerprint.") 1604 - namespace com.atproto.repo.strongRef { 1605 - @doc("A strong reference to a specific version of a record.") 1606 - model Main { 1607 - @doc("The AT-URI of the record.") 1608 - @required 1609 - uri: atUri; 1610 - 1611 - @doc("The CID of the record.") 1612 - @required 1613 - cid: cid; 1614 - } 1615 - } 1616 - ``` 1617 - 1618 - </td><td> 1619 - 1620 - ```json 1621 - { 1622 - "lexicon": 1, 1623 - "id": "com.atproto.repo.strongRef", 1624 - "description": "A URI with a content-hash fingerprint.", 1625 - "defs": { 1626 - "main": { 1627 - "type": "object", 1628 - "description": "A strong reference to a specific version of a record.", 1629 - "required": ["uri", "cid"], 1630 - "properties": { 1631 - "uri": { 1632 - "type": "string", 1633 - "format": "at-uri", 1634 - "description": "The AT-URI of the record." 1635 - }, 1636 - "cid": { 1637 - "type": "string", 1638 - "format": "cid", 1639 - "description": "The CID of the record." 1640 - } 1641 - } 1642 - } 1643 - } 1644 - } 1645 - ``` 1646 - 1647 - </td></tr> 1648 - </table> 1649 - 1650 - ## Code Style 1651 - 1652 - ### Decorator Placement 1653 - 1654 - ```typespec 1655 - model Example { 1656 - @required name: string; // ✅ @required alone on same line 1657 - 1658 - @maxLength(100) // ✅ Other decorators on separate lines 1659 - @required 1660 - description: string; 1661 - 1662 - @doc("Optional field") // ✅ Blank line after multi-line block 1663 - tag?: string; 1664 - 1665 - @required @maxLength(100) bad: string; // ❌ Multiple decorators on one line 1666 - @doc("Bad") @required bad2: string; // ❌ Non-@required on same line 1667 - } 1668 - ``` 1669 - 1670 - ### Union Formatting 1671 - 1672 - ```typespec 1673 - // String + known values: string first 1674 - union LabelValue { 1675 - "!hide", 1676 - "porn", 1677 - "sexual", 1678 - string, 1679 - } 1680 - 1681 - // With named variants: string first, blank lines between groups 1682 - union ReasonType { 1683 - string, 1684 - 1685 - ReasonSpam: "com.atproto.moderation.defs#reasonSpam", 1686 - ReasonViolation: "com.atproto.moderation.defs#reasonViolation", 1687 - 1688 - ToolsOzoneAppeal: "tools.ozone.report.defs#reasonAppeal", 1689 - } 1690 - ``` 1691 - 1692 - ## Atproto Idioms Summary 1693 - 1694 - **Required fields are rare:** 1695 - - Use `@required` only for core identifiers (did, handle), timestamps (createdAt), and critical operation params 1696 - - Most profile/view fields should be optional for forward compatibility 1697 - 1698 - **Open unions everywhere:** 1699 - - Always include `| unknown` in unions 1700 - - Allows adding new variants without breaking old clients 1701 - - Only use closed unions for internal fixed operations (like applyWrites) 1702 - 1703 - **Pagination pattern:** 1704 - - Queries: optional `limit?: int32 = 50` and `cursor?: string` params 1705 - - Results: `cursor?: string` and required items array in output 1706 - 1707 - **Extensible enums:** 1708 - - Always use `"value1" | "value2" | string` pattern 1709 - - Never use plain `"value1" | "value2"` - clients must handle unknown values 1710 - 1711 - ## Quick Reference Table 1712 - 1713 - | Lexicon JSON | TypeSpec | Notes | 1714 - |--------------|----------|-------| 1715 - | `{ "type": "string" }` | `string` | | 1716 - | `{ "type": "string", "format": "did" }` | `did` | Use predefined format scalars | 1717 - | `{ "type": "integer" }` | `integer` | Also: `int32`, `int64`, `float32`, `float64` | 1718 - | `{ "type": "boolean" }` | `boolean` | | 1719 - | `{ "type": "bytes" }` | `bytes` | | 1720 - | `{ "type": "unknown" }` | `unknown` | Rarely used directly | 1721 - | `{ "type": "blob", "accept": [...], "maxSize": N }` | `Blob<#[...], N>` | | 1722 - | `{ "type": "array", "items": T }` | `T[]` | | 1723 - | `{ "type": "object", "required": [...] }` | `model M { @required ... }` | Required is rare | 1724 - | `{ "type": "ref", "ref": "#foo" }` | `Foo` | Same namespace | 1725 - | `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def | 1726 - | `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main | 1727 - | `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** | 1728 - | `{ "type": "union", "refs": [...], "closed": true }` | `@closed @inline union { A, B }` | **Rare - internal ops only** | 1729 - | `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union | 1730 - | `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** | 1731 - | `{ "type": "string", "enum": [...] }` | `@closed @inline union { "a", "b" }` | **Rare - truly fixed strings** | 1732 - | `{ "type": "token" }` | `@token model M {}` | String constant | 1733 - | `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | | 1734 - | `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor | 1735 - | `{ "type": "procedure" }` | `@procedure op main(input: T)` | 1-2 params only | 1736 - | `{ "type": "subscription" }` | `@subscription op main(...)` | Return must be union | 1737 - | `"maxLength": N` | `@maxLength(N)` | String byte length | 1738 - | `"maxGraphemes": N` | `@maxGraphemes(N)` | String char count | 1739 - | `"minimum": N` / `"maximum": N` | `@minValue(N)` / `@maxValue(N)` | Number constraints | 1740 - | `"minLength": N` / `"maxLength": N` (array) | `@minItems(N)` / `@maxItems(N)` | Array constraints | 1741 - | `"default": V` | `field?: T = V` | Default value | 1742 - | `"const": V` | `@readOnly field: T = V` | Constant value (string/boolean/integer only) |