An experimental TypeSpec syntax for Lexicon

init

danabra.mov c8a05c17

+501
+281
TYPELEX-PLAN.md
··· 1 + # TypeLex: TypeSpec-based IDL for ATProto Lexicons 2 + 3 + ## Executive Summary 4 + 5 + TypeSpec can effectively serve as an IDL for ATProto lexicons. By creating a custom emitter, we can leverage TypeSpec's robust parsing, validation, and extensibility infrastructure while maintaining a lean project structure. This approach avoids maintaining complex parsers and tooling while providing a superior developer experience. 6 + 7 + ## Why TypeSpec Works for This 8 + 9 + ### 1. **Architecture Alignment** 10 + - TypeSpec's type system maps well to Lexicon's type system 11 + - Built-in support for validation constraints (minLength, maxLength, format, etc.) 12 + - Native handling of unions, references, and complex types 13 + - Extensible decorator system for ATProto-specific features 14 + 15 + ### 2. **Minimal Maintenance Burden** 16 + - Leverages Microsoft's actively maintained parser and compiler 17 + - Benefits from TypeSpec's tooling ecosystem (VS Code extension, language server) 18 + - Automatic syntax highlighting, auto-completion, and error checking 19 + - No need to maintain custom parsers or AST transformations 20 + 21 + ### 3. **Developer Experience Benefits** 22 + - Familiar syntax for developers coming from TypeScript/OpenAPI 23 + - Better IDE support than raw JSON 24 + - Type-safe with compile-time validation 25 + - Supports comments and documentation 26 + 27 + ## Implementation Plan 28 + 29 + ### Phase 1: Core Emitter Development 30 + 31 + 1. **Create TypeLex Emitter Package** 32 + ``` 33 + @typespec/typelex-emitter/ 34 + ├── src/ 35 + │ ├── index.ts # Main emitter entry point 36 + │ ├── emitter.ts # Core emitter logic 37 + │ ├── lexicon-types.ts # Lexicon type definitions 38 + │ └── validators.ts # ATProto-specific validations 39 + ├── package.json 40 + └── tsconfig.json 41 + ``` 42 + 43 + 2. **Implement Core Type Mappings** 44 + - Map TypeSpec primitives to Lexicon types 45 + - Handle ATProto-specific formats (at-uri, did, handle, etc.) 46 + - Support record, query, procedure, and subscription types 47 + 48 + 3. **Add ATProto Decorators** 49 + ```typescript 50 + @lexicon("com.example.actor.profile") 51 + @record 52 + model Profile { 53 + @format("did") did: string; 54 + @format("handle") handle: string; 55 + @maxGraphemes(150) bio?: string; 56 + } 57 + ``` 58 + 59 + ### Phase 2: Feature Completeness 60 + 61 + 1. **Advanced Type Support** 62 + - Union types with proper `$type` handling 63 + - Blob references 64 + - CID links 65 + - Unknown types 66 + 67 + 2. **XRPC Integration** 68 + - Map HTTP operations to query/procedure types 69 + - Handle input/output schemas 70 + - Support error definitions 71 + 72 + 3. **Validation Rules** 73 + - Implement all Lexicon validation constraints 74 + - Add custom validation for NSIDs 75 + - Ensure closed unions are properly enforced 76 + 77 + ### Phase 3: Developer Experience 78 + 79 + 1. **TypeLex Standard Library** 80 + ```typescript 81 + // typelex-lib.tsp 82 + import "@typespec/typelex"; 83 + 84 + // Common ATProto types 85 + model AtUri extends string { 86 + @format("at-uri") 87 + } 88 + 89 + model Did extends string { 90 + @format("did") 91 + } 92 + 93 + model Cid extends string { 94 + @format("cid") 95 + } 96 + ``` 97 + 98 + 2. **CLI Tool** 99 + ```bash 100 + npx typelex compile app.bsky.feed.tsp --out-dir ./lexicons 101 + ``` 102 + 103 + 3. **VS Code Extension Enhancements** 104 + - Custom snippets for ATProto patterns 105 + - NSID validation and auto-completion 106 + - Lexicon preview panel 107 + 108 + ## Technical Architecture 109 + 110 + ### Emitter Structure 111 + 112 + ```typescript 113 + export async function $onEmit(context: EmitContext) { 114 + const { program, options } = context; 115 + const lexicons = new Map<string, LexiconDocument>(); 116 + 117 + // Walk the TypeSpec program 118 + for (const [type, metadata] of program.stateMap(LexiconKey)) { 119 + const lexicon = buildLexicon(type, metadata); 120 + lexicons.set(lexicon.id, lexicon); 121 + } 122 + 123 + // Write lexicon files 124 + for (const [id, lexicon] of lexicons) { 125 + const path = nsidToPath(id); 126 + await writeFile(path, JSON.stringify(lexicon, null, 2)); 127 + } 128 + } 129 + ``` 130 + 131 + ### Key Mappings 132 + 133 + | TypeSpec | Lexicon | 134 + |----------|---------| 135 + | `string` | `"string"` | 136 + | `int32` | `"integer"` | 137 + | `boolean` | `"boolean"` | 138 + | `bytes` | `"bytes"` | 139 + | `utcDateTime` | `"string"` with `format: "datetime"` | 140 + | `model` | `"object"` or `"record"` | 141 + | `op` | `"query"` or `"procedure"` | 142 + | `union` | `"union"` | 143 + 144 + ### Decorator Extensions 145 + 146 + ```typescript 147 + // Define ATProto-specific decorators 148 + export const $record = defineDecorator({ 149 + name: "record", 150 + target: "Model", 151 + args: { key: "tid" | "literal" } 152 + }); 153 + 154 + export const $maxGraphemes = defineDecorator({ 155 + name: "maxGraphemes", 156 + target: "ModelProperty", 157 + args: { count: "number" } 158 + }); 159 + ``` 160 + 161 + ## Example Usage 162 + 163 + ### Input (TypeLex) 164 + 165 + ```typescript 166 + import "@typespec/typelex"; 167 + using TypeLex; 168 + 169 + @lexicon("app.bsky.feed.post") 170 + @record 171 + model Post { 172 + @maxGraphemes(300) 173 + text: string; 174 + 175 + @format("datetime") 176 + createdAt: utcDateTime; 177 + 178 + reply?: { 179 + root: PostRef; 180 + parent: PostRef; 181 + }; 182 + 183 + @maxLength(3) 184 + langs?: string[]; 185 + } 186 + 187 + model PostRef { 188 + @format("at-uri") 189 + uri: string; 190 + 191 + @format("cid") 192 + cid: string; 193 + } 194 + ``` 195 + 196 + ### Output (Lexicon JSON) 197 + 198 + ```json 199 + { 200 + "lexicon": 1, 201 + "id": "app.bsky.feed.post", 202 + "defs": { 203 + "main": { 204 + "type": "record", 205 + "key": "tid", 206 + "record": { 207 + "type": "object", 208 + "required": ["text", "createdAt"], 209 + "properties": { 210 + "text": { 211 + "type": "string", 212 + "maxGraphemes": 300 213 + }, 214 + "createdAt": { 215 + "type": "string", 216 + "format": "datetime" 217 + }, 218 + "reply": { 219 + "type": "object", 220 + "required": ["root", "parent"], 221 + "properties": { 222 + "root": { "type": "ref", "ref": "#postRef" }, 223 + "parent": { "type": "ref", "ref": "#postRef" } 224 + } 225 + }, 226 + "langs": { 227 + "type": "array", 228 + "maxLength": 3, 229 + "items": { "type": "string" } 230 + } 231 + } 232 + } 233 + }, 234 + "postRef": { 235 + "type": "object", 236 + "required": ["uri", "cid"], 237 + "properties": { 238 + "uri": { "type": "string", "format": "at-uri" }, 239 + "cid": { "type": "string", "format": "cid" } 240 + } 241 + } 242 + } 243 + } 244 + ``` 245 + 246 + ## Advantages Over Alternatives 247 + 248 + 1. **vs. Custom DSL** 249 + - No parser maintenance 250 + - Existing tooling support 251 + - Familiar syntax 252 + 253 + 2. **vs. TypeScript Code Generation** 254 + - Declarative, not imperative 255 + - Better validation at compile time 256 + - Cleaner separation of schema and implementation 257 + 258 + 3. **vs. Raw JSON** 259 + - Type safety 260 + - IDE support 261 + - Comments and documentation 262 + - Less verbose 263 + 264 + ## Implementation Timeline 265 + 266 + - **Week 1-2**: Basic emitter with core types 267 + - **Week 3-4**: XRPC support and validations 268 + - **Week 5-6**: Developer experience improvements 269 + - **Week 7-8**: Testing, documentation, and examples 270 + 271 + ## Open Questions 272 + 273 + 1. **Subscription Handling**: TypeSpec doesn't have native WebSocket support. We'll need custom decorators for subscription types. 274 + 275 + 2. **Blob Management**: Need to determine best representation for blob references in TypeSpec. 276 + 277 + 3. **Package Distribution**: Should this be part of official ATProto tooling or a community package? 278 + 279 + ## Conclusion 280 + 281 + TypeSpec provides an excellent foundation for building a TypeLex IDL. The approach minimizes maintenance burden while providing superior developer experience. The emitter architecture allows for incremental development and easy extension as ATProto lexicon evolves.
+28
lexicon-output-example.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.statusphere.status", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A simple status record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["status", "createdAt"], 12 + "properties": { 13 + "status": { 14 + "type": "string", 15 + "description": "The status emoji", 16 + "minLength": 1, 17 + "maxLength": 32 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "description": "When the status was created", 22 + "format": "datetime" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+102
lexicon-query-example.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.statusphere.feed.getAuthorFeed", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get posts from a user's feed", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "description": "AT-identifier of the account" 15 + }, 16 + "limit": { 17 + "type": "integer", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string" 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["feed"], 32 + "properties": { 33 + "cursor": { 34 + "type": "string" 35 + }, 36 + "feed": { 37 + "type": "array", 38 + "items": { 39 + "type": "ref", 40 + "ref": "#post" 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }, 47 + "post": { 48 + "type": "object", 49 + "required": ["uri", "cid", "author", "record", "indexedAt"], 50 + "properties": { 51 + "uri": { 52 + "type": "string", 53 + "format": "at-uri" 54 + }, 55 + "cid": { 56 + "type": "string", 57 + "format": "cid" 58 + }, 59 + "author": { 60 + "type": "ref", 61 + "ref": "#profileViewBasic" 62 + }, 63 + "record": { 64 + "type": "unknown" 65 + }, 66 + "replyCount": { 67 + "type": "integer" 68 + }, 69 + "repostCount": { 70 + "type": "integer" 71 + }, 72 + "likeCount": { 73 + "type": "integer" 74 + }, 75 + "indexedAt": { 76 + "type": "string", 77 + "format": "datetime" 78 + } 79 + } 80 + }, 81 + "profileViewBasic": { 82 + "type": "object", 83 + "required": ["did", "handle"], 84 + "properties": { 85 + "did": { 86 + "type": "string", 87 + "format": "did" 88 + }, 89 + "handle": { 90 + "type": "string", 91 + "format": "handle" 92 + }, 93 + "displayName": { 94 + "type": "string" 95 + }, 96 + "avatar": { 97 + "type": "string" 98 + } 99 + } 100 + } 101 + } 102 + }
+90
typelex-example.tsp
··· 1 + import "@typespec/http"; 2 + 3 + using TypeSpec.Http; 4 + 5 + // Example TypeLex definition for ATProto lexicons 6 + // This demonstrates how TypeSpec could be used to define ATProto schemas 7 + 8 + @service({ 9 + title: "Status Sphere", 10 + }) 11 + @server("https://statusphere.xyz", "StatusSphere API") 12 + namespace xyz.statusphere; 13 + 14 + // ATProto record type example 15 + @route("/status") 16 + @doc("A simple status record") 17 + model Status { 18 + @doc("The status emoji") 19 + @minLength(1) 20 + @maxLength(32) 21 + status: string; 22 + 23 + @doc("When the status was created") 24 + @format("date-time") 25 + createdAt: utcDateTime; 26 + } 27 + 28 + // ATProto query (XRPC) example 29 + @route("/feed.getAuthorFeed") 30 + @get 31 + op getAuthorFeed( 32 + @query actor: string, 33 + @query limit?: int32 = 50, 34 + @query cursor?: string, 35 + ): { 36 + @body feed: { 37 + cursor?: string; 38 + posts: Post[]; 39 + }; 40 + }; 41 + 42 + model Post { 43 + uri: string; 44 + cid: string; 45 + author: ProfileViewBasic; 46 + record: unknown; // Would be the actual post record 47 + replyCount?: int32; 48 + repostCount?: int32; 49 + likeCount?: int32; 50 + indexedAt: utcDateTime; 51 + } 52 + 53 + model ProfileViewBasic { 54 + did: string; 55 + handle: string; 56 + displayName?: string; 57 + avatar?: string; 58 + } 59 + 60 + // ATProto procedure (XRPC) example 61 + @route("/feed.like") 62 + @post 63 + op createLike( 64 + @body body: { 65 + repo: string; 66 + collection: "app.bsky.feed.like"; 67 + record: { 68 + subject: { 69 + uri: string; 70 + cid: string; 71 + }; 72 + createdAt: utcDateTime; 73 + }; 74 + } 75 + ): { 76 + @statusCode statusCode: 200; 77 + @body result: { 78 + uri: string; 79 + cid: string; 80 + }; 81 + }; 82 + 83 + // ATProto subscription example 84 + @route("/com.atproto.sync.subscribeRepos") 85 + model RepoSubscription { 86 + // This would need special handling for websocket subscriptions 87 + @doc("Subscribe to repository updates") 88 + seq?: int64; 89 + since?: string; 90 + }