An experimental TypeSpec syntax for Lexicon

sick

+1197 -2397
+1140 -1209
SYNTAX.md
··· 1 - # Atproto Lexicon → TypeSpec Conversion Guide 2 - 3 - This document describes all the conventions for converting atproto lexicon JSON schemas to TypeSpec (`.tsp`) files for use with the tlex emitter. 4 - 5 - ## Verification Status 6 - 7 - ✅ **Verified as idiomatic TypeSpec:** 8 - - Union syntax with `string` variant for extensible enums (officially recommended; old `@knownValues` decorator was deprecated) 9 - - `never` type in unions (built-in TypeSpec intrinsic type) 10 - - `Blob<>` template syntax (uses `valueof` constraints, standard TypeSpec pattern) 11 - - Default value syntax with `= "value"` (standard TypeSpec) 12 - - Model-based unions `(Type1 | Type2)` (standard TypeSpec union syntax) 13 - 14 - ✅ **Verified against TypeScript output:** 15 - - All patterns generate TypeScript that matches the official atproto lexicon codegen 16 - - Union variants get `$type` discriminators 17 - - Known values become `"a" | "b" | (string & {})` pattern 18 - - Tokens become string constants 19 - 20 - 📝 **Custom tlex conventions:** 21 - - `@lexConst` - Custom decorator (TypeSpec has no built-in const decorator) 22 - - `@token` - Custom decorator for atproto token type 23 - - `@record` - Custom decorator for atproto record type 24 - - Atproto format scalars (`did`, `atUri`, etc.) - Custom scalar definitions 25 - 26 - ## Spec-Equivalent Output Normalizations 27 - 28 - When porting lexicons from `../atproto/lexicons`, some inconsistencies in the original JSON can be safely normalized to follow lexicon spec more consistently. These changes are **spec-equivalent** (semantically identical). 29 - 30 - ### Safe Normalizations 31 - 32 - **1. Same-namespace references** 33 - 34 - Original atproto lexicons sometimes use global ref format for same-namespace references. These can be normalized to local format: 35 - 36 - - ❌ Original: `"ref": "app.bsky.actor.defs#savedFeed"` (in app.bsky.actor.defs.json) 37 - - ✅ Normalized: `"ref": "#savedFeed"` 38 - 39 - **Justification:** Lexicon spec allows either format. Local refs are more concise and idiomatic for same-namespace references. 40 - 41 - **Examples from normalization:** 42 - - `app.bsky.actor.defs.json`: Normalized 4 refs (savedFeed, mutedWordTarget, mutedWord, nux) 43 - - `app.bsky.labeler.defs.json`: Normalized 1 ref (labelerPolicies) 44 - - `app.bsky.embed.record.json`: Normalized 1 self-ref (#view in embeds array) 1 + # Lexicon TypeSpec Syntax 45 2 46 - **2. Empty required arrays** 47 - 48 - Original atproto lexicons sometimes include `"required": []` for objects with no required properties. Per lexicon spec, the `required` field is optional: 49 - 50 - - ❌ Original: `"required": []` 51 - - ✅ Normalized: (field omitted) 52 - 53 - **Justification:** Lexicon spec states `required` is optional. Omitting empty arrays is cleaner. 54 - 55 - **Examples from normalization:** 56 - - `app.bsky.actor.defs.json`: Removed empty `required` from `verificationPrefs` and `postInteractionSettingsPref` 57 - 58 - **3. Empty parameters for queries** 59 - 60 - Original atproto lexicons are inconsistent about whether queries with no parameters include an empty `parameters` object. Per lexicon spec, parameters are optional: 61 - 62 - - ❌ Original (inconsistent): `"parameters": { "type": "params", "properties": {} }` 63 - - ✅ Normalized: (field omitted when no parameters) 64 - 65 - **Justification:** The lexicon spec allows omitting parameters when there are none. Omitting improves consistency and reduces noise. 66 - 67 - **Examples from normalization:** 68 - - `app.bsky.actor.getPreferences.json`: Removed empty parameters object 69 - - Queries without parameters (like `describeFeedGenerator`) already omit the field in official lexicons 70 - 71 - ### Non-Safe Changes 72 - 73 - **Do NOT normalize these without explicit approval:** 74 - 75 - 1. **Adding/removing required fields** - Breaking change for clients 76 - 2. **Changing property types** - Incompatible with existing data 77 - 3. **Renaming properties** - Changes the wire format 78 - 4. **Adding new constraints** - May reject previously valid data 79 - 80 - ## Table of Contents 81 - 82 - - [File Structure](#file-structure) 83 - - [Basic Types](#basic-types) 84 - - [Objects and Models](#objects-and-models) 85 - - [Records](#records) 86 - - [Arrays](#arrays) 87 - - [Unions](#unions) 88 - - [Strings with Known Values](#strings-with-known-values) 89 - - [Tokens](#tokens) 90 - - [Blobs and Bytes](#blobs-and-bytes) 91 - - [Constraints and Validation](#constraints-and-validation) 92 - - [References](#references) 93 - - [Documentation](#documentation) 94 - - [Code Style](#code-style) 95 - 96 - --- 3 + Quick reference for converting AT Protocol lexicons to TypeSpec. 97 4 98 5 ## File Structure 99 6 100 - ### Naming Conventions 7 + Every `.tsp` file starts with imports and a namespace: 101 8 102 - **Pattern:** Model names use PascalCase, def names use camelCase 9 + <table> 10 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 11 + <tr><td> 103 12 104 - **TypeSpec:** 105 13 ```typespec 106 - namespace app.bsky.feed.defs { 107 - model PostRef { // PascalCase in TypeSpec 108 - // ... 14 + import "@tlex/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; 109 22 } 110 23 } 111 24 ``` 112 25 113 - **JSON:** 26 + </td><td> 27 + 114 28 ```json 115 29 { 30 + "lexicon": 1, 31 + "id": "app.bsky.feed.like", 116 32 "defs": { 117 - "postRef": { // camelCase in JSON 118 - // ... 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 + } 119 51 } 120 52 } 121 53 } 122 54 ``` 123 55 124 - **Convention enforced by emitter:** 125 - - Model names **must** start with uppercase (PascalCase) 126 - - The emitter will error if you use camelCase: `model badName {}` ❌ 127 - - Emitter automatically converts to camelCase for JSON def keys 128 - - References in TypeSpec use PascalCase: `defs.CommitMeta` 129 - - This includes `Main` models (already PascalCase) 56 + </td></tr> 57 + </table> 130 58 131 - **Example error:** 132 - ``` 133 - Model name "badName" must use PascalCase. Did you mean "BadName"? 134 - ``` 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 135 66 136 - ### Lexicon Files vs Defs Files 67 + ### Primitives 137 68 138 - **Pattern:** Namespace naming determines output structure 69 + <table> 70 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 71 + <tr><td> 139 72 140 - **TypeSpec:** 141 73 ```typespec 142 - // Lexicon with main def (e.g., app.bsky.feed.like.tsp) 143 - namespace app.bsky.feed.like { 144 - @record("tid") 145 - model Main { 146 - // ... 147 - } 148 - } 149 - 150 - // Shared definitions file (e.g., com.atproto.repo.defs.tsp) 151 - namespace com.atproto.repo.defs { 152 - model StrongRef { 153 - // ... 154 - } 74 + model JobStatus { 75 + @required jobId: string; 76 + @required state: string; 77 + progress?: integer; 78 + @required canUpload: boolean; 155 79 } 156 80 ``` 157 81 158 - **Rules:** 159 - - Namespaces ending in `.defs` → creates a defs-only lexicon file with no `main` def 160 - - Namespaces with a `Main` model → creates a lexicon file with `main` def + other defs 161 - - Namespaces without `Main` and not ending in `.defs` → **ERROR** (must be explicit) 82 + </td><td> 162 83 163 - **JSON Output:** 164 84 ```json 165 - // app.bsky.feed.like.json 166 85 { 167 - "lexicon": 1, 168 - "id": "app.bsky.feed.like", 169 - "defs": { 170 - "main": { "type": "record", "key": "tid", "record": {...} } 171 - } 172 - } 173 - 174 - // com.atproto.repo.defs.json 175 - { 176 - "lexicon": 1, 177 - "id": "com.atproto.repo.defs", 178 - "defs": { 179 - "strongRef": {...} 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" } 180 93 } 181 94 } 182 95 ``` 183 96 184 - ### Imports 97 + </td></tr> 98 + </table> 185 99 186 - **Pattern:** Import other TypeSpec files when referencing their types 100 + **Atproto idiom:** Most fields are optional. Only use `@required` when truly necessary. 187 101 188 - **TypeSpec:** 189 - ```typespec 190 - import "@tlex/emitter"; // Always required for decorators 191 - import "./defs.tsp"; // Import sibling files as needed 192 - ``` 193 - 194 - --- 195 - 196 - ## Basic Types 102 + ### AT Protocol Formats 197 103 198 - ### Primitive Scalars 104 + <table> 105 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 106 + <tr><td> 199 107 200 - **Pattern:** Use TypeSpec built-in or custom atproto scalars 201 - 202 - **TypeSpec:** 203 108 ```typespec 204 - name: string 205 - count: integer // or int32, int64, int16, int8 206 - price: float32 // or float64 207 - enabled: boolean 109 + model ProfileViewBasic { 110 + @required did: did; 111 + @required handle: handle; 112 + displayName?: string; 113 + avatar?: uri; 114 + createdAt?: datetime; 115 + } 208 116 ``` 209 117 210 - **JSON:** 118 + </td><td> 119 + 211 120 ```json 212 - { "type": "string" } 213 - { "type": "integer" } 214 - { "type": "number" } 215 - { "type": "boolean" } 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 + } 216 132 ``` 217 133 218 - ### Atproto Format Scalars 134 + </td></tr> 135 + </table> 219 136 220 - **Pattern:** Use pre-defined scalars from `@tlex/emitter` 137 + **Available formats:** `did`, `handle`, `uri`, `atUri`, `cid`, `tid`, `nsid`, `datetime`, `language`, `recordKey`, `atIdentifier` 221 138 222 - **TypeSpec:** 139 + ### Optional vs Required (Atproto Idiom) 140 + 141 + <table> 142 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 143 + <tr><td> 144 + 223 145 ```typespec 224 - did: did // DID identifier 225 - handle: handle // Handle identifier 226 - uri: uri // Generic URI 227 - atUri: atUri // AT-URI 228 - cid: cid // Content identifier 229 - tid: tid // Timestamp identifier 230 - nsid: nsid // Namespace identifier 231 - datetime: datetime // ISO 8601 datetime 232 - language: language // Language code 233 - recordKey: recordKey // Record key 234 - atIdentifier: atIdentifier // AT identifier (DID or handle) 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 + } 235 160 ``` 236 161 237 - **JSON:** 162 + </td><td> 163 + 238 164 ```json 239 - { "type": "string", "format": "did" } 240 - { "type": "string", "format": "handle" } 241 - { "type": "string", "format": "uri" } 242 - { "type": "string", "format": "at-uri" } 243 - { "type": "string", "format": "cid" } 244 - { "type": "string", "format": "tid" } 245 - { "type": "string", "format": "nsid" } 246 - { "type": "string", "format": "datetime" } 247 - { "type": "string", "format": "language" } 248 - { "type": "string", "format": "record-key" } 249 - { "type": "string", "format": "at-identifier" } 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 + } 250 180 ``` 251 181 252 - ### Custom Scalars with Constraints 182 + </td></tr> 183 + </table> 253 184 254 - **Pattern:** Define custom scalars extending built-in types with constraints 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> 255 194 256 - **TypeSpec:** 257 195 ```typespec 258 - @maxLength(640) 259 - @maxGraphemes(64) 260 - scalar Tag extends string; 196 + model Post { 197 + @doc("The primary post content.") 198 + @maxGraphemes(300) 199 + @maxLength(3000) 200 + @required 201 + text: string; 261 202 262 - model Example { 263 - @maxItems(100) 264 - @required 265 - tags: Tag[]; 203 + @maxGraphemes(64) 204 + @maxLength(640) 205 + displayName?: string; 266 206 } 267 207 ``` 268 208 269 - **JSON:** 209 + </td><td> 210 + 270 211 ```json 271 212 { 272 - "tags": { 273 - "type": "array", 274 - "maxLength": 100, 275 - "items": { 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": { 276 223 "type": "string", 277 224 "maxLength": 640, 278 225 "maxGraphemes": 64 ··· 281 228 } 282 229 ``` 283 230 284 - **When to use:** When you need to reuse the same constrained type in multiple places. The constraints are inlined at the usage site - no separate def is created for the scalar. 231 + </td></tr> 232 + </table> 285 233 286 - --- 234 + **Available:** `@maxLength` (bytes), `@minLength`, `@maxGraphemes` (characters), `@minGraphemes` 287 235 288 - ## Objects and Models 236 + ### Number Constraints 289 237 290 - ### Basic Object 238 + <table> 239 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 240 + <tr><td> 291 241 292 - **Pattern:** Define models for object types 293 - 294 - **TypeSpec:** 295 242 ```typespec 296 - model PostRef { 297 - @required uri: atUri; 298 - @required cid: cid; 243 + model JobStatus { 244 + @doc("Progress within the current processing state.") 245 + @minValue(0) 246 + @maxValue(100) 247 + progress?: integer; 299 248 } 300 249 ``` 301 250 302 - **JSON:** 251 + </td><td> 252 + 303 253 ```json 304 254 { 305 255 "type": "object", 306 - "required": ["uri", "cid"], 307 256 "properties": { 308 - "uri": { "type": "string", "format": "at-uri" }, 309 - "cid": { "type": "string", "format": "cid" } 257 + "progress": { 258 + "type": "integer", 259 + "description": "Progress within the current processing state.", 260 + "minimum": 0, 261 + "maximum": 100 262 + } 310 263 } 311 264 } 312 265 ``` 313 266 314 - **TypeScript Output:** 315 - ```typescript 316 - export interface PostRef { 317 - $type?: 'namespace.id#postRef' 318 - uri: string 319 - cid: string 320 - } 321 - ``` 267 + </td></tr> 268 + </table> 322 269 323 - ### Required vs Optional Fields 270 + ### Default Values 324 271 325 - **Pattern:** Use `@required` for required fields, `?` for optional fields 272 + <table> 273 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 274 + <tr><td> 326 275 327 - **TypeSpec:** 328 276 ```typespec 329 - model Profile { 330 - @required displayName: string; 331 - description?: string; // Optional 332 - } 277 + @query 278 + op main( 279 + @minValue(1) 280 + @maxValue(100) 281 + limit?: int32 = 50, 282 + 283 + cursor?: string 284 + ): { /* ... */ }; 333 285 ``` 334 286 335 - **JSON:** 287 + </td><td> 288 + 336 289 ```json 337 290 { 338 - "type": "object", 339 - "required": ["displayName"], 340 - "properties": { 341 - "displayName": { "type": "string" }, 342 - "description": { "type": "string" } 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 + } 343 303 } 344 304 } 345 305 ``` 346 306 347 - **Important:** In atproto, required fields are discouraged. The emitter enforces that all non-optional fields MUST have `@required` decorator to make the requirement explicit and intentional. 307 + </td></tr> 308 + </table> 348 309 349 - ### Inline vs Named Models 350 - 351 - **When to create a separate model:** 352 - - When the object is reused in multiple places 353 - - When the object is complex enough to deserve its own name 354 - - When the object is a union variant (always needs to be named for refs) 355 - 356 - **When to inline:** 357 - - Simple objects used only once 358 - - Can directly write object properties inline 310 + ## Arrays 359 311 360 - Both patterns are valid in TypeSpec and will emit the same JSON if inlined. The choice is primarily for code organization. 312 + <table> 313 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 314 + <tr><td> 361 315 362 - ### Nullable Fields 316 + ```typespec 317 + model Post { 318 + @doc("Annotations of text (mentions, URLs, hashtags, etc)") 319 + facets?: app.bsky.richtext.facet.Main[]; 363 320 364 - **Pattern:** Use union with `null` for nullable fields 321 + @doc("Indicates human language of post primary text content.") 322 + @maxItems(3) 323 + langs?: language[]; 365 324 366 - **TypeSpec:** 367 - ```typespec 368 - model Example { 369 - @required maybeNull: string | null; 325 + @doc("Additional hashtags.") 326 + @maxItems(8) 327 + tags?: PostTag[]; 370 328 } 371 329 ``` 372 330 373 - **JSON:** 331 + </td><td> 332 + 374 333 ```json 375 334 { 376 335 "type": "object", 377 - "required": ["maybeNull"], 378 - "nullable": ["maybeNull"], 379 336 "properties": { 380 - "maybeNull": { "type": "string" } 337 + "facets": { 338 + "type": "array", 339 + "description": "Annotations of text (mentions, URLs, hashtags, etc)", 340 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 341 + }, 342 + "langs": { 343 + "type": "array", 344 + "description": "Indicates human language of post primary text content.", 345 + "maxLength": 3, 346 + "items": { "type": "string", "format": "language" } 347 + }, 348 + "tags": { 349 + "type": "array", 350 + "description": "Additional hashtags.", 351 + "maxLength": 8, 352 + "items": { "type": "string" } 353 + } 381 354 } 382 355 } 383 356 ``` 384 357 385 - --- 358 + </td></tr> 359 + </table> 386 360 387 - ## Records 361 + ## References 388 362 389 - ### Record Type 363 + ### Same Namespace 390 364 391 - **Pattern:** Use `@record(keyType)` decorator on `Main` model 365 + <table> 366 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 367 + <tr><td> 392 368 393 - **TypeSpec:** 394 369 ```typespec 395 - namespace app.bsky.feed.like { 396 - @record("tid") 397 - @doc("Record declaring a 'like' of a piece of subject content.") 370 + namespace app.bsky.feed.post { 398 371 model Main { 399 - @required subject: com.atproto.repo.strongRef.Main; 372 + reply?: ReplyRef; 400 373 @required createdAt: datetime; 401 374 } 375 + 376 + model ReplyRef { 377 + @required root: com.atproto.repo.strongRef.Main; 378 + @required parent: com.atproto.repo.strongRef.Main; 379 + } 402 380 } 403 381 ``` 404 382 405 - **JSON:** 383 + </td><td> 384 + 406 385 ```json 407 386 { 408 387 "lexicon": 1, 409 - "id": "app.bsky.feed.like", 388 + "id": "app.bsky.feed.post", 410 389 "defs": { 411 390 "main": { 412 - "type": "record", 413 - "key": "tid", 414 - "description": "Record declaring a 'like' of a piece of subject content.", 415 - "record": { 416 - "type": "object", 417 - "required": ["subject", "createdAt"], 418 - "properties": { 419 - "subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 420 - "createdAt": { "type": "string", "format": "datetime" } 421 - } 391 + "type": "object", 392 + "required": ["createdAt"], 393 + "properties": { 394 + "reply": { "type": "ref", "ref": "#replyRef" }, 395 + "createdAt": { "type": "string", "format": "datetime" } 396 + } 397 + }, 398 + "replyRef": { 399 + "type": "object", 400 + "required": ["root", "parent"], 401 + "properties": { 402 + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 403 + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 422 404 } 423 405 } 424 406 } 425 407 } 426 408 ``` 427 409 428 - **Valid key types:** `"tid"`, `"self"`, `"nsid"` 429 - 430 - --- 410 + </td></tr> 411 + </table> 431 412 432 - ## Arrays 413 + **Note:** Same-namespace refs use `#defName` format. 433 414 434 - ### Basic Array 415 + ### Cross Namespace 435 416 436 - **Pattern:** Use TypeSpec array syntax `Type[]` 417 + <table> 418 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 419 + <tr><td> 437 420 438 - **TypeSpec:** 439 421 ```typespec 440 - model Example { 441 - @required tags: string[]; 442 - @required images: Image[]; 422 + model ProfileViewBasic { 423 + @required did: did; 424 + @required handle: handle; 425 + 426 + // Reference to another namespace 427 + labels?: com.atproto.label.defs.Label[]; 443 428 } 444 429 ``` 445 430 446 - **JSON:** 431 + </td><td> 432 + 447 433 ```json 448 434 { 435 + "type": "object", 436 + "required": ["did", "handle"], 449 437 "properties": { 450 - "tags": { 438 + "did": { "type": "string", "format": "did" }, 439 + "handle": { "type": "string", "format": "handle" }, 440 + "labels": { 451 441 "type": "array", 452 - "items": { "type": "string" } 453 - }, 454 - "images": { 455 - "type": "array", 456 - "items": { "type": "ref", "ref": "#image" } 442 + "items": { 443 + "type": "ref", 444 + "ref": "com.atproto.label.defs#label" 445 + } 457 446 } 458 447 } 459 448 } 460 449 ``` 461 450 462 - ### Array with Constraints 463 - 464 - **Pattern:** Use `@maxItems` and `@minItems` decorators 465 - 466 - **TypeSpec:** 467 - ```typespec 468 - model Example { 469 - @minItems(0) 470 - @maxItems(4) 471 - @required 472 - images: Image[]; 473 - } 474 - ``` 475 - 476 - **JSON:** 477 - ```json 478 - { 479 - "type": "array", 480 - "items": { "type": "ref", "ref": "#image" }, 481 - "minLength": 0, 482 - "maxLength": 4 483 - } 484 - ``` 451 + </td></tr> 452 + </table> 485 453 486 - --- 454 + **Rules:** 455 + - `namespace.Main` → `"namespace"` (main def) 456 + - `namespace.defs.Model` → `"namespace.defs#model"` (named def) 487 457 488 - ## Unions 458 + ## Strings with Known Values 489 459 490 - ### Overview 460 + ### Inline (Property Level) 491 461 492 - In ATProto, unions are **critical** for backwards compatibility. The TypeSpec syntax enforces intentional design: 462 + <table> 463 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 464 + <tr><td> 493 465 494 - - **Open unions** (extensible): Use `union { A, B, unknown }` or `(A | B | unknown)` 495 - - **Closed unions** (fixed set): Use `@closed union { A, B }` (named) or `Closed<A | B>` (inline) 496 - - **Not allowed**: Plain `union { A, B }` or `(A | B)` without explicit open/closed marker 497 - 498 - This prevents accidentally creating closed unions where extensibility matters. 499 - 500 - **Important:** The `@closed` decorator can ONLY be used on `union` declarations, not on properties. 501 - 502 - ### Open Unions (Extensible) 503 - 504 - **Pattern:** Include `unknown` to allow future variants 505 - 506 - Open unions are the **default pattern in ATProto** - they allow adding new variants without breaking existing clients. 507 - 508 - **Inline union syntax:** 509 466 ```typespec 510 - model Post { 511 - @maxItems(5) 512 - embeddingRules?: (DisableRule | unknown)[]; 513 - 514 - labels?: (com.atproto.label.defs.SelfLabels | unknown); 467 + model JobStatus { 468 + @doc("The state of the video processing job.") 469 + @required 470 + state: "JOB_STATE_COMPLETED" | "JOB_STATE_FAILED" | string; 515 471 } 516 472 517 - model DisableRule {} 473 + model CreateResult { 474 + validationStatus?: "valid" | "unknown" | string; 475 + } 518 476 ``` 519 477 520 - **Alternative union syntax:** 521 - ```typespec 522 - model Main { 523 - @required features: union { Mention, Link, Tag, unknown }[]; 524 - } 478 + </td><td> 525 479 526 - model Mention { 527 - @required did: did; 528 - } 529 - 530 - model Link { 531 - @required uri: uri; 532 - } 533 - 534 - model Tag { 535 - @required tag: string; 536 - } 537 - ``` 538 - 539 - **JSON:** 540 480 ```json 541 481 { 542 - "embeddingRules": { 543 - "type": "array", 544 - "maxLength": 5, 545 - "items": { 546 - "type": "union", 547 - "refs": ["#disableRule"] 482 + "jobStatus": { 483 + "type": "object", 484 + "required": ["state"], 485 + "properties": { 486 + "state": { 487 + "type": "string", 488 + "description": "The state of the video processing job.", 489 + "knownValues": ["JOB_STATE_COMPLETED", "JOB_STATE_FAILED"] 490 + } 548 491 } 549 492 }, 550 - "labels": { 551 - "type": "union", 552 - "refs": ["com.atproto.label.defs#selfLabels"] 553 - }, 554 - "features": { 555 - "type": "array", 556 - "items": { 557 - "type": "union", 558 - "refs": ["#mention", "#link", "#tag"] 493 + "createResult": { 494 + "type": "object", 495 + "properties": { 496 + "validationStatus": { 497 + "type": "string", 498 + "knownValues": ["valid", "unknown"] 499 + } 559 500 } 560 501 } 561 502 } 562 503 ``` 563 504 564 - **TypeScript Output:** 565 - ```typescript 566 - embeddingRules?: ($Typed<DisableRule> | { $type: string })[] 567 - labels?: $Typed<SelfLabels> | { $type: string } 568 - features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[] 569 - ``` 505 + </td></tr> 506 + </table> 570 507 571 - **Note:** The `unknown` type is omitted from JSON `refs` but signals extensibility, producing `{ $type: string }` in TypeScript to accept future variants. 572 - 573 - ### Closed Unions (Fixed Set) 508 + **Atproto idiom:** Always include `| string` for extensibility. This allows new values without breaking old clients. 574 509 575 - **Pattern:** Use `@closed` on named union declarations, or `Closed<>` template for inline unions 510 + ### Named (Def Level) 576 511 577 - Closed unions reject unknown variants - use only when the set is guaranteed never to change. 512 + <table> 513 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 514 + <tr><td> 578 515 579 - **Named union with `@closed` decorator:** 580 516 ```typespec 581 - @closed 582 - union WriteTypes { 583 - Create, 584 - Update, 585 - Delete, 586 - } 587 - 588 - model Main { 589 - @required writes: WriteTypes[]; 517 + namespace com.atproto.label.defs { 518 + union LabelValue { 519 + "!hide", 520 + "!no-promote", 521 + "!warn", 522 + "porn", 523 + "sexual", 524 + string, 525 + } 590 526 } 591 527 ``` 592 528 593 - **Inline union with `Closed<>` template:** 594 - ```typespec 595 - model Main { 596 - @required writes: Closed<Create | Update | Delete>[]; 597 - } 598 - ``` 529 + </td><td> 599 530 600 - **JSON:** 601 531 ```json 602 532 { 603 - "writes": { 604 - "type": "array", 605 - "items": { 606 - "type": "union", 607 - "refs": ["#create", "#update", "#delete"], 608 - "closed": true 533 + "lexicon": 1, 534 + "id": "com.atproto.label.defs", 535 + "defs": { 536 + "labelValue": { 537 + "type": "string", 538 + "knownValues": ["!hide", "!no-promote", "!warn", "porn", "sexual"] 609 539 } 610 540 } 611 541 } 612 542 ``` 613 543 614 - **TypeScript Output:** 615 - ```typescript 616 - writes: ($Typed<Create> | $Typed<Update> | $Typed<Delete>)[] 617 - ``` 544 + </td></tr> 545 + </table> 618 546 619 - **Important:** Closed unions do NOT include `{ $type: string }` - they only accept the exact listed types. 547 + ## Unions 620 548 621 - ### Single Type Reference (Not a Union) 549 + ### Open Unions (Atproto Default) 622 550 623 - **Pattern:** Reference a model directly without union syntax 551 + Open unions are the **standard pattern in atproto** - always include `unknown`: 624 552 625 - When you have exactly one type and don't need extensibility: 553 + <table> 554 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 555 + <tr><td> 626 556 627 - **TypeSpec:** 628 557 ```typespec 629 - model StatusView { 630 - embed?: app.bsky.embed.external.View; // Single ref, not a union 558 + model Post { 559 + // Inline open union - most common pattern 560 + embed?: ( 561 + | app.bsky.embed.images.Main 562 + | app.bsky.embed.video.Main 563 + | app.bsky.embed.external.Main 564 + | app.bsky.embed.record.Main 565 + | app.bsky.embed.recordWithMedia.Main 566 + | unknown 567 + ); 568 + 569 + // Single union member (also needs unknown) 570 + labels?: (com.atproto.label.defs.SelfLabels | unknown); 631 571 } 632 572 ``` 633 573 634 - **JSON:** 574 + </td><td> 575 + 635 576 ```json 636 577 { 637 - "embed": { 638 - "type": "ref", 639 - "ref": "app.bsky.embed.external#view" 578 + "type": "object", 579 + "properties": { 580 + "embed": { 581 + "type": "union", 582 + "refs": [ 583 + "app.bsky.embed.images", 584 + "app.bsky.embed.video", 585 + "app.bsky.embed.external", 586 + "app.bsky.embed.record", 587 + "app.bsky.embed.recordWithMedia" 588 + ] 589 + }, 590 + "labels": { 591 + "type": "union", 592 + "refs": ["com.atproto.label.defs#selfLabels"] 593 + } 640 594 } 641 595 } 642 596 ``` 643 597 644 - This is NOT a union - it's a plain reference. Use this when you have a single type that won't change. 598 + </td></tr> 599 + </table> 645 600 646 - ### Named Union Defs 601 + **Note:** `unknown` signals extensibility but doesn't appear in `refs`. This allows future variants without breaking old clients. 647 602 648 - **Pattern:** Define a top-level union type for reuse 603 + <table> 604 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 605 + <tr><td> 649 606 650 - **TypeSpec:** 651 607 ```typespec 652 608 namespace app.bsky.actor.defs { 653 - @doc("A set of user preferences.") 609 + // Named open union for reuse 654 610 union Preferences { 655 611 AdultContentPref, 656 612 ContentLabelPref, ··· 658 614 PersonalDetailsPref, 659 615 FeedViewPref, 660 616 ThreadViewPref, 617 + InterestsPref, 661 618 unknown, 662 619 } 663 620 ··· 666 623 } 667 624 668 625 model ContentLabelPref { 626 + @required labelerDid?: did; 669 627 @required label: string; 628 + @required visibility: string; 670 629 } 630 + 671 631 // ... more variants 672 632 } 673 633 ``` 674 634 675 - **JSON:** 635 + </td><td> 636 + 676 637 ```json 677 638 { 678 - "preferences": { 679 - "type": "array", 680 - "items": { 681 - "type": "union", 682 - "refs": [ 683 - "#adultContentPref", 684 - "#contentLabelPref", 685 - "#savedFeedsPref", 686 - "#personalDetailsPref", 687 - "#feedViewPref", 688 - "#threadViewPref" 689 - ] 639 + "lexicon": 1, 640 + "id": "app.bsky.actor.defs", 641 + "defs": { 642 + "preferences": { 643 + "type": "array", 644 + "items": { 645 + "type": "union", 646 + "refs": [ 647 + "#adultContentPref", 648 + "#contentLabelPref", 649 + "#savedFeedsPref", 650 + "#personalDetailsPref", 651 + "#feedViewPref", 652 + "#threadViewPref", 653 + "#interestsPref" 654 + ] 655 + } 656 + }, 657 + "adultContentPref": { 658 + "type": "object", 659 + "required": ["enabled"], 660 + "properties": { 661 + "enabled": { "type": "boolean" } 662 + } 663 + }, 664 + "contentLabelPref": { 665 + "type": "object", 666 + "required": ["label", "visibility"], 667 + "properties": { 668 + "labelerDid": { "type": "string", "format": "did" }, 669 + "label": { "type": "string" }, 670 + "visibility": { "type": "string" } 671 + } 690 672 } 691 673 } 692 674 } 693 675 ``` 694 676 695 - **When to use:** When defining a reusable union that will be referenced elsewhere. The union automatically wraps in an array at the def level. Always include `unknown` for open unions. 677 + </td></tr> 678 + </table> 679 + 680 + **Note:** Named unions auto-wrap in array at def level. 696 681 697 - ### Empty Union Variants 682 + ### Closed Unions (Rare) 698 683 699 - **Pattern:** Empty models for variants with no properties 684 + Only use for **fixed internal operations** where the set is guaranteed never to change: 700 685 701 - **TypeSpec:** 686 + <table> 687 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 688 + <tr><td> 689 + 702 690 ```typespec 703 - model Main { 704 - allow?: (MentionRule | FollowerRule | unknown)[]; 705 - } 691 + namespace com.atproto.repo.applyWrites { 692 + @procedure 693 + op main(input: { 694 + @required repo: atIdentifier; 706 695 707 - @doc("Allow replies from actors mentioned in your post.") 708 - model MentionRule {} 696 + // Closed union - write operations are fixed 697 + @required 698 + writes: Closed<Create | Update | Delete>[]; 699 + }): { 700 + results?: Closed<CreateResult | UpdateResult | DeleteResult>[]; 701 + }; 709 702 710 - @doc("Allow replies from actors who follow you.") 711 - model FollowerRule {} 712 - ``` 703 + model Create { 704 + @required collection: nsid; 705 + rkey?: recordKey; 706 + @required value: unknown; 707 + } 713 708 714 - **JSON:** 715 - ```json 716 - { 717 - "allow": { 718 - "type": "array", 719 - "items": { 720 - "type": "union", 721 - "refs": ["#mentionRule", "#followerRule"] 722 - } 709 + model Update { 710 + @required collection: nsid; 711 + @required rkey: recordKey; 712 + @required value: unknown; 723 713 } 724 - }, 725 - { 726 - "mentionRule": { 727 - "type": "object", 728 - "description": "Allow replies from actors mentioned in your post.", 729 - "properties": {} 730 - }, 731 - "followerRule": { 732 - "type": "object", 733 - "description": "Allow replies from actors who follow you.", 734 - "properties": {} 714 + 715 + model Delete { 716 + @required collection: nsid; 717 + @required rkey: recordKey; 735 718 } 736 - } 737 - ``` 738 719 739 - ### Empty Union (No Refs) 720 + model CreateResult { 721 + @required uri: atUri; 722 + @required cid: cid; 723 + } 740 724 741 - **Pattern:** Use `never | unknown` for unions with no model references 725 + model UpdateResult { 726 + @required uri: atUri; 727 + @required cid: cid; 728 + } 742 729 743 - In rare cases, you need an empty union that accepts any type with a `$type` discriminator but has no known refs. Use the `never | unknown` pattern: 744 - 745 - **TypeSpec:** 746 - ```typespec 747 - model SubjectView { 748 - @required type: string; 749 - @required subject: string; 750 - profile?: never | unknown; // Empty union 730 + model DeleteResult {} 751 731 } 752 732 ``` 753 733 754 - **JSON:** 734 + </td><td> 735 + 755 736 ```json 756 737 { 757 - "profile": { 758 - "type": "union", 759 - "refs": [] 738 + "lexicon": 1, 739 + "id": "com.atproto.repo.applyWrites", 740 + "defs": { 741 + "main": { 742 + "type": "procedure", 743 + "input": { 744 + "encoding": "application/json", 745 + "schema": { 746 + "type": "object", 747 + "required": ["repo", "writes"], 748 + "properties": { 749 + "repo": { "type": "string", "format": "at-identifier" }, 750 + "writes": { 751 + "type": "array", 752 + "items": { 753 + "type": "union", 754 + "closed": true, 755 + "refs": ["#create", "#update", "#delete"] 756 + } 757 + } 758 + } 759 + } 760 + }, 761 + "output": { 762 + "encoding": "application/json", 763 + "schema": { 764 + "type": "object", 765 + "properties": { 766 + "results": { 767 + "type": "array", 768 + "items": { 769 + "type": "union", 770 + "closed": true, 771 + "refs": ["#createResult", "#updateResult", "#deleteResult"] 772 + } 773 + } 774 + } 775 + } 776 + } 777 + }, 778 + "create": { 779 + "type": "object", 780 + "required": ["collection", "value"], 781 + "properties": { 782 + "collection": { "type": "string", "format": "nsid" }, 783 + "rkey": { "type": "string", "format": "record-key" }, 784 + "value": { "type": "unknown" } 785 + } 786 + }, 787 + "update": { 788 + "type": "object", 789 + "required": ["collection", "rkey", "value"], 790 + "properties": { 791 + "collection": { "type": "string", "format": "nsid" }, 792 + "rkey": { "type": "string", "format": "record-key" }, 793 + "value": { "type": "unknown" } 794 + } 795 + }, 796 + "delete": { 797 + "type": "object", 798 + "required": ["collection", "rkey"], 799 + "properties": { 800 + "collection": { "type": "string", "format": "nsid" }, 801 + "rkey": { "type": "string", "format": "record-key" } 802 + } 803 + } 760 804 } 761 805 } 762 806 ``` 763 807 764 - **Why `never | unknown`?** 765 - - `never` creates a union type in TypeSpec (single `unknown` is just intrinsic, not a union) 766 - - `never` contributes no refs (it's an empty type) 767 - - `unknown` marks the union as open/extensible 768 - - Result: empty refs array with open union semantics 808 + </td></tr> 809 + </table> 769 810 770 - **When to use:** Fields that may contain any discriminated type in the future but have no current known types. 771 - 772 - ### Syntax Summary 773 - 774 - | Pattern | Syntax | JSON `closed` field | Use Case | 775 - |---------|--------|---------------------|----------| 776 - | **Open union (inline)** | `(A \| B \| unknown)` | omitted (false) | Default - allows future variants | 777 - | **Open union (named)** | `union { A, B, unknown }` | omitted (false) | Named def, reusable | 778 - | **Empty union** | `never \| unknown` | omitted (false) | No current refs, open to future types | 779 - | **Closed union (named)** | `@closed union { A, B }` | `true` | Fixed set, named def | 780 - | **Closed union (inline)** | `Closed<A \| B>` | `true` | Fixed set, inline usage | 781 - | **Single reference** | `SomeType` | N/A (not a union) | Exactly one type, no variants | 782 - | **ERROR** | `(A \| B)` or `union { A, B }` | ❌ Not allowed | Must be explicit about open/closed | 811 + **When to use closed unions:** 812 + - Internal server operations (like applyWrites) 813 + - Batch operations with fixed types 814 + - NOT for user-facing content or records 783 815 784 - --- 785 - 786 - ## Strings with Known Values 816 + ### Empty Union 787 817 788 - ### Inline Union (Property-level) 818 + No current refs, but open to future types: 789 819 790 - **Pattern:** Use inline union type with `string` as last variant 820 + <table> 821 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 822 + <tr><td> 791 823 792 - **TypeSpec:** 793 824 ```typespec 794 - model Label { 795 - @required severity: "inform" | "alert" | "none" | string; 796 - @required blurs: "content" | "media" | "none" | string; 825 + model SubjectView { 826 + @required subject: string; 827 + // Empty union - no known types yet 828 + profile?: (never | unknown); 797 829 } 798 830 ``` 799 831 800 - **JSON:** 832 + </td><td> 833 + 801 834 ```json 802 835 { 803 - "severity": { 804 - "type": "string", 805 - "knownValues": ["inform", "alert", "none"] 806 - }, 807 - "blurs": { 808 - "type": "string", 809 - "knownValues": ["content", "media", "none"] 836 + "type": "object", 837 + "required": ["subject"], 838 + "properties": { 839 + "subject": { "type": "string" }, 840 + "profile": { 841 + "type": "union", 842 + "refs": [] 843 + } 810 844 } 811 845 } 812 846 ``` 813 847 814 - **TypeScript Output:** 815 - ```typescript 816 - severity: 'inform' | 'alert' | 'none' | (string & {}) 817 - blurs: 'content' | 'media' | 'none' | (string & {}) 818 - ``` 848 + </td></tr> 849 + </table> 819 850 820 - The `string & {}` pattern allows any string while preserving autocomplete for known values. 851 + **Why `never | unknown`?** Single `unknown` emits `{ type: "unknown" }`. Adding `never` forces union type with no refs. 821 852 822 - ### Named Union (Def-level) 853 + ### Single Reference (Not a Union) 823 854 824 - **Pattern:** Define a named union in defs namespace 855 + <table> 856 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 857 + <tr><td> 825 858 826 - **TypeSpec:** 827 859 ```typespec 828 - namespace com.atproto.label.defs { 829 - union LabelValue { 830 - "!hide", 831 - "!no-promote", 832 - "!warn", 833 - "porn", 834 - "sexual", 835 - string, 836 - } 860 + model ReplyRef { 861 + // Single type - not a union 862 + @required root: com.atproto.repo.strongRef.Main; 863 + @required parent: com.atproto.repo.strongRef.Main; 837 864 } 838 865 ``` 839 866 840 - **JSON:** 867 + </td><td> 868 + 841 869 ```json 842 870 { 843 - "labelValue": { 844 - "type": "string", 845 - "knownValues": ["!hide", "!no-promote", "!warn", "porn", "sexual"] 871 + "type": "object", 872 + "required": ["root", "parent"], 873 + "properties": { 874 + "root": { 875 + "type": "ref", 876 + "ref": "com.atproto.repo.strongRef" 877 + }, 878 + "parent": { 879 + "type": "ref", 880 + "ref": "com.atproto.repo.strongRef" 881 + } 846 882 } 847 883 } 848 884 ``` 849 885 850 - **TypeScript Output:** 851 - ```typescript 852 - export type LabelValue = 853 - | '!hide' 854 - | '!no-promote' 855 - | '!warn' 856 - | 'porn' 857 - | 'sexual' 858 - | (string & {}) 859 - ``` 886 + </td></tr> 887 + </table> 888 + 889 + **Note:** Single type without `|` creates a ref, not a union. 860 890 861 - **When to use:** Use unnamed union syntax (without explicit variant names like `Hide: "!hide"`) when you just want to list the known string values. The emitter will emit these as `knownValues`. 891 + ## Binary Data 862 892 863 - ### Named Variants in Unions 893 + ### Blobs 864 894 865 - **Pattern:** Use named variants for cross-namespace references 895 + <table> 896 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 897 + <tr><td> 866 898 867 - **TypeSpec:** 868 899 ```typespec 869 - namespace com.atproto.moderation.defs { 870 - union reasonType { 871 - string, 872 - 873 - ReasonSpam: "com.atproto.moderation.defs#reasonSpam", 874 - ReasonViolation: "com.atproto.moderation.defs#reasonViolation", 875 - 876 - // Cross-namespace references 877 - ToolsOzoneReasonAppeal: "tools.ozone.report.defs#reasonAppeal", 878 - ToolsOzoneReasonSpam: "tools.ozone.report.defs#reasonMisleadingSpam", 879 - } 900 + model Image { 901 + @required image: Blob<#["image/*"], 1000000>; 902 + thumb?: Blob<#["image/png", "image/jpeg"], 256000>; 880 903 } 881 - ``` 882 904 883 - **JSON:** 884 - ```json 885 - { 886 - "reasonType": { 887 - "type": "string", 888 - "knownValues": [ 889 - "com.atproto.moderation.defs#reasonSpam", 890 - "com.atproto.moderation.defs#reasonViolation", 891 - "tools.ozone.report.defs#reasonAppeal", 892 - "tools.ozone.report.defs#reasonMisleadingSpam" 893 - ] 894 - } 905 + model Video { 906 + @required video: Blob<#["video/mp4"], 50000000>; 907 + // 0 = no size limit 908 + captions?: Blob<#["text/vtt"], 0>; 895 909 } 896 910 ``` 897 911 898 - **When to use:** When known values include references to token defs (with `#` in them). 899 - 900 - ### With Default Value 901 - 902 - **Pattern:** Use TypeSpec default value syntax `= "value"` 912 + </td><td> 903 913 904 - **TypeSpec:** 905 - ```typespec 906 - model Label { 907 - defaultSetting?: "ignore" | "warn" | "hide" | string = "warn"; 908 - } 909 - ``` 910 - 911 - **JSON:** 912 914 ```json 913 915 { 914 - "defaultSetting": { 915 - "type": "string", 916 - "knownValues": ["ignore", "warn", "hide"], 917 - "default": "warn" 916 + "image": { 917 + "type": "object", 918 + "required": ["image"], 919 + "properties": { 920 + "image": { 921 + "type": "blob", 922 + "accept": ["image/*"], 923 + "maxSize": 1000000 924 + }, 925 + "thumb": { 926 + "type": "blob", 927 + "accept": ["image/png", "image/jpeg"], 928 + "maxSize": 256000 929 + } 930 + } 931 + }, 932 + "video": { 933 + "type": "object", 934 + "required": ["video"], 935 + "properties": { 936 + "video": { 937 + "type": "blob", 938 + "accept": ["video/mp4"], 939 + "maxSize": 50000000 940 + }, 941 + "captions": { 942 + "type": "blob", 943 + "accept": ["text/vtt"], 944 + "maxSize": 0 945 + } 946 + } 918 947 } 919 948 } 920 949 ``` 921 950 922 - --- 951 + </td></tr> 952 + </table> 923 953 924 - ## Tokens 954 + **Syntax:** `Blob<#[mimeType1, mimeType2, ...], maxSizeInBytes>` 925 955 926 - ### Token Type 956 + ### Bytes 927 957 928 - **Pattern:** Use `@token` decorator on empty model 958 + <table> 959 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 960 + <tr><td> 929 961 930 - **TypeSpec:** 931 962 ```typespec 932 - namespace com.atproto.moderation.defs { 933 - @doc("Spam: frequent unwanted promotion, replies, mentions.") 934 - @token 935 - model reasonSpam {} 963 + model Label { 964 + @required val: string; 965 + sig?: bytes; 936 966 } 937 967 ``` 938 968 939 - **JSON:** 969 + </td><td> 970 + 940 971 ```json 941 972 { 942 - "reasonSpam": { 943 - "type": "token", 944 - "description": "Spam: frequent unwanted promotion, replies, mentions." 973 + "type": "object", 974 + "required": ["val"], 975 + "properties": { 976 + "val": { "type": "string" }, 977 + "sig": { "type": "bytes" } 945 978 } 946 979 } 947 980 ``` 948 981 949 - **TypeScript Output:** 950 - ```typescript 951 - export const REASONSPAM = 'com.atproto.moderation.defs#reasonSpam' 952 - ``` 953 - 954 - Tokens are emitted as string constants in TypeScript, not as types. 955 - 956 - --- 957 - 958 - ## Blobs and Bytes 982 + </td></tr> 983 + </table> 959 984 960 - ### Blob Type (Template) 985 + ## Records 961 986 962 - **Pattern:** Use `Blob<#[mimeTypes...], maxSize>` template 987 + <table> 988 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 989 + <tr><td> 963 990 964 - **TypeSpec:** 965 991 ```typespec 966 - model Image { 967 - @required image: Blob<#["image/*"], 1000000>; 968 - thumb?: Blob<#["image/*"], 1000000>; 969 - } 970 - ``` 992 + namespace app.bsky.feed.post { 993 + @record("tid") 994 + @doc("Record containing a Bluesky post.") 995 + model Main { 996 + @doc("The primary post content.") 997 + @maxGraphemes(300) 998 + @maxLength(3000) 999 + @required 1000 + text: string; 971 1001 972 - **JSON:** 973 - ```json 974 - { 975 - "image": { 976 - "type": "blob", 977 - "accept": ["image/*"], 978 - "maxSize": 1000000 979 - }, 980 - "thumb": { 981 - "type": "blob", 982 - "accept": ["image/*"], 983 - "maxSize": 1000000 984 - } 985 - } 986 - ``` 1002 + @doc("Annotations of text (mentions, URLs, hashtags, etc)") 1003 + facets?: app.bsky.richtext.facet.Main[]; 987 1004 988 - **Syntax notes:** 989 - - `#[...]` creates a tuple of string literals 990 - - First parameter: accepted MIME types 991 - - Second parameter: max size in bytes 992 - - Use `0` or omit for no max size limit 1005 + reply?: ReplyRef; 993 1006 994 - ### Bytes Type 1007 + embed?: ( 1008 + | app.bsky.embed.images.Main 1009 + | app.bsky.embed.external.Main 1010 + | app.bsky.embed.record.Main 1011 + | unknown 1012 + ); 995 1013 996 - **Pattern:** Use `bytes` scalar 1014 + @doc("Client-declared timestamp when this post was originally created.") 1015 + @required 1016 + createdAt: datetime; 1017 + } 997 1018 998 - **TypeSpec:** 999 - ```typespec 1000 - model Label { 1001 - sig?: bytes; 1019 + model ReplyRef { 1020 + @required root: com.atproto.repo.strongRef.Main; 1021 + @required parent: com.atproto.repo.strongRef.Main; 1022 + } 1002 1023 } 1003 1024 ``` 1004 1025 1005 - **JSON:** 1026 + </td><td> 1027 + 1006 1028 ```json 1007 1029 { 1008 - "sig": { "type": "bytes" } 1030 + "lexicon": 1, 1031 + "id": "app.bsky.feed.post", 1032 + "defs": { 1033 + "main": { 1034 + "type": "record", 1035 + "description": "Record containing a Bluesky post.", 1036 + "key": "tid", 1037 + "record": { 1038 + "type": "object", 1039 + "required": ["text", "createdAt"], 1040 + "properties": { 1041 + "text": { 1042 + "type": "string", 1043 + "description": "The primary post content.", 1044 + "maxLength": 3000, 1045 + "maxGraphemes": 300 1046 + }, 1047 + "facets": { 1048 + "type": "array", 1049 + "description": "Annotations of text (mentions, URLs, hashtags, etc)", 1050 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 1051 + }, 1052 + "reply": { "type": "ref", "ref": "#replyRef" }, 1053 + "embed": { 1054 + "type": "union", 1055 + "refs": [ 1056 + "app.bsky.embed.images", 1057 + "app.bsky.embed.external", 1058 + "app.bsky.embed.record" 1059 + ] 1060 + }, 1061 + "createdAt": { 1062 + "type": "string", 1063 + "format": "datetime", 1064 + "description": "Client-declared timestamp when this post was originally created." 1065 + } 1066 + } 1067 + } 1068 + }, 1069 + "replyRef": { 1070 + "type": "object", 1071 + "required": ["root", "parent"], 1072 + "properties": { 1073 + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 1074 + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 1075 + } 1076 + } 1077 + } 1009 1078 } 1010 1079 ``` 1011 1080 1012 - **TypeScript Output:** 1013 - ```typescript 1014 - sig?: Uint8Array 1015 - ``` 1081 + </td></tr> 1082 + </table> 1016 1083 1017 - --- 1084 + **Valid key types:** `"tid"`, `"self"`, `"nsid"` 1018 1085 1019 - ## Constraints and Validation 1086 + ## Operations 1020 1087 1021 - ### String Constraints 1088 + ### Query (HTTP GET) 1022 1089 1023 - **Pattern:** Use decorator for each constraint type 1090 + <table> 1091 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1092 + <tr><td> 1024 1093 1025 - **TypeSpec:** 1026 1094 ```typespec 1027 - model Example { 1028 - @maxLength(128) 1029 - @required 1030 - short: string; 1095 + namespace app.bsky.bookmark.getBookmarks { 1096 + @doc("Gets views of records bookmarked by the authenticated user.") 1097 + @query 1098 + op main( 1099 + @minValue(1) 1100 + @maxValue(100) 1101 + limit?: int32 = 50, 1031 1102 1032 - @maxGraphemes(64) 1033 - @maxLength(640) 1034 - @required 1035 - display: string; 1103 + cursor?: string 1104 + ): { 1105 + cursor?: string; 1106 + @required bookmarks: app.bsky.bookmark.defs.BookmarkView[]; 1107 + }; 1036 1108 } 1037 1109 ``` 1038 1110 1039 - **JSON:** 1111 + </td><td> 1112 + 1040 1113 ```json 1041 1114 { 1042 - "short": { "type": "string", "maxLength": 128 }, 1043 - "display": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } 1115 + "lexicon": 1, 1116 + "id": "app.bsky.bookmark.getBookmarks", 1117 + "defs": { 1118 + "main": { 1119 + "type": "query", 1120 + "description": "Gets views of records bookmarked by the authenticated user.", 1121 + "parameters": { 1122 + "type": "params", 1123 + "properties": { 1124 + "limit": { 1125 + "type": "integer", 1126 + "minimum": 1, 1127 + "maximum": 100, 1128 + "default": 50 1129 + }, 1130 + "cursor": { "type": "string" } 1131 + } 1132 + }, 1133 + "output": { 1134 + "encoding": "application/json", 1135 + "schema": { 1136 + "type": "object", 1137 + "required": ["bookmarks"], 1138 + "properties": { 1139 + "cursor": { "type": "string" }, 1140 + "bookmarks": { 1141 + "type": "array", 1142 + "items": { 1143 + "type": "ref", 1144 + "ref": "app.bsky.bookmark.defs#bookmarkView" 1145 + } 1146 + } 1147 + } 1148 + } 1149 + } 1150 + } 1151 + } 1044 1152 } 1045 1153 ``` 1046 1154 1047 - **Available decorators:** 1048 - - `@maxLength(n)` - Maximum byte length 1049 - - `@minLength(n)` - Minimum byte length 1050 - - `@maxGraphemes(n)` - Maximum grapheme/character count 1051 - - `@minGraphemes(n)` - Minimum grapheme/character count 1155 + </td></tr> 1156 + </table> 1052 1157 1053 - ### Integer/Number Constraints 1158 + **Atproto idiom:** Queries typically have optional `limit` (with default) and `cursor` params for pagination. 1054 1159 1055 - **Pattern:** Use `@minValue` and `@maxValue` decorators 1160 + ### Procedure (HTTP POST) 1161 + 1162 + <table> 1163 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1164 + <tr><td> 1056 1165 1057 - **TypeSpec:** 1058 1166 ```typespec 1059 - model ByteSlice { 1060 - @minValue(0) 1061 - @required 1062 - byteStart: int32; 1063 - 1064 - @minValue(1) 1065 - @required 1066 - width: int32; 1167 + namespace app.bsky.actor.putPreferences { 1168 + @doc("Set the private preferences attached to the account.") 1169 + @procedure 1170 + op main(input: { 1171 + @required preferences: app.bsky.actor.defs.Preferences; 1172 + }): void; 1067 1173 } 1068 1174 ``` 1069 1175 1070 - **JSON:** 1071 - ```json 1072 - { 1073 - "byteStart": { "type": "integer", "minimum": 0 }, 1074 - "width": { "type": "integer", "minimum": 1 } 1075 - } 1076 - ``` 1176 + </td><td> 1077 1177 1078 - ### Const Values 1079 - 1080 - **Pattern:** Use `@lexConst(value)` decorator 1081 - 1082 - **TypeSpec:** 1083 - ```typespec 1084 - model Example { 1085 - @lexConst("app.bsky.feed.post") 1086 - @required 1087 - type: string; 1088 - 1089 - @lexConst(true) 1090 - @required 1091 - enabled: boolean; 1092 - 1093 - @lexConst(42) 1094 - @required 1095 - count: integer; 1096 - } 1097 - ``` 1098 - 1099 - **JSON:** 1100 1178 ```json 1101 1179 { 1102 - "type": { "type": "string", "const": "app.bsky.feed.post" }, 1103 - "enabled": { "type": "boolean", "const": true }, 1104 - "count": { "type": "integer", "const": 42 } 1180 + "lexicon": 1, 1181 + "id": "app.bsky.actor.putPreferences", 1182 + "defs": { 1183 + "main": { 1184 + "type": "procedure", 1185 + "description": "Set the private preferences attached to the account.", 1186 + "input": { 1187 + "encoding": "application/json", 1188 + "schema": { 1189 + "type": "object", 1190 + "required": ["preferences"], 1191 + "properties": { 1192 + "preferences": { 1193 + "type": "ref", 1194 + "ref": "app.bsky.actor.defs#preferences" 1195 + } 1196 + } 1197 + } 1198 + } 1199 + } 1200 + } 1105 1201 } 1106 1202 ``` 1107 1203 1108 - **Note:** `@lexConst` is a custom decorator from `@tlex/emitter`. TypeSpec doesn't have a built-in const decorator, but this pattern follows TypeSpec's decorator conventions. 1109 - 1110 - --- 1204 + </td></tr> 1205 + </table> 1111 1206 1112 - ## References 1207 + **Rules:** 1208 + - 1 param: must be named `input` 1209 + - 2 params: must be named `input` and `parameters` (in order) 1210 + - 3+ params: error 1113 1211 1114 - ### Same-Namespace Reference 1212 + ### Subscription (WebSocket) 1115 1213 1116 - **Pattern:** Use the model type directly 1214 + <table> 1215 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1216 + <tr><td> 1117 1217 1118 - **TypeSpec:** 1119 1218 ```typespec 1120 - namespace app.bsky.richtext.facet { 1121 - model Main { 1122 - @required index: ByteSlice; // Local reference 1219 + namespace com.atproto.sync.subscribeRepos { 1220 + @doc("Subscribe to repo updates.") 1221 + @subscription 1222 + op main(cursor?: integer): (Commit | Handle | Identity | Tombstone | Info); 1223 + 1224 + model Commit { 1225 + @required seq: integer; 1226 + @required rebase: boolean; 1227 + @required repo: did; 1228 + @required commit: cid; 1123 1229 } 1124 1230 1125 - model ByteSlice { 1126 - @required byteStart: int32; 1231 + model Handle { 1232 + @required seq: integer; 1233 + @required did: did; 1234 + @required handle: handle; 1127 1235 } 1128 - } 1129 - ``` 1130 1236 1131 - **JSON:** 1132 - ```json 1133 - { 1134 - "index": { "type": "ref", "ref": "#byteSlice" } 1237 + // ... other message types 1135 1238 } 1136 1239 ``` 1137 1240 1138 - **Note:** References within the same namespace always use the `#defName` format, never the full `namespace#defName` format. 1139 - 1140 - ### Cross-Namespace Reference (to Main) 1141 - 1142 - **Pattern:** Use fully qualified namespace reference 1241 + </td><td> 1143 1242 1144 - **TypeSpec:** 1145 - ```typespec 1146 - namespace app.bsky.feed.like { 1147 - model Main { 1148 - @required subject: com.atproto.repo.strongRef.Main; 1149 - } 1150 - } 1151 - ``` 1152 - 1153 - **JSON:** 1154 1243 ```json 1155 1244 { 1156 - "subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 1157 - } 1158 - ``` 1159 - 1160 - When referencing a `Main` model from another namespace, just use the namespace - no `#main` needed. 1161 - 1162 - ### Cross-Namespace Reference (to Def) 1163 - 1164 - **Pattern:** Use fully qualified namespace + model name 1165 - 1166 - **TypeSpec:** 1167 - ```typespec 1168 - namespace app.bsky.embed.images { 1169 - model Image { 1170 - aspectRatio?: app.bsky.embed.defs.AspectRatio; 1245 + "lexicon": 1, 1246 + "id": "com.atproto.sync.subscribeRepos", 1247 + "defs": { 1248 + "main": { 1249 + "type": "subscription", 1250 + "description": "Subscribe to repo updates.", 1251 + "parameters": { 1252 + "type": "params", 1253 + "properties": { 1254 + "cursor": { "type": "integer" } 1255 + } 1256 + }, 1257 + "message": { 1258 + "schema": { 1259 + "type": "union", 1260 + "refs": ["#commit", "#handle", "#identity", "#tombstone", "#info"] 1261 + } 1262 + } 1263 + }, 1264 + "commit": { 1265 + "type": "object", 1266 + "required": ["seq", "rebase", "repo", "commit"], 1267 + "properties": { 1268 + "seq": { "type": "integer" }, 1269 + "rebase": { "type": "boolean" }, 1270 + "repo": { "type": "string", "format": "did" }, 1271 + "commit": { "type": "string", "format": "cid" } 1272 + } 1273 + }, 1274 + "handle": { 1275 + "type": "object", 1276 + "required": ["seq", "did", "handle"], 1277 + "properties": { 1278 + "seq": { "type": "integer" }, 1279 + "did": { "type": "string", "format": "did" }, 1280 + "handle": { "type": "string", "format": "handle" } 1281 + } 1282 + } 1171 1283 } 1172 1284 } 1173 1285 ``` 1174 1286 1175 - **JSON:** 1176 - ```json 1177 - { 1178 - "aspectRatio": { "type": "ref", "ref": "app.bsky.embed.defs#aspectRatio" } 1179 - } 1180 - ``` 1287 + </td></tr> 1288 + </table> 1181 1289 1182 - When referencing a non-Main def from another namespace, use `namespace.defs#defName` format. 1290 + **Note:** Return type must be a union. 1183 1291 1184 - --- 1292 + ### Custom Encodings 1185 1293 1186 - ## Documentation 1187 - 1188 - ### Model Documentation 1189 - 1190 - **Pattern:** Use `@doc("...")` decorator 1294 + <table> 1295 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1296 + <tr><td> 1191 1297 1192 - **TypeSpec:** 1193 1298 ```typespec 1194 - @doc("Annotation of a sub-string within rich text.") 1195 - model Main { 1196 - // ... 1299 + namespace app.bsky.video.uploadVideo { 1300 + @doc("Upload a video to be processed.") 1301 + @procedure 1302 + op main( 1303 + @encoding("video/mp4") 1304 + input: void 1305 + ): { 1306 + @required jobStatus: app.bsky.video.defs.JobStatus; 1307 + }; 1197 1308 } 1198 1309 ``` 1199 1310 1200 - **JSON:** 1311 + </td><td> 1312 + 1201 1313 ```json 1202 1314 { 1203 - "main": { 1204 - "type": "object", 1205 - "description": "Annotation of a sub-string within rich text.", 1206 - "properties": {...} 1315 + "lexicon": 1, 1316 + "id": "app.bsky.video.uploadVideo", 1317 + "defs": { 1318 + "main": { 1319 + "type": "procedure", 1320 + "description": "Upload a video to be processed.", 1321 + "input": { 1322 + "encoding": "video/mp4" 1323 + }, 1324 + "output": { 1325 + "encoding": "application/json", 1326 + "schema": { 1327 + "type": "object", 1328 + "required": ["jobStatus"], 1329 + "properties": { 1330 + "jobStatus": { 1331 + "type": "ref", 1332 + "ref": "app.bsky.video.defs#jobStatus" 1333 + } 1334 + } 1335 + } 1336 + } 1337 + } 1207 1338 } 1208 1339 } 1209 1340 ``` 1210 1341 1211 - ### Property Documentation 1342 + </td></tr> 1343 + </table> 1212 1344 1213 - **Pattern:** Use `@doc("...")` on property 1345 + **Rules:** 1346 + - `@encoding` on `input` param → input encoding 1347 + - `@encoding` on operation → output encoding 1348 + - `void` type → no schema field (encoding only) 1349 + - Default encoding: `application/json` 1214 1350 1215 - **TypeSpec:** 1216 - ```typespec 1217 - model PostRef { 1218 - @doc("AT URI of the post") 1219 - @required 1220 - uri: atUri; 1221 - } 1222 - ``` 1351 + ### Operation Errors 1223 1352 1224 - **JSON:** 1225 - ```json 1226 - { 1227 - "uri": { 1228 - "type": "string", 1229 - "format": "at-uri", 1230 - "description": "AT URI of the post" 1231 - } 1232 - } 1233 - ``` 1353 + <table> 1354 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1355 + <tr><td> 1234 1356 1235 - ### Namespace Documentation 1236 - 1237 - **Pattern:** Use `@doc("...")` before namespace declaration 1238 - 1239 - **TypeSpec:** 1240 1357 ```typespec 1241 - @doc("A URI with a content-hash fingerprint.") 1242 - namespace com.atproto.repo.strongRef { 1243 - model Main { 1244 - // ... 1245 - } 1358 + namespace com.atproto.repo.applyWrites { 1359 + @doc("Indicates that the 'swapCommit' parameter did not match current commit.") 1360 + model InvalidSwap {} 1361 + 1362 + @procedure 1363 + @errors(InvalidSwap) 1364 + op main(input: { /* ... */ }): { /* ... */ }; 1246 1365 } 1247 1366 ``` 1248 1367 1249 - **JSON:** 1368 + </td><td> 1369 + 1250 1370 ```json 1251 1371 { 1252 1372 "lexicon": 1, 1253 - "id": "com.atproto.repo.strongRef", 1254 - "description": "A URI with a content-hash fingerprint.", 1255 - "defs": {...} 1373 + "id": "com.atproto.repo.applyWrites", 1374 + "defs": { 1375 + "main": { 1376 + "type": "procedure", 1377 + "errors": [ 1378 + { 1379 + "name": "InvalidSwap", 1380 + "description": "Indicates that the 'swapCommit' parameter did not match current commit." 1381 + } 1382 + ] 1383 + } 1384 + } 1256 1385 } 1257 1386 ``` 1258 1387 1259 - **For `.defs` files:** Description goes on the lexicon level. For files with `Main`, namespace `@doc` goes to lexicon level, `Main` model `@doc` goes to the main def's description. 1388 + </td></tr> 1389 + </table> 1260 1390 1261 - --- 1391 + **Note:** Error models are empty (only name and optional `@doc`). They're not emitted as defs. 1262 1392 1263 - ## Code Style 1393 + ## Tokens 1264 1394 1265 - ### Decorator Placement 1395 + <table> 1396 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1397 + <tr><td> 1266 1398 1267 - **Rule:** Only put decorators on the same line if it's `@required` alone 1268 - 1269 - **Good:** 1270 1399 ```typespec 1271 - model Example { 1272 - @required name: string; 1400 + namespace com.atproto.moderation.defs { 1401 + @doc("Spam: frequent unwanted promotion, replies, mentions.") 1402 + @token 1403 + model ReasonSpam {} 1273 1404 1274 - @maxLength(100) 1275 - @required 1276 - description: string; 1277 - 1278 - @doc("Optional field") 1279 - tag?: string; 1280 - } 1281 - ``` 1282 - 1283 - **Bad:** 1284 - ```typespec 1285 - model Example { 1286 - @required @maxLength(100) name: string; // ❌ Multiple decorators on one line 1287 - @doc("Field") @required tag: string; // ❌ Non-@required decorator on same line 1405 + @doc("Direct violation of server rules, laws, terms of service.") 1406 + @token 1407 + model ReasonViolation {} 1288 1408 } 1289 1409 ``` 1290 1410 1291 - ### Decorator Spacing 1411 + </td><td> 1292 1412 1293 - **Rule:** Add a blank line after multi-line decorator blocks 1294 - 1295 - **Good:** 1296 - ```typespec 1297 - model Example { 1298 - @maxLength(128) 1299 - @maxGraphemes(64) 1300 - @required 1301 - name: string; 1302 - 1303 - @required age: int32; 1304 - } 1305 - ``` 1306 - 1307 - **Bad:** 1308 - ```typespec 1309 - model Example { 1310 - @maxLength(128) 1311 - @maxGraphemes(64) 1312 - @required 1313 - name: string; 1314 - @required age: int32; // ❌ No blank line after multi-line block 1315 - } 1316 - ``` 1317 - 1318 - ### Union Formatting 1319 - 1320 - **Rule:** Put `string` variant first, then named variants with blank lines between groups 1321 - 1322 - **Good:** 1323 - ```typespec 1324 - union reasonType { 1325 - string, 1326 - 1327 - ReasonSpam: "com.atproto.moderation.defs#reasonSpam", 1328 - ReasonViolation: "com.atproto.moderation.defs#reasonViolation", 1329 - 1330 - ToolsOzoneReasonAppeal: "tools.ozone.report.defs#reasonAppeal", 1413 + ```json 1414 + { 1415 + "lexicon": 1, 1416 + "id": "com.atproto.moderation.defs", 1417 + "defs": { 1418 + "reasonSpam": { 1419 + "type": "token", 1420 + "description": "Spam: frequent unwanted promotion, replies, mentions." 1421 + }, 1422 + "reasonViolation": { 1423 + "type": "token", 1424 + "description": "Direct violation of server rules, laws, terms of service." 1425 + } 1426 + } 1331 1427 } 1332 1428 ``` 1333 1429 1334 - **Good (unnamed):** 1335 - ```typespec 1336 - union LabelValue { 1337 - "!hide", 1338 - "!no-promote", 1339 - "!warn", 1340 - string, 1341 - } 1342 - ``` 1430 + </td></tr> 1431 + </table> 1343 1432 1344 - --- 1433 + **Note:** Tokens become string constants in TypeScript, not types. 1345 1434 1346 - ## Operations 1435 + ## Custom Scalars 1347 1436 1348 - ### Query Operations (HTTP GET) 1437 + Define reusable constrained types: 1349 1438 1350 - **Pattern:** Use `@query` decorator. All operation parameters → lexicon `parameters` field 1439 + <table> 1440 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1441 + <tr><td> 1351 1442 1352 - **TypeSpec:** 1353 1443 ```typespec 1354 - namespace app.bsky.feed.getFeed { 1355 - @doc("Get a hydrated feed from an actor's selected feed generator.") 1356 - @query 1357 - @errors(UnknownFeed) 1358 - op main( 1359 - @required feed: atUri, 1360 - 1361 - @minValue(1) 1362 - @maxValue(100) 1363 - limit?: int32 = 50, 1444 + @maxGraphemes(64) 1445 + @maxLength(640) 1446 + scalar PostTag extends string; 1364 1447 1365 - cursor?: string 1366 - ): { 1367 - cursor?: string; 1368 - @required feed: app.bsky.feed.defs.FeedViewPost[]; 1369 - }; 1370 - 1371 - model UnknownFeed {} 1448 + model Post { 1449 + @maxItems(8) 1450 + tags?: PostTag[]; 1372 1451 } 1373 1452 ``` 1374 1453 1375 - **JSON:** 1454 + </td><td> 1455 + 1376 1456 ```json 1377 1457 { 1378 - "type": "query", 1379 - "description": "Get a hydrated feed from an actor's selected feed generator.", 1380 - "parameters": { 1381 - "type": "params", 1382 - "required": ["feed"], 1458 + "post": { 1459 + "type": "object", 1383 1460 "properties": { 1384 - "feed": { "type": "string", "format": "at-uri" }, 1385 - "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 1386 - "cursor": { "type": "string" } 1387 - } 1388 - }, 1389 - "output": { 1390 - "encoding": "application/json", 1391 - "schema": { 1392 - "type": "object", 1393 - "required": ["feed"], 1394 - "properties": { 1395 - "cursor": { "type": "string" }, 1396 - "feed": { 1397 - "type": "array", 1398 - "items": { "type": "ref", "ref": "app.bsky.feed.defs#feedViewPost" } 1461 + "tags": { 1462 + "type": "array", 1463 + "maxLength": 8, 1464 + "items": { 1465 + "type": "string", 1466 + "maxLength": 640, 1467 + "maxGraphemes": 64 1399 1468 } 1400 1469 } 1401 1470 } 1402 - }, 1403 - "errors": [ 1404 - { "name": "UnknownFeed" } 1405 - ] 1471 + } 1406 1472 } 1407 1473 ``` 1408 1474 1409 - ### Procedure Operations (HTTP POST) 1475 + </td></tr> 1476 + </table> 1410 1477 1411 - **Pattern:** Use `@procedure` decorator. Operation parameters map to lexicon `input` and optionally `parameters` fields. 1478 + **Note:** Constraints are inlined at usage site - no separate def is created. 1412 1479 1413 - **Parameter rules:** 1414 - - **1 param**: Must be named `input`. Maps to lexicon `input` field. 1415 - - **2 params**: MUST be named `input` and `parameters`. Second param must be a plain object (not a model reference). 1416 - - **3+ params**: Error (not allowed). 1480 + ## Documentation 1417 1481 1418 - **Common case - inline input:** 1419 - ```typespec 1420 - namespace chat.bsky.convo.sendMessage { 1421 - @procedure 1422 - op main(input: { 1423 - @required convoId: string; 1424 - @required message: MessageInput; 1425 - }): MessageView; 1426 - } 1427 - ``` 1482 + <table> 1483 + <tr><th>TypeSpec</th><th>Lexicon JSON</th></tr> 1484 + <tr><td> 1428 1485 1429 - **Common case - input with model reference:** 1430 1486 ```typespec 1431 - namespace app.bsky.actor.putPreferences { 1432 - @doc("Set the private preferences attached to the account.") 1433 - @procedure 1434 - op main(input: { 1435 - @required preferences: app.bsky.actor.defs.Preferences; 1436 - }): void; 1437 - } 1438 - ``` 1487 + @doc("A URI with a content-hash fingerprint.") 1488 + namespace com.atproto.repo.strongRef { 1489 + @doc("A strong reference to a specific version of a record.") 1490 + model Main { 1491 + @doc("The AT-URI of the record.") 1492 + @required 1493 + uri: atUri; 1439 1494 1440 - **JSON:** 1441 - ```json 1442 - { 1443 - "type": "procedure", 1444 - "description": "Set the private preferences attached to the account.", 1445 - "input": { 1446 - "encoding": "application/json", 1447 - "schema": { 1448 - "type": "object", 1449 - "required": ["preferences"], 1450 - "properties": { 1451 - "preferences": { "type": "ref", "ref": "app.bsky.actor.defs#preferences" } 1452 - } 1453 - } 1495 + @doc("The CID of the record.") 1496 + @required 1497 + cid: cid; 1454 1498 } 1455 1499 } 1456 1500 ``` 1457 1501 1458 - **Rare case - both input and query parameters:** 1459 - ```typespec 1460 - namespace com.atproto.repo.uploadBlob { 1461 - @doc("Upload a binary blob.") 1462 - @procedure 1463 - op main( 1464 - input: bytes, 1465 - parameters: { 1466 - @required filename: string; 1467 - mimeType?: string; 1468 - } 1469 - ): { 1470 - @required blob: Blob; 1471 - }; 1472 - } 1473 - ``` 1502 + </td><td> 1474 1503 1475 - **JSON:** 1476 1504 ```json 1477 1505 { 1478 - "type": "procedure", 1479 - "description": "Upload a binary blob.", 1480 - "parameters": { 1481 - "type": "params", 1482 - "required": ["filename"], 1483 - "properties": { 1484 - "filename": { "type": "string" }, 1485 - "mimeType": { "type": "string" } 1486 - } 1487 - }, 1488 - "input": { 1489 - "encoding": "application/json", 1490 - "schema": { "type": "bytes" } 1491 - }, 1492 - "output": { 1493 - "encoding": "application/json", 1494 - "schema": { 1506 + "lexicon": 1, 1507 + "id": "com.atproto.repo.strongRef", 1508 + "description": "A URI with a content-hash fingerprint.", 1509 + "defs": { 1510 + "main": { 1495 1511 "type": "object", 1496 - "required": ["blob"], 1512 + "description": "A strong reference to a specific version of a record.", 1513 + "required": ["uri", "cid"], 1497 1514 "properties": { 1498 - "blob": { "type": "blob" } 1515 + "uri": { 1516 + "type": "string", 1517 + "format": "at-uri", 1518 + "description": "The AT-URI of the record." 1519 + }, 1520 + "cid": { 1521 + "type": "string", 1522 + "format": "cid", 1523 + "description": "The CID of the record." 1524 + } 1499 1525 } 1500 1526 } 1501 1527 } 1502 1528 } 1503 1529 ``` 1504 1530 1505 - **No input or output:** 1506 - ```typespec 1507 - @procedure 1508 - op ping(): void; 1509 - ``` 1531 + </td></tr> 1532 + </table> 1510 1533 1511 - **Input encoding:** 1512 - ```typespec 1513 - @procedure 1514 - op upload( 1515 - @encoding("application/octet-stream") 1516 - input: bytes 1517 - ): Result; 1518 - // → input: { encoding: "application/octet-stream", schema: bytes } 1519 - ``` 1534 + ## Code Style 1535 + 1536 + ### Decorator Placement 1520 1537 1521 - **Output encoding:** 1522 1538 ```typespec 1523 - @procedure 1524 - @encoding("application/jsonl") 1525 - op export(input: { data: string }): void; 1526 - // → output: { encoding: "application/jsonl" } 1527 - ``` 1539 + model Example { 1540 + @required name: string; // ✅ @required alone on same line 1528 1541 1529 - ### Subscription Operations (WebSocket) 1542 + @maxLength(100) // ✅ Other decorators on separate lines 1543 + @required 1544 + description: string; 1530 1545 1531 - **Pattern:** Use `@subscription` decorator. Return type must be a union 1546 + @doc("Optional field") // ✅ Blank line after multi-line block 1547 + tag?: string; 1532 1548 1533 - **TypeSpec:** 1534 - ```typespec 1535 - namespace com.atproto.sync.subscribeRepos { 1536 - @doc("Subscribe to repo updates. Returns a stream of messages.") 1537 - @subscription 1538 - op main(cursor?: integer): (Commit | Handle | Migrate | Tombstone | Info); 1539 - 1540 - model Commit { @required seq: integer; } 1541 - model Handle { @required seq: integer; } 1542 - // ... other message types 1549 + @required @maxLength(100) bad: string; // ❌ Multiple decorators on one line 1550 + @doc("Bad") @required bad2: string; // ❌ Non-@required on same line 1543 1551 } 1544 1552 ``` 1545 1553 1546 - **JSON:** 1547 - ```json 1548 - { 1549 - "type": "subscription", 1550 - "description": "Subscribe to repo updates. Returns a stream of messages.", 1551 - "parameters": { 1552 - "type": "params", 1553 - "properties": { 1554 - "cursor": { "type": "integer" } 1555 - } 1556 - }, 1557 - "message": { 1558 - "schema": { 1559 - "type": "union", 1560 - "refs": ["#commit", "#handle", "#migrate", "#tombstone", "#info"] 1561 - } 1562 - } 1563 - } 1564 - ``` 1554 + ### Union Formatting 1565 1555 1566 - ### Custom Encodings 1567 - 1568 - **Pattern:** Use `@encoding` on input parameter for input encoding, on operation for output encoding 1569 - 1570 - **Input encoding:** 1571 1556 ```typespec 1572 - @procedure 1573 - op upload( 1574 - @encoding("application/octet-stream") 1575 - input: bytes 1576 - ): Result; 1577 - // → input: { encoding: "application/octet-stream", schema: bytes } 1578 - ``` 1557 + // String + known values: string first 1558 + union LabelValue { 1559 + "!hide", 1560 + "porn", 1561 + "sexual", 1562 + string, 1563 + } 1579 1564 1580 - **Input encoding (no schema):** 1581 - ```typespec 1582 - @procedure 1583 - op importRepo( 1584 - @encoding("application/vnd.ipld.car") 1585 - input: void 1586 - ): void; 1587 - // → input: { encoding: "application/vnd.ipld.car" } (no schema) 1588 - ``` 1565 + // With named variants: string first, blank lines between groups 1566 + union ReasonType { 1567 + string, 1589 1568 1590 - **Output encoding:** 1591 - ```typespec 1592 - @query 1593 - @encoding("application/jsonl") 1594 - op exportData(): void; 1595 - // → output: { encoding: "application/jsonl" } (no schema) 1596 - ``` 1569 + ReasonSpam: "com.atproto.moderation.defs#reasonSpam", 1570 + ReasonViolation: "com.atproto.moderation.defs#reasonViolation", 1597 1571 1598 - **Both:** 1599 - ```typespec 1600 - @procedure 1601 - @encoding("application/cbor") 1602 - op transform( 1603 - @encoding("multipart/form-data") 1604 - input: FormData 1605 - ): BinaryResult; 1606 - // → input: { encoding: "multipart/form-data", ... } 1607 - // → output: { encoding: "application/cbor", ... } 1608 - ``` 1609 - 1610 - **Default:** Both input and output default to `"application/json"` if not specified 1611 - 1612 - ### Operation Errors 1613 - 1614 - **Pattern:** Define empty error models with optional `@doc`, then use `@errors` decorator 1615 - 1616 - **TypeSpec:** 1617 - ```typespec 1618 - namespace app.bsky.feed.getAuthorFeed { 1619 - model BlockedActor {} 1620 - 1621 - @doc("The requesting account is blocked by the actor") 1622 - model BlockedByActor {} 1623 - 1624 - @query 1625 - @errors(BlockedActor, BlockedByActor) 1626 - op main(...): {...}; 1572 + ToolsOzoneAppeal: "tools.ozone.report.defs#reasonAppeal", 1627 1573 } 1628 1574 ``` 1629 1575 1630 - **JSON:** 1631 - ```json 1632 - { 1633 - "errors": [ 1634 - { "name": "BlockedActor" }, 1635 - { 1636 - "name": "BlockedByActor", 1637 - "description": "The requesting account is blocked by the actor" 1638 - } 1639 - ] 1640 - } 1641 - ``` 1576 + ## Atproto Idioms Summary 1642 1577 1643 - **Notes:** 1644 - - Error models must be empty (only `@doc` allowed) 1645 - - Error models are NOT emitted as defs - they only exist to declare error names 1646 - - Model name becomes the error name in JSON 1647 - - `@doc` on the model becomes the error description (optional) 1578 + **Required fields are rare:** 1579 + - Use `@required` only for core identifiers (did, handle), timestamps (createdAt), and critical operation params 1580 + - Most profile/view fields should be optional for forward compatibility 1648 1581 1649 - --- 1582 + **Open unions everywhere:** 1583 + - Always include `| unknown` in unions 1584 + - Allows adding new variants without breaking old clients 1585 + - Only use closed unions for internal fixed operations (like applyWrites) 1650 1586 1651 - ## Summary: Quick Reference 1587 + **Pagination pattern:** 1588 + - Queries: optional `limit?: int32 = 50` and `cursor?: string` params 1589 + - Results: `cursor?: string` and required items array in output 1652 1590 1653 - | Lexicon Pattern | TypeSpec Syntax | Notes | 1654 - |----------------|-----------------|-------| 1655 - | **Basic Types** | | | 1656 - | String with format | `uri`, `did`, `datetime`, etc. | Use predefined scalars | 1657 - | Custom scalar with constraints | `scalar Tag extends string` + decorators | Inlined at usage site | 1658 - | Required field | `@required field: Type` | Must be explicit in atproto | 1659 - | Optional field | `field?: Type` | Use `?` suffix | 1660 - | Nullable field | `field: Type \| null` | Adds to `nullable` array | 1661 - | Array | `Type[]` | Standard TS syntax | 1662 - | Array min/max items | `@minItems(n)`, `@maxItems(n)` | On property | 1663 - | String max length | `@maxLength(n)` | Byte length | 1664 - | String max graphemes | `@maxGraphemes(n)` | Character count | 1665 - | Integer min/max | `@minValue(n)`, `@maxValue(n)` | On numeric fields | 1666 - | Const value | `@lexConst(value)` | Custom decorator | 1667 - | Default value | `field?: Type = value` | Standard TypeSpec | 1668 - | **Objects & Unions** | | | 1669 - | Object | `model Name { }` | Standard TypeSpec | 1670 - | Union of objects | `(Type1 \| Type2)[]` | Emits `type: "union"` | 1671 - | Union def (Preferences) | `union Name { Type1, Type2 }` | Auto-wrapped in array | 1672 - | Open union | `(Type \| unknown)[]` | Allows extensibility | 1673 - | String + known values (inline) | `"a" \| "b" \| string` | On property | 1674 - | String + known values (def) | `union Name { "a", "b", string }` | Named def | 1675 - | Token | `@token model Name {}` | Empty model | 1676 - | **Binary Data** | | | 1677 - | Blob | `Blob<#[mimes], size>` | Template syntax | 1678 - | Bytes | `bytes` | Scalar type | 1679 - | **Records & Refs** | | | 1680 - | Record | `@record("tid") model Main` | On Main model | 1681 - | Reference (same namespace) | `ModelName` | Becomes `#modelName` | 1682 - | Reference (cross namespace) | `Namespace.Model` | Becomes `namespace#model` | 1683 - | **Operations** | | | 1684 - | Query (HTTP GET) | `@query op main(...): {...}` | Params → parameters | 1685 - | Procedure (HTTP POST) | `@procedure op main(input: T): R` | 1st param → input, 2nd param (if named `parameters`) → parameters | 1686 - | Procedure (anonymous) | `@procedure op main({ ... }): R` | Anonymous object → input | 1687 - | Subscription (WebSocket) | `@subscription op main(...): (A\|B)` | Return must be union | 1688 - | Input encoding | `@encoding("mime") input: T` | On input parameter | 1689 - | Output encoding | `@encoding("mime") @query op ...` | On operation | 1690 - | Operation errors | `@errors(ErrorA, ErrorB)` | On operation | 1691 - | **Other** | | | 1692 - | Documentation | `@doc("...")` | On any declaration | 1591 + **Extensible enums:** 1592 + - Always use `"value1" | "value2" | string` pattern 1593 + - Never use plain `"value1" | "value2"` - clients must handle unknown values 1594 + 1595 + ## Quick Reference Table 1693 1596 1597 + | Lexicon JSON | TypeSpec | Notes | 1598 + |--------------|----------|-------| 1599 + | `{ "type": "string" }` | `string` | | 1600 + | `{ "type": "string", "format": "did" }` | `did` | Use predefined format scalars | 1601 + | `{ "type": "integer" }` | `integer` | Also: `int32`, `int64`, `float32`, `float64` | 1602 + | `{ "type": "boolean" }` | `boolean` | | 1603 + | `{ "type": "bytes" }` | `bytes` | | 1604 + | `{ "type": "unknown" }` | `unknown` | Rarely used directly | 1605 + | `{ "type": "blob", "accept": [...], "maxSize": N }` | `Blob<#[...], N>` | | 1606 + | `{ "type": "array", "items": T }` | `T[]` | | 1607 + | `{ "type": "object", "required": [...] }` | `model M { @required ... }` | Required is rare | 1608 + | `{ "type": "ref", "ref": "#foo" }` | `Foo` | Same namespace | 1609 + | `{ "type": "ref", "ref": "ns.id#foo" }` | `ns.id.Foo` | Cross namespace def | 1610 + | `{ "type": "ref", "ref": "ns.id" }` | `ns.id.Main` | Cross namespace main | 1611 + | `{ "type": "union", "refs": [...] }` | `(A \| B \| unknown)` | **Default - always use** | 1612 + | `{ "type": "union", "refs": [...], "closed": true }` | `Closed<A \| B>` | **Rare - internal ops only** | 1613 + | `{ "type": "union", "refs": [] }` | `(never \| unknown)` | Empty union | 1614 + | `{ "type": "string", "knownValues": [...] }` | `"a" \| "b" \| string` | **Always include string** | 1615 + | `{ "type": "token" }` | `@token model M {}` | String constant | 1616 + | `{ "type": "record", "key": "tid" }` | `@record("tid") model Main` | | 1617 + | `{ "type": "query" }` | `@query op main(...)` | Usually with limit/cursor | 1618 + | `{ "type": "procedure" }` | `@procedure op main(input: T)` | 1-2 params only | 1619 + | `{ "type": "subscription" }` | `@subscription op main(...)` | Return must be union | 1620 + | `"maxLength": N` | `@maxLength(N)` | String byte length | 1621 + | `"maxGraphemes": N` | `@maxGraphemes(N)` | String char count | 1622 + | `"minimum": N` / `"maximum": N` | `@minValue(N)` / `@maxValue(N)` | Number constraints | 1623 + | `"minLength": N` / `"maxLength": N` (array) | `@minItems(N)` / `@maxItems(N)` | Array constraints | 1624 + | `"default": V` | `field?: T = V` | Default value |
+57 -52
packages/emitter/test/scenarios.test.ts
··· 17 17 18 18 const pkgRoot = await findTestPackageRoot(import.meta.url); 19 19 const SCENARIOS_DIRECTORY = resolvePath(pkgRoot, "test/scenarios"); 20 + const SPEC_DIRECTORY = resolvePath(pkgRoot, "test/spec"); 20 21 21 22 const TlexTestLibrary: TypeSpecTestLibrary = { 22 23 name: "@tlex/emitter", ··· 40 41 ], 41 42 }; 42 43 43 - describe("lexicon scenarios", function () { 44 - const scenarios = readdirSync(SCENARIOS_DIRECTORY) 45 - .map((dn) => path.join(SCENARIOS_DIRECTORY, dn)) 46 - .filter((dn) => statSync(dn).isDirectory()); 44 + function runTestsForDirectory(testDir: string, suiteName: string) { 45 + describe(suiteName, function () { 46 + const scenarios = readdirSync(testDir) 47 + .map((dn) => path.join(testDir, dn)) 48 + .filter((dn) => statSync(dn).isDirectory()); 47 49 48 - for (const scenario of scenarios) { 49 - const scenarioName = path.basename(scenario); 50 + for (const scenario of scenarios) { 51 + const scenarioName = path.basename(scenario); 52 + 53 + describe(scenarioName, async function () { 54 + const inputFiles = await readdirRecursive(path.join(scenario, "input")); 55 + const expectedFiles = await readdirRecursive(path.join(scenario, "output")); 50 56 51 - describe(scenarioName, async function () { 52 - const inputFiles = await readdirRecursive(path.join(scenario, "input")); 53 - const expectedFiles = await readdirRecursive(path.join(scenario, "output")); 57 + // Compile all inputs together (for cross-references) 58 + const tspFiles = Object.keys(inputFiles).filter((f) => f.endsWith(".tsp")); 59 + let emitResult: EmitResult; 54 60 55 - // Compile all inputs together (for cross-references) 56 - const tspFiles = Object.keys(inputFiles).filter((f) => f.endsWith(".tsp")); 57 - let emitResult: EmitResult; 61 + if (tspFiles.length > 0) { 62 + // Create a virtual main.tsp that imports all other files 63 + const mainContent = 'import "@tlex/emitter";\n' + 64 + tspFiles.map(f => `import "./${f}";`).join('\n'); 65 + const filesWithMain = { ...inputFiles, "main.tsp": mainContent }; 66 + emitResult = await doEmit(filesWithMain, "main.tsp"); 67 + } else { 68 + emitResult = { files: {}, diagnostics: [], inputFiles: {} }; 69 + } 58 70 59 - if (tspFiles.length > 0) { 60 - // Create a virtual main.tsp that imports all other files 61 - const mainContent = 'import "@tlex/emitter";\n' + 62 - tspFiles.map(f => `import "./${f}";`).join('\n'); 63 - const filesWithMain = { ...inputFiles, "main.tsp": mainContent }; 64 - emitResult = await doEmit(filesWithMain, "main.tsp"); 65 - } else { 66 - emitResult = { files: {}, diagnostics: [], inputFiles: {} }; 67 - } 71 + // Generate a test for each expected output 72 + for (const expectedPath of Object.keys(expectedFiles)) { 73 + if (!expectedPath.endsWith(".json")) continue; 68 74 69 - // Generate a test for each expected output 70 - for (const expectedPath of Object.keys(expectedFiles)) { 71 - if (!expectedPath.endsWith(".json")) continue; 75 + // Derive expected input path: output/app/bsky/feed/post.json -> input/app/bsky/feed/post.tsp 76 + const inputPath = expectedPath.replace(/\.json$/, ".tsp"); 77 + const hasInput = Object.keys(inputFiles).includes(inputPath); 72 78 73 - // Derive expected input path: output/app/bsky/feed/post.json -> input/app/bsky/feed/post.tsp 74 - const inputPath = expectedPath.replace(/\.json$/, ".tsp"); 75 - const hasInput = Object.keys(inputFiles).includes(inputPath); 79 + if (hasInput) { 80 + it(`should emit ${expectedPath}`, function () { 81 + // Check for compilation errors 82 + if (emitResult.diagnostics.length > 0) { 83 + const formattedDiagnostics = emitResult.diagnostics.map((diag) => 84 + formatDiagnostic(diag) 85 + ); 86 + assert.fail( 87 + `Expected no diagnostics but got:\n${formattedDiagnostics.join("\n\n")}` 88 + ); 89 + } 76 90 77 - if (hasInput) { 78 - it(`should emit ${expectedPath}`, function () { 79 - // Check for compilation errors 80 - if (emitResult.diagnostics.length > 0) { 81 - const formattedDiagnostics = emitResult.diagnostics.map((diag) => 82 - formatDiagnostic(diag) 91 + assert.ok( 92 + Object.prototype.hasOwnProperty.call(emitResult.files, expectedPath), 93 + `Expected file ${expectedPath} was not produced`, 83 94 ); 84 - assert.fail( 85 - `Expected no diagnostics but got:\n${formattedDiagnostics.join("\n\n")}` 86 - ); 87 - } 88 95 89 - assert.ok( 90 - Object.prototype.hasOwnProperty.call(emitResult.files, expectedPath), 91 - `Expected file ${expectedPath} was not produced`, 92 - ); 93 - 94 - const actual = JSON.parse(emitResult.files[expectedPath]); 95 - const expected = JSON.parse(expectedFiles[expectedPath]); 96 - assert.deepStrictEqual(actual, expected); 97 - }); 98 - } else { 99 - it.skip(`TODO: ${expectedPath} (add ${inputPath})`, function () {}); 96 + const actual = JSON.parse(emitResult.files[expectedPath]); 97 + const expected = JSON.parse(expectedFiles[expectedPath]); 98 + assert.deepStrictEqual(actual, expected); 99 + }); 100 + } else { 101 + it.skip(`TODO: ${expectedPath} (add ${inputPath})`, function () {}); 102 + } 100 103 } 101 - } 102 - }); 104 + }); 105 + } 106 + }); 107 + } 103 108 104 - } 105 - }); 109 + runTestsForDirectory(SCENARIOS_DIRECTORY, "lexicon scenarios"); 110 + runTestsForDirectory(SPEC_DIRECTORY, "lexicon spec"); 106 111 107 112 interface EmitResult { 108 113 files: Record<string, string>;
-30
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/createTemplate.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.communication.createTemplate { 4 - model DuplicateTemplateName {} 5 - 6 - @doc("Administrative action to create a new, re-usable communication (email for now) template.") 7 - @procedure 8 - @errors(DuplicateTemplateName) 9 - op main( 10 - input: { 11 - @doc("Subject of the message, used in emails.") 12 - @required 13 - subject: string; 14 - 15 - @doc("Content of the template, markdown supported, can contain variable placeholders.") 16 - @required 17 - contentMarkdown: string; 18 - 19 - @doc("Name of the template.") 20 - @required 21 - name: string; 22 - 23 - @doc("Message language.") 24 - lang?: language; 25 - 26 - @doc("DID of the user who is creating the template.") 27 - createdBy?: did; 28 - } 29 - ): tools.ozone.communication.defs.TemplateView; 30 - }
-30
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.communication.defs { 4 - model TemplateView { 5 - @required id: string; 6 - 7 - @doc("Name of the template.") 8 - @required 9 - name: string; 10 - 11 - @doc("Content of the template, can contain markdown and variable placeholders.") 12 - subject?: string; 13 - 14 - @doc("Subject of the message, used in emails.") 15 - @required 16 - contentMarkdown: string; 17 - 18 - @required disabled: boolean; 19 - 20 - @doc("Message language.") 21 - lang?: language; 22 - 23 - @doc("DID of the user who last updated the template.") 24 - @required 25 - lastUpdatedBy: did; 26 - 27 - @required createdAt: datetime; 28 - @required updatedAt: datetime; 29 - } 30 - }
-11
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/deleteTemplate.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.communication.deleteTemplate { 4 - @doc("Delete a communication template.") 5 - @procedure 6 - op main( 7 - input: { 8 - @required id: string; 9 - } 10 - ): void; 11 - }
-9
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/listTemplates.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.communication.listTemplates { 4 - @doc("Get list of all communication templates.") 5 - @query 6 - op main(): { 7 - @required communicationTemplates: tools.ozone.communication.defs.TemplateView[]; 8 - }; 9 - }
-33
packages/emitter/test/scenarios/atproto/input/tools/ozone/communication/updateTemplate.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.communication.updateTemplate { 4 - model DuplicateTemplateName {} 5 - 6 - @doc("Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.") 7 - @procedure 8 - @errors(DuplicateTemplateName) 9 - op main( 10 - input: { 11 - @doc("ID of the template to be updated.") 12 - @required 13 - id: string; 14 - 15 - @doc("Name of the template.") 16 - name?: string; 17 - 18 - @doc("Message language.") 19 - lang?: language; 20 - 21 - @doc("Content of the template, markdown supported, can contain variable placeholders.") 22 - contentMarkdown?: string; 23 - 24 - @doc("Subject of the message, used in emails.") 25 - subject?: string; 26 - 27 - @doc("DID of the user who is updating the template.") 28 - updatedBy?: did; 29 - 30 - disabled?: boolean; 31 - } 32 - ): tools.ozone.communication.defs.TemplateView; 33 - }
-540
packages/emitter/test/scenarios/atproto/input/tools/ozone/moderation/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.moderation.defs { 4 - model ModEventView { 5 - @required id: integer; 6 - @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent; 7 - @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef; 8 - @required subjectBlobCids: string[]; 9 - @required createdBy: did; 10 - @required createdAt: datetime; 11 - creatorHandle?: string; 12 - subjectHandle?: string; 13 - modTool?: ModTool; 14 - } 15 - 16 - model ModEventViewDetail { 17 - @required id: integer; 18 - @required event: ModEventTakedown | ModEventReverseTakedown | ModEventComment | ModEventReport | ModEventLabel | ModEventAcknowledge | ModEventEscalate | ModEventMute | ModEventUnmute | ModEventMuteReporter | ModEventUnmuteReporter | ModEventEmail | ModEventResolveAppeal | ModEventDivert | ModEventTag | AccountEvent | IdentityEvent | RecordEvent | ModEventPriorityScore | AgeAssuranceEvent | AgeAssuranceOverrideEvent | RevokeAccountCredentialsEvent; 19 - @required subject: RepoView | RepoViewNotFound | RecordView | RecordViewNotFound; 20 - @required subjectBlobs: BlobView[]; 21 - @required createdBy: did; 22 - @required createdAt: datetime; 23 - modTool?: ModTool; 24 - } 25 - 26 - model SubjectStatusView { 27 - @required id: integer; 28 - @required subject: com.atproto.admin.defs.RepoRef | com.atproto.repo.strongRef.Main | chat.bsky.convo.defs.MessageRef; 29 - hosting?: AccountHosting | RecordHosting; 30 - subjectBlobCids?: cid[]; 31 - subjectRepoHandle?: string; 32 - 33 - @doc("Timestamp referencing the first moderation status impacting event was emitted on the subject") 34 - @required 35 - createdAt: datetime; 36 - 37 - @doc("Timestamp referencing when the last update was made to the moderation status of the subject") 38 - @required 39 - updatedAt: datetime; 40 - 41 - @required reviewState: SubjectReviewState; 42 - 43 - @doc("Sticky comment on the subject.") 44 - comment?: string; 45 - 46 - @doc("Numeric value representing the level of priority. Higher score means higher priority.") 47 - @minValue(0) 48 - @maxValue(100) 49 - priorityScore?: integer; 50 - 51 - muteUntil?: datetime; 52 - muteReportingUntil?: datetime; 53 - lastReviewedBy?: did; 54 - lastReviewedAt?: datetime; 55 - lastReportedAt?: datetime; 56 - 57 - @doc("Timestamp referencing when the author of the subject appealed a moderation action") 58 - lastAppealedAt?: datetime; 59 - 60 - takendown?: boolean; 61 - 62 - @doc("True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.") 63 - appealed?: boolean; 64 - 65 - suspendUntil?: datetime; 66 - tags?: string[]; 67 - 68 - @doc("Statistics related to the account subject") 69 - accountStats?: AccountStats; 70 - 71 - @doc("Statistics related to the record subjects authored by the subject's account") 72 - recordsStats?: RecordsStats; 73 - 74 - @doc("Current age assurance state of the subject.") 75 - ageAssuranceState?: "pending" | "assured" | "unknown" | "reset" | "blocked" | string; 76 - 77 - @doc("Whether or not the last successful update to age assurance was made by the user or admin.") 78 - ageAssuranceUpdatedBy?: "admin" | "user" | string; 79 - } 80 - 81 - @doc("Detailed view of a subject. For record subjects, the author's repo and profile will be returned.") 82 - model SubjectView { 83 - @required type: com.atproto.moderation.defs.SubjectType; 84 - @required subject: string; 85 - status?: SubjectStatusView; 86 - repo?: RepoViewDetail; 87 - profile?: (never | unknown); 88 - record?: RecordViewDetail; 89 - } 90 - 91 - @doc("Statistics about a particular account subject") 92 - model AccountStats { 93 - @doc("Total number of reports on the account") 94 - reportCount?: integer; 95 - 96 - @doc("Total number of appeals against a moderation action on the account") 97 - appealCount?: integer; 98 - 99 - @doc("Number of times the account was suspended") 100 - suspendCount?: integer; 101 - 102 - @doc("Number of times the account was escalated") 103 - escalateCount?: integer; 104 - 105 - @doc("Number of times the account was taken down") 106 - takedownCount?: integer; 107 - } 108 - 109 - @doc("Statistics about a set of record subject items") 110 - model RecordsStats { 111 - @doc("Cumulative sum of the number of reports on the items in the set") 112 - totalReports?: integer; 113 - 114 - @doc("Number of items that were reported at least once") 115 - reportedCount?: integer; 116 - 117 - @doc("Number of items that were escalated at least once") 118 - escalatedCount?: integer; 119 - 120 - @doc("Number of items that were appealed at least once") 121 - appealedCount?: integer; 122 - 123 - @doc("Total number of item in the set") 124 - subjectCount?: integer; 125 - 126 - @doc("Number of item currently in \"reviewOpen\" or \"reviewEscalated\" state") 127 - pendingCount?: integer; 128 - 129 - @doc("Number of item currently in \"reviewNone\" or \"reviewClosed\" state") 130 - processedCount?: integer; 131 - 132 - @doc("Number of item currently taken down") 133 - takendownCount?: integer; 134 - } 135 - 136 - union SubjectReviewState { 137 - string, 138 - 139 - ReviewOpen: "#reviewOpen", 140 - ReviewEscalated: "#reviewEscalated", 141 - ReviewClosed: "#reviewClosed", 142 - ReviewNone: "#reviewNone", 143 - } 144 - 145 - @doc("Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator") 146 - @token 147 - model ReviewOpen {} 148 - 149 - @doc("Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator") 150 - @token 151 - model ReviewEscalated {} 152 - 153 - @doc("Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator") 154 - @token 155 - model ReviewClosed {} 156 - 157 - @doc("Moderator review status of a subject: Unnecessary. Indicates that the subject does not need a review at the moment but there is probably some moderation related metadata available for it") 158 - @token 159 - model ReviewNone {} 160 - 161 - @doc("Take down a subject permanently or temporarily") 162 - model ModEventTakedown { 163 - comment?: string; 164 - 165 - @doc("Indicates how long the takedown should be in effect before automatically expiring.") 166 - durationInHours?: integer; 167 - 168 - @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).") 169 - acknowledgeAccountSubjects?: boolean; 170 - 171 - @maxItems(5) 172 - @doc("Names/Keywords of the policies that drove the decision.") 173 - policies?: string[]; 174 - } 175 - 176 - @doc("Revert take down action on a subject") 177 - model ModEventReverseTakedown { 178 - @doc("Describe reasoning behind the reversal.") 179 - comment?: string; 180 - } 181 - 182 - @doc("Resolve appeal on a subject") 183 - model ModEventResolveAppeal { 184 - @doc("Describe resolution.") 185 - comment?: string; 186 - } 187 - 188 - @doc("Add a comment to a subject. An empty comment will clear any previously set sticky comment.") 189 - model ModEventComment { 190 - comment?: string; 191 - 192 - @doc("Make the comment persistent on the subject") 193 - sticky?: boolean; 194 - } 195 - 196 - @doc("Report a subject") 197 - model ModEventReport { 198 - comment?: string; 199 - 200 - @doc("Set to true if the reporter was muted from reporting at the time of the event. These reports won't impact the reviewState of the subject.") 201 - isReporterMuted?: boolean; 202 - 203 - @required reportType: com.atproto.moderation.defs.ReasonType; 204 - } 205 - 206 - @doc("Apply/Negate labels on a subject") 207 - model ModEventLabel { 208 - comment?: string; 209 - @required createLabelVals: string[]; 210 - @required negateLabelVals: string[]; 211 - 212 - @doc("Indicates how long the label will remain on the subject. Only applies on labels that are being added.") 213 - durationInHours?: integer; 214 - } 215 - 216 - @doc("Set priority score of the subject. Higher score means higher priority.") 217 - model ModEventPriorityScore { 218 - comment?: string; 219 - 220 - @minValue(0) 221 - @maxValue(100) 222 - @required 223 - score: integer; 224 - } 225 - 226 - @doc("Age assurance info coming directly from users. Only works on DID subjects.") 227 - model AgeAssuranceEvent { 228 - @doc("The date and time of this write operation.") 229 - @required 230 - createdAt: datetime; 231 - 232 - @doc("The status of the age assurance process.") 233 - @required 234 - status: "unknown" | "pending" | "assured" | string; 235 - 236 - @doc("The unique identifier for this instance of the age assurance flow, in UUID format.") 237 - @required 238 - attemptId: string; 239 - 240 - @doc("The IP address used when initiating the AA flow.") 241 - initIp?: string; 242 - 243 - @doc("The user agent used when initiating the AA flow.") 244 - initUa?: string; 245 - 246 - @doc("The IP address used when completing the AA flow.") 247 - completeIp?: string; 248 - 249 - @doc("The user agent used when completing the AA flow.") 250 - completeUa?: string; 251 - } 252 - 253 - @doc("Age assurance status override by moderators. Only works on DID subjects.") 254 - model AgeAssuranceOverrideEvent { 255 - @doc("Comment describing the reason for the override.") 256 - @required 257 - comment: string; 258 - 259 - @doc("The status to be set for the user decided by a moderator, overriding whatever value the user had previously. Use reset to default to original state.") 260 - @required 261 - status: "assured" | "reset" | "blocked" | string; 262 - } 263 - 264 - @doc("Account credentials revocation by moderators. Only works on DID subjects.") 265 - model RevokeAccountCredentialsEvent { 266 - @doc("Comment describing the reason for the revocation.") 267 - @required 268 - comment: string; 269 - } 270 - 271 - model ModEventAcknowledge { 272 - comment?: string; 273 - 274 - @doc("If true, all other reports on content authored by this account will be resolved (acknowledged).") 275 - acknowledgeAccountSubjects?: boolean; 276 - } 277 - 278 - model ModEventEscalate { 279 - comment?: string; 280 - } 281 - 282 - @doc("Mute incoming reports on a subject") 283 - model ModEventMute { 284 - comment?: string; 285 - 286 - @doc("Indicates how long the subject should remain muted.") 287 - @required 288 - durationInHours: integer; 289 - } 290 - 291 - @doc("Unmute action on a subject") 292 - model ModEventUnmute { 293 - @doc("Describe reasoning behind the reversal.") 294 - comment?: string; 295 - } 296 - 297 - @doc("Mute incoming reports from an account") 298 - model ModEventMuteReporter { 299 - comment?: string; 300 - 301 - @doc("Indicates how long the account should remain muted. Falsy value here means a permanent mute.") 302 - durationInHours?: integer; 303 - } 304 - 305 - @doc("Unmute incoming reports from an account") 306 - model ModEventUnmuteReporter { 307 - @doc("Describe reasoning behind the reversal.") 308 - comment?: string; 309 - } 310 - 311 - @doc("Keep a log of outgoing email to a user") 312 - model ModEventEmail { 313 - @doc("The subject line of the email sent to the user.") 314 - @required 315 - subjectLine: string; 316 - 317 - @doc("The content of the email sent to the user.") 318 - content?: string; 319 - 320 - @doc("Additional comment about the outgoing comm.") 321 - comment?: string; 322 - } 323 - 324 - @doc("Divert a record's blobs to a 3rd party service for further scanning/tagging") 325 - model ModEventDivert { 326 - comment?: string; 327 - } 328 - 329 - @doc("Add/Remove a tag on a subject") 330 - model ModEventTag { 331 - @doc("Tags to be added to the subject. If already exists, won't be duplicated.") 332 - @required 333 - add: string[]; 334 - 335 - @doc("Tags to be removed to the subject. Ignores a tag If it doesn't exist, won't be duplicated.") 336 - @required 337 - remove: string[]; 338 - 339 - @doc("Additional comment about added/removed tags.") 340 - comment?: string; 341 - } 342 - 343 - @doc("Logs account status related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 344 - model AccountEvent { 345 - comment?: string; 346 - @required timestamp: datetime; 347 - 348 - @doc("Indicates that the account has a repository which can be fetched from the host that emitted this event.") 349 - @required 350 - active: boolean; 351 - 352 - status?: "unknown" | "deactivated" | "deleted" | "takendown" | "suspended" | "tombstoned" | string; 353 - } 354 - 355 - @doc("Logs identity related events on a repo subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 356 - model IdentityEvent { 357 - comment?: string; 358 - handle?: handle; 359 - pdsHost?: uri; 360 - tombstone?: boolean; 361 - @required timestamp: datetime; 362 - } 363 - 364 - @doc("Logs lifecycle event on a record subject. Normally captured by automod from the firehose and emitted to ozone for historical tracking.") 365 - model RecordEvent { 366 - comment?: string; 367 - @required timestamp: datetime; 368 - 369 - @required 370 - `op`: "create" | "update" | "delete" | string; 371 - 372 - cid?: cid; 373 - } 374 - 375 - model RepoView { 376 - @required did: did; 377 - @required handle: handle; 378 - email?: string; 379 - @required relatedRecords: unknown[]; 380 - @required indexedAt: datetime; 381 - @required moderation: Moderation; 382 - invitedBy?: com.atproto.server.defs.InviteCode; 383 - invitesDisabled?: boolean; 384 - inviteNote?: string; 385 - deactivatedAt?: datetime; 386 - threatSignatures?: com.atproto.admin.defs.ThreatSignature[]; 387 - } 388 - 389 - model RepoViewDetail { 390 - @required did: did; 391 - @required handle: handle; 392 - email?: string; 393 - @required relatedRecords: unknown[]; 394 - @required indexedAt: datetime; 395 - @required moderation: ModerationDetail; 396 - labels?: com.atproto.label.defs.Label[]; 397 - invitedBy?: com.atproto.server.defs.InviteCode; 398 - invites?: com.atproto.server.defs.InviteCode[]; 399 - invitesDisabled?: boolean; 400 - inviteNote?: string; 401 - emailConfirmedAt?: datetime; 402 - deactivatedAt?: datetime; 403 - threatSignatures?: com.atproto.admin.defs.ThreatSignature[]; 404 - } 405 - 406 - model RepoViewNotFound { 407 - @required did: did; 408 - } 409 - 410 - model RecordView { 411 - @required uri: atUri; 412 - @required cid: cid; 413 - @required value: unknown; 414 - @required blobCids: cid[]; 415 - @required indexedAt: datetime; 416 - @required moderation: Moderation; 417 - @required repo: RepoView; 418 - } 419 - 420 - model RecordViewDetail { 421 - @required uri: atUri; 422 - @required cid: cid; 423 - @required value: unknown; 424 - @required blobs: BlobView[]; 425 - labels?: com.atproto.label.defs.Label[]; 426 - @required indexedAt: datetime; 427 - @required moderation: ModerationDetail; 428 - @required repo: RepoView; 429 - } 430 - 431 - model RecordViewNotFound { 432 - @required uri: atUri; 433 - } 434 - 435 - model Moderation { 436 - subjectStatus?: SubjectStatusView; 437 - } 438 - 439 - model ModerationDetail { 440 - subjectStatus?: SubjectStatusView; 441 - } 442 - 443 - model BlobView { 444 - @required cid: cid; 445 - @required mimeType: string; 446 - @required size: integer; 447 - @required createdAt: datetime; 448 - details?: ImageDetails | VideoDetails; 449 - moderation?: Moderation; 450 - } 451 - 452 - model ImageDetails { 453 - @required width: integer; 454 - @required height: integer; 455 - } 456 - 457 - model VideoDetails { 458 - @required width: integer; 459 - @required height: integer; 460 - @required length: integer; 461 - } 462 - 463 - model AccountHosting { 464 - @required 465 - status: "takendown" | "suspended" | "deleted" | "deactivated" | "unknown" | string; 466 - 467 - updatedAt?: datetime; 468 - createdAt?: datetime; 469 - deletedAt?: datetime; 470 - deactivatedAt?: datetime; 471 - reactivatedAt?: datetime; 472 - } 473 - 474 - model RecordHosting { 475 - @required 476 - status: "deleted" | "unknown" | string; 477 - 478 - updatedAt?: datetime; 479 - createdAt?: datetime; 480 - deletedAt?: datetime; 481 - } 482 - 483 - model ReporterStats { 484 - @required did: did; 485 - 486 - @doc("The total number of reports made by the user on accounts.") 487 - @required 488 - accountReportCount: integer; 489 - 490 - @doc("The total number of reports made by the user on records.") 491 - @required 492 - recordReportCount: integer; 493 - 494 - @doc("The total number of accounts reported by the user.") 495 - @required 496 - reportedAccountCount: integer; 497 - 498 - @doc("The total number of records reported by the user.") 499 - @required 500 - reportedRecordCount: integer; 501 - 502 - @doc("The total number of accounts taken down as a result of the user's reports.") 503 - @required 504 - takendownAccountCount: integer; 505 - 506 - @doc("The total number of records taken down as a result of the user's reports.") 507 - @required 508 - takendownRecordCount: integer; 509 - 510 - @doc("The total number of accounts labeled as a result of the user's reports.") 511 - @required 512 - labeledAccountCount: integer; 513 - 514 - @doc("The total number of records labeled as a result of the user's reports.") 515 - @required 516 - labeledRecordCount: integer; 517 - } 518 - 519 - @doc("Moderation tool information for tracing the source of the action") 520 - model ModTool { 521 - @doc("Name/identifier of the source (e.g., 'automod', 'ozone/workspace')") 522 - @required 523 - name: string; 524 - 525 - @doc("Additional arbitrary metadata about the source") 526 - meta?: unknown; 527 - } 528 - 529 - @doc("Moderation event timeline event for a PLC create operation") 530 - @token 531 - model TimelineEventPlcCreate {} 532 - 533 - @doc("Moderation event timeline event for generic PLC operation") 534 - @token 535 - model TimelineEventPlcOperation {} 536 - 537 - @doc("Moderation event timeline event for a PLC tombstone operation") 538 - @token 539 - model TimelineEventPlcTombstone {} 540 - }
-240
packages/emitter/test/scenarios/atproto/input/tools/ozone/report/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.report.defs { 4 - union ReasonType { 5 - string, 6 - 7 - ReasonAppeal: "tools.ozone.report.defs#reasonAppeal", 8 - 9 - ReasonViolenceAnimalWelfare: "tools.ozone.report.defs#reasonViolenceAnimalWelfare", 10 - ReasonViolenceThreats: "tools.ozone.report.defs#reasonViolenceThreats", 11 - ReasonViolenceGraphicContent: "tools.ozone.report.defs#reasonViolenceGraphicContent", 12 - ReasonViolenceSelfHarm: "tools.ozone.report.defs#reasonViolenceSelfHarm", 13 - ReasonViolenceGlorification: "tools.ozone.report.defs#reasonViolenceGlorification", 14 - ReasonViolenceExtremistContent: "tools.ozone.report.defs#reasonViolenceExtremistContent", 15 - ReasonViolenceTrafficking: "tools.ozone.report.defs#reasonViolenceTrafficking", 16 - ReasonViolenceOther: "tools.ozone.report.defs#reasonViolenceOther", 17 - 18 - ReasonSexualAbuseContent: "tools.ozone.report.defs#reasonSexualAbuseContent", 19 - ReasonSexualNCII: "tools.ozone.report.defs#reasonSexualNCII", 20 - ReasonSexualSextortion: "tools.ozone.report.defs#reasonSexualSextortion", 21 - ReasonSexualDeepfake: "tools.ozone.report.defs#reasonSexualDeepfake", 22 - ReasonSexualAnimal: "tools.ozone.report.defs#reasonSexualAnimal", 23 - ReasonSexualUnlabeled: "tools.ozone.report.defs#reasonSexualUnlabeled", 24 - ReasonSexualOther: "tools.ozone.report.defs#reasonSexualOther", 25 - 26 - ReasonChildSafetyCSAM: "tools.ozone.report.defs#reasonChildSafetyCSAM", 27 - ReasonChildSafetyGroom: "tools.ozone.report.defs#reasonChildSafetyGroom", 28 - ReasonChildSafetyMinorPrivacy: "tools.ozone.report.defs#reasonChildSafetyMinorPrivacy", 29 - ReasonChildSafetyEndangerment: "tools.ozone.report.defs#reasonChildSafetyEndangerment", 30 - ReasonChildSafetyHarassment: "tools.ozone.report.defs#reasonChildSafetyHarassment", 31 - ReasonChildSafetyPromotion: "tools.ozone.report.defs#reasonChildSafetyPromotion", 32 - ReasonChildSafetyOther: "tools.ozone.report.defs#reasonChildSafetyOther", 33 - 34 - ReasonHarassmentTroll: "tools.ozone.report.defs#reasonHarassmentTroll", 35 - ReasonHarassmentTargeted: "tools.ozone.report.defs#reasonHarassmentTargeted", 36 - ReasonHarassmentHateSpeech: "tools.ozone.report.defs#reasonHarassmentHateSpeech", 37 - ReasonHarassmentDoxxing: "tools.ozone.report.defs#reasonHarassmentDoxxing", 38 - ReasonHarassmentOther: "tools.ozone.report.defs#reasonHarassmentOther", 39 - 40 - ReasonMisleadingBot: "tools.ozone.report.defs#reasonMisleadingBot", 41 - ReasonMisleadingImpersonation: "tools.ozone.report.defs#reasonMisleadingImpersonation", 42 - ReasonMisleadingSpam: "tools.ozone.report.defs#reasonMisleadingSpam", 43 - ReasonMisleadingScam: "tools.ozone.report.defs#reasonMisleadingScam", 44 - ReasonMisleadingSyntheticContent: "tools.ozone.report.defs#reasonMisleadingSyntheticContent", 45 - ReasonMisleadingMisinformation: "tools.ozone.report.defs#reasonMisleadingMisinformation", 46 - ReasonMisleadingOther: "tools.ozone.report.defs#reasonMisleadingOther", 47 - 48 - ReasonRuleSiteSecurity: "tools.ozone.report.defs#reasonRuleSiteSecurity", 49 - ReasonRuleStolenContent: "tools.ozone.report.defs#reasonRuleStolenContent", 50 - ReasonRuleProhibitedSales: "tools.ozone.report.defs#reasonRuleProhibitedSales", 51 - ReasonRuleBanEvasion: "tools.ozone.report.defs#reasonRuleBanEvasion", 52 - ReasonRuleOther: "tools.ozone.report.defs#reasonRuleOther", 53 - 54 - ReasonCivicElectoralProcess: "tools.ozone.report.defs#reasonCivicElectoralProcess", 55 - ReasonCivicDisclosure: "tools.ozone.report.defs#reasonCivicDisclosure", 56 - ReasonCivicInterference: "tools.ozone.report.defs#reasonCivicInterference", 57 - ReasonCivicMisinformation: "tools.ozone.report.defs#reasonCivicMisinformation", 58 - ReasonCivicImpersonation: "tools.ozone.report.defs#reasonCivicImpersonation", 59 - } 60 - 61 - @doc("Appeal a previously taken moderation action") 62 - @token 63 - model ReasonAppeal {} 64 - 65 - @doc("Animal welfare violations") 66 - @token 67 - model ReasonViolenceAnimalWelfare {} 68 - 69 - @doc("Threats or incitement") 70 - @token 71 - model ReasonViolenceThreats {} 72 - 73 - @doc("Graphic violent content") 74 - @token 75 - model ReasonViolenceGraphicContent {} 76 - 77 - @doc("Self harm") 78 - @token 79 - model ReasonViolenceSelfHarm {} 80 - 81 - @doc("Glorification of violence") 82 - @token 83 - model ReasonViolenceGlorification {} 84 - 85 - @doc("Extremist content. These reports will be sent only be sent to the application's Moderation Authority.") 86 - @token 87 - model ReasonViolenceExtremistContent {} 88 - 89 - @doc("Human trafficking") 90 - @token 91 - model ReasonViolenceTrafficking {} 92 - 93 - @doc("Other violent content") 94 - @token 95 - model ReasonViolenceOther {} 96 - 97 - @doc("Adult sexual abuse content") 98 - @token 99 - model ReasonSexualAbuseContent {} 100 - 101 - @doc("Non-consensual intimate imagery") 102 - @token 103 - model ReasonSexualNCII {} 104 - 105 - @doc("Sextortion") 106 - @token 107 - model ReasonSexualSextortion {} 108 - 109 - @doc("Deepfake adult content") 110 - @token 111 - model ReasonSexualDeepfake {} 112 - 113 - @doc("Animal sexual abuse") 114 - @token 115 - model ReasonSexualAnimal {} 116 - 117 - @doc("Unlabelled adult content") 118 - @token 119 - model ReasonSexualUnlabeled {} 120 - 121 - @doc("Other sexual violence content") 122 - @token 123 - model ReasonSexualOther {} 124 - 125 - @doc("Child sexual abuse material (CSAM). These reports will be sent only be sent to the application's Moderation Authority.") 126 - @token 127 - model ReasonChildSafetyCSAM {} 128 - 129 - @doc("Grooming or predatory behavior. These reports will be sent only be sent to the application's Moderation Authority.") 130 - @token 131 - model ReasonChildSafetyGroom {} 132 - 133 - @doc("Privacy violation involving a minor") 134 - @token 135 - model ReasonChildSafetyMinorPrivacy {} 136 - 137 - @doc("Child endangerment. These reports will be sent only be sent to the application's Moderation Authority.") 138 - @token 139 - model ReasonChildSafetyEndangerment {} 140 - 141 - @doc("Harassment or bullying of minors") 142 - @token 143 - model ReasonChildSafetyHarassment {} 144 - 145 - @doc("Promotion of child exploitation. These reports will be sent only be sent to the application's Moderation Authority.") 146 - @token 147 - model ReasonChildSafetyPromotion {} 148 - 149 - @doc("Other child safety. These reports will be sent only be sent to the application's Moderation Authority.") 150 - @token 151 - model ReasonChildSafetyOther {} 152 - 153 - @doc("Trolling") 154 - @token 155 - model ReasonHarassmentTroll {} 156 - 157 - @doc("Targeted harassment") 158 - @token 159 - model ReasonHarassmentTargeted {} 160 - 161 - @doc("Hate speech") 162 - @token 163 - model ReasonHarassmentHateSpeech {} 164 - 165 - @doc("Doxxing") 166 - @token 167 - model ReasonHarassmentDoxxing {} 168 - 169 - @doc("Other harassing or hateful content") 170 - @token 171 - model ReasonHarassmentOther {} 172 - 173 - @doc("Fake account or bot") 174 - @token 175 - model ReasonMisleadingBot {} 176 - 177 - @doc("Impersonation") 178 - @token 179 - model ReasonMisleadingImpersonation {} 180 - 181 - @doc("Spam") 182 - @token 183 - model ReasonMisleadingSpam {} 184 - 185 - @doc("Scam") 186 - @token 187 - model ReasonMisleadingScam {} 188 - 189 - @doc("Unlabelled gen-AI or synthetic content") 190 - @token 191 - model ReasonMisleadingSyntheticContent {} 192 - 193 - @doc("Harmful false claims") 194 - @token 195 - model ReasonMisleadingMisinformation {} 196 - 197 - @doc("Other misleading content") 198 - @token 199 - model ReasonMisleadingOther {} 200 - 201 - @doc("Hacking or system attacks") 202 - @token 203 - model ReasonRuleSiteSecurity {} 204 - 205 - @doc("Stolen content") 206 - @token 207 - model ReasonRuleStolenContent {} 208 - 209 - @doc("Promoting or selling prohibited items or services") 210 - @token 211 - model ReasonRuleProhibitedSales {} 212 - 213 - @doc("Banned user returning") 214 - @token 215 - model ReasonRuleBanEvasion {} 216 - 217 - @doc("Other") 218 - @token 219 - model ReasonRuleOther {} 220 - 221 - @doc("Electoral process violations") 222 - @token 223 - model ReasonCivicElectoralProcess {} 224 - 225 - @doc("Disclosure & transparency violations") 226 - @token 227 - model ReasonCivicDisclosure {} 228 - 229 - @doc("Voter intimidation or interference") 230 - @token 231 - model ReasonCivicInterference {} 232 - 233 - @doc("Election misinformation") 234 - @token 235 - model ReasonCivicMisinformation {} 236 - 237 - @doc("Impersonation of electoral officials/entities") 238 - @token 239 - model ReasonCivicImpersonation {} 240 - }
-83
packages/emitter/test/scenarios/atproto/input/tools/ozone/safelink/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.safelink.defs { 4 - @doc("An event for URL safety decisions") 5 - model Event { 6 - @doc("Auto-incrementing row ID") 7 - @required 8 - id: integer; 9 - 10 - @required eventType: EventType; 11 - 12 - @doc("The URL that this rule applies to") 13 - @required 14 - url: string; 15 - 16 - @required pattern: PatternType; 17 - @required action: ActionType; 18 - @required reason: ReasonType; 19 - 20 - @doc("DID of the user who created this rule") 21 - @required 22 - createdBy: did; 23 - 24 - @required createdAt: datetime; 25 - 26 - @doc("Optional comment about the decision") 27 - comment?: string; 28 - } 29 - 30 - union EventType { 31 - "addRule", 32 - "updateRule", 33 - "removeRule", 34 - string, 35 - } 36 - 37 - union PatternType { 38 - "domain", 39 - "url", 40 - string, 41 - } 42 - 43 - union ActionType { 44 - "block", 45 - "warn", 46 - "whitelist", 47 - string, 48 - } 49 - 50 - union ReasonType { 51 - "csam", 52 - "spam", 53 - "phishing", 54 - "none", 55 - string, 56 - } 57 - 58 - @doc("Input for creating a URL safety rule") 59 - model UrlRule { 60 - @doc("The URL or domain to apply the rule to") 61 - @required 62 - url: string; 63 - 64 - @required pattern: PatternType; 65 - @required action: ActionType; 66 - @required reason: ReasonType; 67 - 68 - @doc("Optional comment about the decision") 69 - comment?: string; 70 - 71 - @doc("DID of the user added the rule.") 72 - @required 73 - createdBy: did; 74 - 75 - @doc("Timestamp when the rule was created") 76 - @required 77 - createdAt: datetime; 78 - 79 - @doc("Timestamp when the rule was last updated") 80 - @required 81 - updatedAt: datetime; 82 - } 83 - }
-24
packages/emitter/test/scenarios/atproto/input/tools/ozone/server/getConfig.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.server.getConfig { 4 - model ServiceConfig { 5 - url?: uri; 6 - } 7 - 8 - model ViewerConfig { 9 - role?: "tools.ozone.team.defs#roleAdmin" | "tools.ozone.team.defs#roleModerator" | "tools.ozone.team.defs#roleTriage" | "tools.ozone.team.defs#roleVerifier" | string; 10 - } 11 - 12 - @doc("Get details about ozone's server configuration.") 13 - @query 14 - op main(): { 15 - appview?: ServiceConfig; 16 - pds?: ServiceConfig; 17 - blobDivert?: ServiceConfig; 18 - chat?: ServiceConfig; 19 - viewer?: ViewerConfig; 20 - 21 - @doc("The did of the verifier used for verification.") 22 - verifierDid?: did; 23 - }; 24 - }
-29
packages/emitter/test/scenarios/atproto/input/tools/ozone/set/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.set.defs { 4 - model Set { 5 - @minLength(3) 6 - @maxLength(128) 7 - @required 8 - name: string; 9 - 10 - @maxGraphemes(1024) 11 - @maxLength(10240) 12 - description?: string; 13 - } 14 - 15 - model SetView { 16 - @minLength(3) 17 - @maxLength(128) 18 - @required 19 - name: string; 20 - 21 - @maxGraphemes(1024) 22 - @maxLength(10240) 23 - description?: string; 24 - 25 - @required setSize: integer; 26 - @required createdAt: datetime; 27 - @required updatedAt: datetime; 28 - } 29 - }
-24
packages/emitter/test/scenarios/atproto/input/tools/ozone/setting/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.setting.defs { 4 - model Option { 5 - @required key: nsid; 6 - @required value: unknown; 7 - @required did: did; 8 - 9 - @maxGraphemes(1024) 10 - @maxLength(10240) 11 - description?: string; 12 - 13 - createdAt?: datetime; 14 - updatedAt?: datetime; 15 - 16 - managerRole?: "tools.ozone.team.defs#roleModerator" | "tools.ozone.team.defs#roleTriage" | "tools.ozone.team.defs#roleAdmin" | "tools.ozone.team.defs#roleVerifier" | string; 17 - 18 - @required 19 - scope: "instance" | "personal" | string; 20 - 21 - @required createdBy: did; 22 - @required lastUpdatedBy: did; 23 - } 24 - }
-8
packages/emitter/test/scenarios/atproto/input/tools/ozone/signature/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.signature.defs { 4 - model SigDetail { 5 - @required property: string; 6 - @required value: string; 7 - } 8 - }
-31
packages/emitter/test/scenarios/atproto/input/tools/ozone/team/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.team.defs { 4 - model Member { 5 - @required did: did; 6 - disabled?: boolean; 7 - profile?: app.bsky.actor.defs.ProfileViewDetailed; 8 - createdAt?: datetime; 9 - updatedAt?: datetime; 10 - lastUpdatedBy?: string; 11 - 12 - @required 13 - role: "#roleAdmin" | "#roleModerator" | "#roleTriage" | "#roleVerifier" | string; 14 - } 15 - 16 - @doc("Admin role. Highest level of access, can perform all actions.") 17 - @token 18 - model RoleAdmin {} 19 - 20 - @doc("Moderator role. Can perform most actions.") 21 - @token 22 - model RoleModerator {} 23 - 24 - @doc("Triage role. Mostly intended for monitoring and escalating issues.") 25 - @token 26 - model RoleTriage {} 27 - 28 - @doc("Verifier role. Only allowed to issue verifications.") 29 - @token 30 - model RoleVerifier {} 31 - }
-44
packages/emitter/test/scenarios/atproto/input/tools/ozone/verification/defs.tsp
··· 1 - import "@tlex/emitter"; 2 - 3 - namespace tools.ozone.verification.defs { 4 - @doc("Verification data for the associated subject.") 5 - model VerificationView { 6 - @doc("The user who issued this verification.") 7 - @required 8 - issuer: did; 9 - 10 - @doc("The AT-URI of the verification record.") 11 - @required 12 - uri: atUri; 13 - 14 - @doc("The subject of the verification.") 15 - @required 16 - subject: did; 17 - 18 - @doc("Handle of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current handle matches the one at the time of verifying.") 19 - @required 20 - handle: handle; 21 - 22 - @doc("Display name of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current displayName matches the one at the time of verifying.") 23 - @required 24 - displayName: string; 25 - 26 - @doc("Timestamp when the verification was created.") 27 - @required 28 - createdAt: datetime; 29 - 30 - @doc("Describes the reason for revocation, also indicating that the verification is no longer valid.") 31 - revokeReason?: string; 32 - 33 - @doc("Timestamp when the verification was revoked.") 34 - revokedAt?: datetime; 35 - 36 - @doc("The user who revoked this verification.") 37 - revokedBy?: did; 38 - 39 - subjectProfile?: (never | unknown); 40 - issuerProfile?: (never | unknown); 41 - subjectRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound; 42 - issuerRepo?: tools.ozone.moderation.defs.RepoViewDetail | tools.ozone.moderation.defs.RepoViewNotFound; 43 - } 44 - }
packages/emitter/test/spec/output/binary-types/blob.json packages/emitter/test/spec/binary-types/output/blob.json
packages/emitter/test/spec/output/binary-types/cid-link.json packages/emitter/test/spec/binary-types/output/cid-link.json
packages/emitter/test/spec/output/container-types/array.json packages/emitter/test/spec/container-types/output/array.json
packages/emitter/test/spec/output/container-types/object.json packages/emitter/test/spec/container-types/output/object.json
packages/emitter/test/spec/output/container-types/params.json packages/emitter/test/spec/container-types/output/params.json
packages/emitter/test/spec/output/field-types/boolean-constraints.json packages/emitter/test/spec/field-types/output/boolean-constraints.json
packages/emitter/test/spec/output/field-types/bytes-constraints.json packages/emitter/test/spec/field-types/output/bytes-constraints.json
packages/emitter/test/spec/output/field-types/integer-constraints.json packages/emitter/test/spec/field-types/output/integer-constraints.json
packages/emitter/test/spec/output/field-types/primitives.json packages/emitter/test/spec/field-types/output/primitives.json
packages/emitter/test/spec/output/field-types/string-constraints.json packages/emitter/test/spec/field-types/output/string-constraints.json
packages/emitter/test/spec/output/meta-types/ref.json packages/emitter/test/spec/meta-types/output/ref.json
packages/emitter/test/spec/output/meta-types/token.json packages/emitter/test/spec/meta-types/output/token.json
packages/emitter/test/spec/output/meta-types/union-closed.json packages/emitter/test/spec/meta-types/output/union-closed.json
packages/emitter/test/spec/output/meta-types/union-empty.json packages/emitter/test/spec/meta-types/output/union-empty.json
packages/emitter/test/spec/output/meta-types/union-open.json packages/emitter/test/spec/meta-types/output/union-open.json
packages/emitter/test/spec/output/meta-types/unknown.json packages/emitter/test/spec/meta-types/output/unknown.json
packages/emitter/test/spec/output/primary-types/procedure-simple.json packages/emitter/test/spec/primary-types/output/procedure-simple.json
packages/emitter/test/spec/output/primary-types/query-simple.json packages/emitter/test/spec/primary-types/output/query-simple.json
packages/emitter/test/spec/output/primary-types/record-simple.json packages/emitter/test/spec/primary-types/output/record-simple.json
packages/emitter/test/spec/output/primary-types/subscription-simple.json packages/emitter/test/spec/primary-types/output/subscription-simple.json
packages/emitter/test/spec/output/special-features/defs-lexicon.json packages/emitter/test/spec/special-features/output/defs-lexicon.json
packages/emitter/test/spec/output/special-features/errors.json packages/emitter/test/spec/special-features/output/errors.json
packages/emitter/test/spec/output/special-features/input-output-ref.json packages/emitter/test/spec/special-features/output/input-output-ref.json
packages/emitter/test/spec/output/special-features/nullable-fields.json packages/emitter/test/spec/special-features/output/nullable-fields.json
packages/emitter/test/spec/output/special-features/output-without-schema.json packages/emitter/test/spec/special-features/output/output-without-schema.json
packages/emitter/test/spec/output/special-features/procedure-no-output.json packages/emitter/test/spec/special-features/output/procedure-no-output.json
packages/emitter/test/spec/output/special-features/query-no-params.json packages/emitter/test/spec/special-features/output/query-no-params.json
packages/emitter/test/spec/output/special-features/record-key-any.json packages/emitter/test/spec/special-features/output/record-key-any.json
packages/emitter/test/spec/output/special-features/record-key-literal.json packages/emitter/test/spec/special-features/output/record-key-literal.json
packages/emitter/test/spec/output/special-features/record-key-tid.json packages/emitter/test/spec/special-features/output/record-key-tid.json
packages/emitter/test/spec/output/special-features/subscription-union-message.json packages/emitter/test/spec/special-features/output/subscription-union-message.json
packages/emitter/test/spec/output/string-formats/all-formats.json packages/emitter/test/spec/string-formats/output/all-formats.json
packages/emitter/test/spec/output/string-formats/datetime-examples.json packages/emitter/test/spec/string-formats/output/datetime-examples.json
packages/emitter/test/spec/output/string-formats/identifier-examples.json packages/emitter/test/spec/string-formats/output/identifier-examples.json
packages/emitter/test/spec/output/string-formats/uri-examples.json packages/emitter/test/spec/string-formats/output/uri-examples.json