prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey

workspaces refactor

Tyler ebdfa22f aef63639

+5777 -48
+2 -2
.gitignore
··· 1 - /lib 2 - /node_modules 1 + lib 2 + node_modules 3 3 /.attest
+126 -3
aislop/plan-emit.md
··· 170 170 171 171 1. ✅ **Phase 1**: Basic CLI structure + Track B (inferred generation) - COMPLETE 172 172 2. ✅ **Phase 2**: File organization + output directory structure - COMPLETE 173 - 3. **Phase 3**: Track A (standard generation, delegate to lex-cli) 174 - 4. **Phase 4**: Configuration file support 175 - 5. **Phase 5**: Documentation + examples 173 + 3. ✅ **Phase 3**: Convert to pnpm workspaces monorepo - COMPLETE 174 + 4. **Phase 4**: Track A (standard generation, delegate to lex-cli) 175 + 5. **Phase 5**: Configuration file support 176 + 6. **Phase 6**: Documentation + examples 176 177 177 178 ## Phase 1 & 2 Implementation Notes 178 179 ··· 232 233 - Successfully generated types from sample lexicons 233 234 - Runtime validation works (tested with node) 234 235 - Schema imports work correctly with JSON modules 236 + 237 + ## Phase 3: Monorepo Strategy 238 + 239 + ### Why Monorepo? 240 + 241 + The CLI tool should be a separate package from the core inference library for several reasons: 242 + 243 + 1. **Separation of concerns**: Core inference types vs. code generation tooling 244 + 2. **Dependency isolation**: CLI needs `sade`, `tinyglobby`, etc. - consumers of the core library don't 245 + 3. **Bundle size**: Users importing just types don't want CLI bloat 246 + 4. **Independent versioning**: CLI can evolve separately from type inference 247 + 5. **Better organization**: Clear boundaries between runtime and build-time code 248 + 249 + ### Proposed Structure 250 + 251 + ``` 252 + prototypey/ 253 + ├── package.json # Root workspace config 254 + ├── pnpm-workspace.yaml # Workspace definition 255 + ├── packages/ 256 + │ ├── prototypey/ # Core inference library 257 + │ │ ├── package.json # Main package (prototypey) 258 + │ │ ├── src/ 259 + │ │ │ ├── index.ts 260 + │ │ │ ├── infer.ts 261 + │ │ │ ├── lib.ts 262 + │ │ │ └── type-utils.ts 263 + │ │ ├── lib/ # Built output 264 + │ │ └── tests/ 265 + │ │ 266 + │ └── cli/ # CLI package 267 + │ ├── package.json # Separate package (@prototypey/cli) 268 + │ ├── src/ 269 + │ │ ├── index.ts 270 + │ │ ├── commands/ 271 + │ │ │ └── gen-inferred.ts 272 + │ │ └── templates/ 273 + │ │ └── inferred.ts 274 + │ └── lib/ # Built CLI output 275 + 276 + ├── samples/ # Shared samples 277 + ├── generated/ # Generated output (gitignored) 278 + └── lexicons/ # Input lexicons (gitignored) 279 + ``` 280 + 281 + ### Package Configurations 282 + 283 + **Root `pnpm-workspace.yaml`:** 284 + ```yaml 285 + packages: 286 + - 'packages/*' 287 + ``` 288 + 289 + **Root `package.json`:** 290 + ```json 291 + { 292 + "name": "prototypey-monorepo", 293 + "private": true, 294 + "scripts": { 295 + "build": "pnpm -r build", 296 + "test": "pnpm -r test", 297 + "lint": "pnpm -r lint", 298 + "format": "prettier . --write" 299 + } 300 + } 301 + ``` 302 + 303 + **`packages/prototypey/package.json`:** 304 + ```json 305 + { 306 + "name": "prototypey", 307 + "version": "0.0.0", 308 + "main": "lib/index.js", 309 + "exports": { 310 + ".": "./lib/index.js", 311 + "./infer": "./lib/infer.js" 312 + }, 313 + "dependencies": {}, 314 + "scripts": { 315 + "build": "tsdown", 316 + "test": "vitest run" 317 + } 318 + } 319 + ``` 320 + 321 + **`packages/cli/package.json`:** 322 + ```json 323 + { 324 + "name": "@prototypey/cli", 325 + "version": "0.0.0", 326 + "bin": { 327 + "prototypey": "./lib/index.js" 328 + }, 329 + "dependencies": { 330 + "prototypey": "workspace:*", 331 + "sade": "^1.8.1", 332 + "tinyglobby": "^0.2.15" 333 + }, 334 + "scripts": { 335 + "build": "tsdown --entry src/index.ts --format esm --dts false" 336 + } 337 + } 338 + ``` 339 + 340 + ### Migration Steps 341 + 342 + 1. Create `pnpm-workspace.yaml` at root 343 + 2. Create `packages/prototypey/` and move core files 344 + 3. Create `packages/cli/` and move CLI files 345 + 4. Update import paths in CLI to use `prototypey` package 346 + 5. Update root `package.json` to be private workspace root 347 + 6. Update build scripts to use `pnpm -r` (recursive) 348 + 7. Test both packages build independently 349 + 8. Update documentation 350 + 351 + ### Benefits 352 + 353 + - **Cleaner dependency tree**: Core has zero dependencies 354 + - **Better DX**: Users can `npm install prototypey` for types only 355 + - **CLI as optional tool**: `npm install -D @prototypey/cli` when needed 356 + - **Easier testing**: Each package can have its own test suite 357 + - **Future expansion**: Easy to add more packages (e.g., `@prototypey/validator`) 235 358 236 359 ## ATProto Lexicon Background Research 237 360
+9 -31
package.json
··· 1 1 { 2 - "name": "prototypey", 2 + "name": "prototypey-monorepo", 3 3 "version": "0.0.0", 4 - "description": "A very lovely package. Hooray!", 4 + "private": true, 5 + "description": "Type-safe lexicon inference for ATProto schemas", 5 6 "repository": { 6 7 "type": "git", 7 8 "url": "git+https://github.com/tylersayshi/prototypey.git" ··· 11 12 "name": "tylersayshi", 12 13 "email": "hi@tylur.dev" 13 14 }, 14 - "type": "module", 15 - "main": "lib/index.js", 16 - "bin": { 17 - "prototypey": "./lib/cli/index.js" 18 - }, 19 - "files": [ 20 - "LICENSE.md", 21 - "README.md", 22 - "lib/", 23 - "package.json" 24 - ], 25 15 "scripts": { 26 - "build": "tsdown", 27 - "build:cli": "tsdown --entry src/cli/index.ts --format esm --dts false --outDir lib/cli", 28 - "codegen:samples": "node lib/cli/index.js gen-inferred ./generated/inferred './samples/*.json'", 16 + "build": "pnpm -r build", 17 + "codegen:samples": "node packages/cli/lib/index.js gen-inferred ./generated/inferred './samples/*.json'", 29 18 "format": "prettier . --list-different", 30 19 "format:fix": "prettier . --write", 31 - "lint": "eslint . --max-warnings 0", 32 - "test": "vitest run", 33 - "test:bench": "node tests/infer.bench.ts", 34 - "test:update-snapshots": "vitest run -u", 35 - "tsc": "tsc" 20 + "lint": "pnpm -r lint", 21 + "test": "pnpm -r test", 22 + "tsc": "pnpm -r tsc" 36 23 }, 37 24 "devDependencies": { 38 - "@ark/attest": "^0.49.0", 39 25 "@eslint/js": "9.29.0", 40 - "@types/node": "24.0.4", 41 26 "eslint": "9.29.0", 42 27 "prettier": "3.6.1", 43 - "tsdown": "0.12.7", 44 - "typescript": "5.8.3", 45 - "typescript-eslint": "8.35.0", 46 - "vitest": "^3.2.4" 28 + "typescript-eslint": "8.35.0" 47 29 }, 48 30 "packageManager": "pnpm@10.4.0", 49 31 "engines": { 50 32 "node": ">=20.19.0" 51 - }, 52 - "dependencies": { 53 - "sade": "^1.8.1", 54 - "tinyglobby": "^0.2.15" 55 33 } 56 34 }
+39
packages/cli/package.json
··· 1 + { 2 + "name": "@prototypey/cli", 3 + "version": "0.0.0", 4 + "description": "CLI tool for generating types from ATProto lexicon schemas", 5 + "repository": { 6 + "type": "git", 7 + "url": "git+https://github.com/tylersayshi/prototypey.git", 8 + "directory": "packages/cli" 9 + }, 10 + "license": "MIT", 11 + "author": { 12 + "name": "tylersayshi", 13 + "email": "hi@tylur.dev" 14 + }, 15 + "type": "module", 16 + "bin": { 17 + "prototypey": "./lib/index.js" 18 + }, 19 + "files": [ 20 + "lib/", 21 + "README.md" 22 + ], 23 + "scripts": { 24 + "build": "tsdown --entry src/index.ts --format esm --dts false" 25 + }, 26 + "dependencies": { 27 + "prototypey": "workspace:*", 28 + "sade": "^1.8.1", 29 + "tinyglobby": "^0.2.15" 30 + }, 31 + "devDependencies": { 32 + "@types/node": "24.0.4", 33 + "tsdown": "0.12.7", 34 + "typescript": "5.8.3" 35 + }, 36 + "engines": { 37 + "node": ">=20.19.0" 38 + } 39 + }
+71
packages/cli/src/commands/gen-inferred.ts
··· 1 + import { glob } from "tinyglobby"; 2 + import { readFile, mkdir, writeFile } from "node:fs/promises"; 3 + import { join, dirname, relative, parse } from "node:path"; 4 + import { generateInferredCode } from "../templates/inferred.ts"; 5 + 6 + interface LexiconSchema { 7 + lexicon: number; 8 + id: string; 9 + defs: Record<string, unknown>; 10 + } 11 + 12 + export async function genInferred( 13 + outdir: string, 14 + schemas: string | string[], 15 + ): Promise<void> { 16 + try { 17 + const schemaPatterns = Array.isArray(schemas) ? schemas : [schemas]; 18 + 19 + // Find all schema files matching the patterns 20 + const schemaFiles = await glob(schemaPatterns, { 21 + absolute: true, 22 + onlyFiles: true, 23 + }); 24 + 25 + if (schemaFiles.length === 0) { 26 + console.log("No schema files found matching patterns:", schemaPatterns); 27 + return; 28 + } 29 + 30 + console.log(`Found ${schemaFiles.length} schema file(s)`); 31 + 32 + // Process each schema file 33 + for (const schemaPath of schemaFiles) { 34 + await processSchema(schemaPath, outdir); 35 + } 36 + 37 + console.log(`\nGenerated inferred types in ${outdir}`); 38 + } catch (error) { 39 + console.error("Error generating inferred types:", error); 40 + process.exit(1); 41 + } 42 + } 43 + 44 + async function processSchema( 45 + schemaPath: string, 46 + outdir: string, 47 + ): Promise<void> { 48 + const content = await readFile(schemaPath, "utf-8"); 49 + const schema: LexiconSchema = JSON.parse(content); 50 + 51 + if (!schema.id || !schema.defs) { 52 + console.warn(`Skipping ${schemaPath}: Missing id or defs`); 53 + return; 54 + } 55 + 56 + // Convert NSID to file path: app.bsky.feed.post -> app/bsky/feed/post.ts 57 + const nsidParts = schema.id.split("."); 58 + const relativePath = join(...nsidParts) + ".ts"; 59 + const outputPath = join(outdir, relativePath); 60 + 61 + // Create directory structure 62 + await mkdir(dirname(outputPath), { recursive: true }); 63 + 64 + // Generate the TypeScript code 65 + const code = generateInferredCode(schema, schemaPath, outdir); 66 + 67 + // Write the file 68 + await writeFile(outputPath, code, "utf-8"); 69 + 70 + console.log(` ✓ ${schema.id} -> ${relativePath}`); 71 + }
+17
packages/cli/src/index.ts
··· 1 + #!/usr/bin/env node 2 + import sade from "sade"; 3 + import { genInferred } from "./commands/gen-inferred.ts"; 4 + 5 + const prog = sade("prototypey"); 6 + 7 + prog 8 + .version("0.0.0") 9 + .describe("Type-safe lexicon inference and code generation"); 10 + 11 + prog 12 + .command("gen-inferred <outdir> <schemas...>") 13 + .describe("Generate type-inferred code from lexicon schemas") 14 + .example("gen-inferred ./generated/inferred ./lexicons/**/*.json") 15 + .action(genInferred); 16 + 17 + prog.parse(process.argv);
+65
packages/cli/src/templates/inferred.ts
··· 1 + import { relative, dirname } from "node:path"; 2 + 3 + interface LexiconSchema { 4 + lexicon: number; 5 + id: string; 6 + defs: Record<string, unknown>; 7 + } 8 + 9 + export function generateInferredCode( 10 + schema: LexiconSchema, 11 + schemaPath: string, 12 + outdir: string, 13 + ): string { 14 + const { id } = schema; 15 + 16 + // Calculate relative import path from output file to schema file 17 + // We need to go from generated/{nsid}.ts to the original schema 18 + const nsidParts = id.split("."); 19 + const outputDir = dirname([outdir, ...nsidParts].join("/")); 20 + const relativeSchemaPath = relative(outputDir, schemaPath); 21 + 22 + // Generate a clean type name from the NSID 23 + const typeName = generateTypeName(id); 24 + 25 + return `// Generated by prototypey - DO NOT EDIT 26 + // Source: ${id} 27 + import type { Infer } from "prototypey"; 28 + import schema from "${relativeSchemaPath}" with { type: "json" }; 29 + 30 + /** 31 + * Type-inferred from lexicon schema: ${id} 32 + */ 33 + export type ${typeName} = Infer<typeof schema>; 34 + 35 + /** 36 + * The lexicon schema object 37 + */ 38 + export const ${typeName}Schema = schema; 39 + 40 + /** 41 + * Type guard to check if a value is a ${typeName} 42 + */ 43 + export function is${typeName}(v: unknown): v is ${typeName} { 44 + return ( 45 + typeof v === "object" && 46 + v !== null && 47 + "$type" in v && 48 + v.$type === "${id}" 49 + ); 50 + } 51 + `; 52 + } 53 + 54 + function generateTypeName(nsid: string): string { 55 + // Convert app.bsky.feed.post -> Post 56 + // Convert com.atproto.repo.createRecord -> CreateRecord 57 + const parts = nsid.split("."); 58 + const lastPart = parts[parts.length - 1]; 59 + 60 + // Convert kebab-case or camelCase to PascalCase 61 + return lastPart 62 + .split(/[-_]/) 63 + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 64 + .join(""); 65 + }
+17
packages/cli/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "declaration": true, 4 + "declarationMap": true, 5 + "esModuleInterop": true, 6 + "module": "NodeNext", 7 + "moduleResolution": "NodeNext", 8 + "noEmit": true, 9 + "resolveJsonModule": true, 10 + "noErrorTruncation": true, 11 + "skipLibCheck": true, 12 + "strict": true, 13 + "target": "ES2022", 14 + "allowImportingTsExtensions": true 15 + }, 16 + "include": ["src", "tests"] 17 + }
+8
packages/cli/tsdown.config.ts
··· 1 + import { defineConfig } from "tsdown"; 2 + 3 + export default defineConfig({ 4 + dts: true, 5 + entry: ["src/index.ts"], 6 + outDir: "lib", 7 + unbundle: true, 8 + });
+1200
packages/prototypey/.attest/assertions/typescript.json
··· 1 + { 2 + "tests/infer.test.ts": [ 3 + { 4 + "location": { 5 + "start": { 6 + "line": 19, 7 + "char": 2 8 + }, 9 + "end": { 10 + "line": 19, 11 + "char": 30 12 + } 13 + }, 14 + "args": [ 15 + { 16 + "type": "{ $type: \"com.example.post\"; tags?: string[] | undefined; likes?: number | undefined; createdAt: string; text: string; }", 17 + "relationships": { 18 + "args": [ 19 + "equality" 20 + ], 21 + "typeArgs": [] 22 + } 23 + } 24 + ], 25 + "typeArgs": [], 26 + "errors": [], 27 + "completions": {} 28 + }, 29 + { 30 + "location": { 31 + "start": { 32 + "line": 36, 33 + "char": 2 34 + }, 35 + "end": { 36 + "line": 36, 37 + "char": 22 38 + } 39 + }, 40 + "args": [ 41 + { 42 + "type": "{ $type: \"test\"; optional?: string | undefined; required: string; }", 43 + "relationships": { 44 + "args": [ 45 + "equality" 46 + ], 47 + "typeArgs": [] 48 + } 49 + } 50 + ], 51 + "typeArgs": [], 52 + "errors": [], 53 + "completions": {} 54 + }, 55 + { 56 + "location": { 57 + "start": { 58 + "line": 50, 59 + "char": 2 60 + }, 61 + "end": { 62 + "line": 50, 63 + "char": 22 64 + } 65 + }, 66 + "args": [ 67 + { 68 + "type": "{ $type: \"test\"; nullable: string | null; }", 69 + "relationships": { 70 + "args": [ 71 + "equality" 72 + ], 73 + "typeArgs": [] 74 + } 75 + } 76 + ], 77 + "typeArgs": [], 78 + "errors": [], 79 + "completions": {} 80 + }, 81 + { 82 + "location": { 83 + "start": { 84 + "line": 66, 85 + "char": 2 86 + }, 87 + "end": { 88 + "line": 66, 89 + "char": 25 90 + } 91 + }, 92 + "args": [ 93 + { 94 + "type": "{ $type: \"test.string\"; simpleString?: string | undefined; }", 95 + "relationships": { 96 + "args": [ 97 + "equality" 98 + ], 99 + "typeArgs": [] 100 + } 101 + } 102 + ], 103 + "typeArgs": [], 104 + "errors": [], 105 + "completions": {} 106 + }, 107 + { 108 + "location": { 109 + "start": { 110 + "line": 80, 111 + "char": 2 112 + }, 113 + "end": { 114 + "line": 80, 115 + "char": 25 116 + } 117 + }, 118 + "args": [ 119 + { 120 + "type": "{ $type: \"test.integer\"; count?: number | undefined; age?: number | undefined; }", 121 + "relationships": { 122 + "args": [ 123 + "equality" 124 + ], 125 + "typeArgs": [] 126 + } 127 + } 128 + ], 129 + "typeArgs": [], 130 + "errors": [], 131 + "completions": {} 132 + }, 133 + { 134 + "location": { 135 + "start": { 136 + "line": 95, 137 + "char": 2 138 + }, 139 + "end": { 140 + "line": 95, 141 + "char": 25 142 + } 143 + }, 144 + "args": [ 145 + { 146 + "type": "{ $type: \"test.boolean\"; isActive?: boolean | undefined; hasAccess: boolean; }", 147 + "relationships": { 148 + "args": [ 149 + "equality" 150 + ], 151 + "typeArgs": [] 152 + } 153 + } 154 + ], 155 + "typeArgs": [], 156 + "errors": [], 157 + "completions": {} 158 + }, 159 + { 160 + "location": { 161 + "start": { 162 + "line": 109, 163 + "char": 2 164 + }, 165 + "end": { 166 + "line": 109, 167 + "char": 25 168 + } 169 + }, 170 + "args": [ 171 + { 172 + "type": "{ $type: \"test.null\"; nullValue?: null | undefined; }", 173 + "relationships": { 174 + "args": [ 175 + "equality" 176 + ], 177 + "typeArgs": [] 178 + } 179 + } 180 + ], 181 + "typeArgs": [], 182 + "errors": [], 183 + "completions": {} 184 + }, 185 + { 186 + "location": { 187 + "start": { 188 + "line": 122, 189 + "char": 2 190 + }, 191 + "end": { 192 + "line": 122, 193 + "char": 25 194 + } 195 + }, 196 + "args": [ 197 + { 198 + "type": "{ $type: \"test.unknown\"; metadata?: unknown; }", 199 + "relationships": { 200 + "args": [ 201 + "equality" 202 + ], 203 + "typeArgs": [] 204 + } 205 + } 206 + ], 207 + "typeArgs": [], 208 + "errors": [], 209 + "completions": {} 210 + }, 211 + { 212 + "location": { 213 + "start": { 214 + "line": 134, 215 + "char": 2 216 + }, 217 + "end": { 218 + "line": 134, 219 + "char": 25 220 + } 221 + }, 222 + "args": [ 223 + { 224 + "type": "{ $type: \"test.bytes\"; data?: Uint8Array<ArrayBufferLike> | undefined; }", 225 + "relationships": { 226 + "args": [ 227 + "equality" 228 + ], 229 + "typeArgs": [] 230 + } 231 + } 232 + ], 233 + "typeArgs": [], 234 + "errors": [], 235 + "completions": {} 236 + }, 237 + { 238 + "location": { 239 + "start": { 240 + "line": 147, 241 + "char": 2 242 + }, 243 + "end": { 244 + "line": 147, 245 + "char": 25 246 + } 247 + }, 248 + "args": [ 249 + { 250 + "type": "{ $type: \"test.blob\"; image?: Blob | undefined; }", 251 + "relationships": { 252 + "args": [ 253 + "equality" 254 + ], 255 + "typeArgs": [] 256 + } 257 + } 258 + ], 259 + "typeArgs": [], 260 + "errors": [], 261 + "completions": {} 262 + }, 263 + { 264 + "location": { 265 + "start": { 266 + "line": 163, 267 + "char": 2 268 + }, 269 + "end": { 270 + "line": 163, 271 + "char": 25 272 + } 273 + }, 274 + "args": [ 275 + { 276 + "type": "{ $type: \"test.token\"; symbol?: string | undefined; }", 277 + "relationships": { 278 + "args": [ 279 + "equality" 280 + ], 281 + "typeArgs": [] 282 + } 283 + } 284 + ], 285 + "typeArgs": [], 286 + "errors": [], 287 + "completions": {} 288 + }, 289 + { 290 + "location": { 291 + "start": { 292 + "line": 180, 293 + "char": 2 294 + }, 295 + "end": { 296 + "line": 180, 297 + "char": 25 298 + } 299 + }, 300 + "args": [ 301 + { 302 + "type": "{ $type: \"test.array.string\"; tags?: string[] | undefined; }", 303 + "relationships": { 304 + "args": [ 305 + "equality" 306 + ], 307 + "typeArgs": [] 308 + } 309 + } 310 + ], 311 + "typeArgs": [], 312 + "errors": [], 313 + "completions": {} 314 + }, 315 + { 316 + "location": { 317 + "start": { 318 + "line": 193, 319 + "char": 2 320 + }, 321 + "end": { 322 + "line": 193, 323 + "char": 25 324 + } 325 + }, 326 + "args": [ 327 + { 328 + "type": "{ $type: \"test.array.integer\"; scores?: number[] | undefined; }", 329 + "relationships": { 330 + "args": [ 331 + "equality" 332 + ], 333 + "typeArgs": [] 334 + } 335 + } 336 + ], 337 + "typeArgs": [], 338 + "errors": [], 339 + "completions": {} 340 + }, 341 + { 342 + "location": { 343 + "start": { 344 + "line": 206, 345 + "char": 2 346 + }, 347 + "end": { 348 + "line": 206, 349 + "char": 25 350 + } 351 + }, 352 + "args": [ 353 + { 354 + "type": "{ $type: \"test.array.boolean\"; flags?: boolean[] | undefined; }", 355 + "relationships": { 356 + "args": [ 357 + "equality" 358 + ], 359 + "typeArgs": [] 360 + } 361 + } 362 + ], 363 + "typeArgs": [], 364 + "errors": [], 365 + "completions": {} 366 + }, 367 + { 368 + "location": { 369 + "start": { 370 + "line": 219, 371 + "char": 2 372 + }, 373 + "end": { 374 + "line": 219, 375 + "char": 25 376 + } 377 + }, 378 + "args": [ 379 + { 380 + "type": "{ $type: \"test.array.unknown\"; items?: unknown[] | undefined; }", 381 + "relationships": { 382 + "args": [ 383 + "equality" 384 + ], 385 + "typeArgs": [] 386 + } 387 + } 388 + ], 389 + "typeArgs": [], 390 + "errors": [], 391 + "completions": {} 392 + }, 393 + { 394 + "location": { 395 + "start": { 396 + "line": 239, 397 + "char": 2 398 + }, 399 + "end": { 400 + "line": 239, 401 + "char": 25 402 + } 403 + }, 404 + "args": [ 405 + { 406 + "type": "{ $type: \"test.mixed\"; age?: number | undefined; email?: string | undefined; id: string; name: string; }", 407 + "relationships": { 408 + "args": [ 409 + "equality" 410 + ], 411 + "typeArgs": [] 412 + } 413 + } 414 + ], 415 + "typeArgs": [], 416 + "errors": [], 417 + "completions": {} 418 + }, 419 + { 420 + "location": { 421 + "start": { 422 + "line": 257, 423 + "char": 2 424 + }, 425 + "end": { 426 + "line": 257, 427 + "char": 25 428 + } 429 + }, 430 + "args": [ 431 + { 432 + "type": "{ $type: \"test.allOptional\"; field1?: string | undefined; field2?: number | undefined; field3?: boolean | undefined; }", 433 + "relationships": { 434 + "args": [ 435 + "equality" 436 + ], 437 + "typeArgs": [] 438 + } 439 + } 440 + ], 441 + "typeArgs": [], 442 + "errors": [], 443 + "completions": {} 444 + }, 445 + { 446 + "location": { 447 + "start": { 448 + "line": 274, 449 + "char": 2 450 + }, 451 + "end": { 452 + "line": 274, 453 + "char": 25 454 + } 455 + }, 456 + "args": [ 457 + { 458 + "type": "{ $type: \"test.allRequired\"; field1: string; field2: number; field3: boolean; }", 459 + "relationships": { 460 + "args": [ 461 + "equality" 462 + ], 463 + "typeArgs": [] 464 + } 465 + } 466 + ], 467 + "typeArgs": [], 468 + "errors": [], 469 + "completions": {} 470 + }, 471 + { 472 + "location": { 473 + "start": { 474 + "line": 293, 475 + "char": 2 476 + }, 477 + "end": { 478 + "line": 293, 479 + "char": 25 480 + } 481 + }, 482 + "args": [ 483 + { 484 + "type": "{ $type: \"test.nullableOptional\"; description?: string | null | undefined; }", 485 + "relationships": { 486 + "args": [ 487 + "equality" 488 + ], 489 + "typeArgs": [] 490 + } 491 + } 492 + ], 493 + "typeArgs": [], 494 + "errors": [], 495 + "completions": {} 496 + }, 497 + { 498 + "location": { 499 + "start": { 500 + "line": 308, 501 + "char": 2 502 + }, 503 + "end": { 504 + "line": 308, 505 + "char": 25 506 + } 507 + }, 508 + "args": [ 509 + { 510 + "type": "{ $type: \"test.multipleNullable\"; field1?: string | null | undefined; field2?: number | null | undefined; field3?: boolean | null | undefined; }", 511 + "relationships": { 512 + "args": [ 513 + "equality" 514 + ], 515 + "typeArgs": [] 516 + } 517 + } 518 + ], 519 + "typeArgs": [], 520 + "errors": [], 521 + "completions": {} 522 + }, 523 + { 524 + "location": { 525 + "start": { 526 + "line": 323, 527 + "char": 2 528 + }, 529 + "end": { 530 + "line": 323, 531 + "char": 25 532 + } 533 + }, 534 + "args": [ 535 + { 536 + "type": "{ $type: \"test.nullableRequired\"; value: string | null; }", 537 + "relationships": { 538 + "args": [ 539 + "equality" 540 + ], 541 + "typeArgs": [] 542 + } 543 + } 544 + ], 545 + "typeArgs": [], 546 + "errors": [], 547 + "completions": {} 548 + }, 549 + { 550 + "location": { 551 + "start": { 552 + "line": 339, 553 + "char": 2 554 + }, 555 + "end": { 556 + "line": 339, 557 + "char": 25 558 + } 559 + }, 560 + "args": [ 561 + { 562 + "type": "{ $type: \"test.mixedNullable\"; optional?: string | undefined; required: string; optionalNullable?: string | null | undefined; requiredNullable: string | null; }", 563 + "relationships": { 564 + "args": [ 565 + "equality" 566 + ], 567 + "typeArgs": [] 568 + } 569 + } 570 + ], 571 + "typeArgs": [], 572 + "errors": [], 573 + "completions": {} 574 + }, 575 + { 576 + "location": { 577 + "start": { 578 + "line": 359, 579 + "char": 2 580 + }, 581 + "end": { 582 + "line": 359, 583 + "char": 25 584 + } 585 + }, 586 + "args": [ 587 + { 588 + "type": "{ $type: \"test.ref\"; post?: { [x: string]: unknown; $type: \"com.example.post\"; } | undefined; }", 589 + "relationships": { 590 + "args": [ 591 + "equality" 592 + ], 593 + "typeArgs": [] 594 + } 595 + } 596 + ], 597 + "typeArgs": [], 598 + "errors": [], 599 + "completions": {} 600 + }, 601 + { 602 + "location": { 603 + "start": { 604 + "line": 374, 605 + "char": 2 606 + }, 607 + "end": { 608 + "line": 374, 609 + "char": 25 610 + } 611 + }, 612 + "args": [ 613 + { 614 + "type": "{ $type: \"test.refRequired\"; author?: { [x: string]: unknown; $type: \"com.example.user\"; } | undefined; }", 615 + "relationships": { 616 + "args": [ 617 + "equality" 618 + ], 619 + "typeArgs": [] 620 + } 621 + } 622 + ], 623 + "typeArgs": [], 624 + "errors": [], 625 + "completions": {} 626 + }, 627 + { 628 + "location": { 629 + "start": { 630 + "line": 389, 631 + "char": 2 632 + }, 633 + "end": { 634 + "line": 389, 635 + "char": 25 636 + } 637 + }, 638 + "args": [ 639 + { 640 + "type": "{ $type: \"test.refNullable\"; parent?: { [x: string]: unknown; $type: \"com.example.node\"; } | undefined; }", 641 + "relationships": { 642 + "args": [ 643 + "equality" 644 + ], 645 + "typeArgs": [] 646 + } 647 + } 648 + ], 649 + "typeArgs": [], 650 + "errors": [], 651 + "completions": {} 652 + }, 653 + { 654 + "location": { 655 + "start": { 656 + "line": 408, 657 + "char": 2 658 + }, 659 + "end": { 660 + "line": 408, 661 + "char": 25 662 + } 663 + }, 664 + "args": [ 665 + { 666 + "type": "{ $type: \"test.union\"; content?: { [x: string]: unknown; $type: \"com.example.text\"; } | { [x: string]: unknown; $type: \"com.example.image\"; } | undefined; }", 667 + "relationships": { 668 + "args": [ 669 + "equality" 670 + ], 671 + "typeArgs": [] 672 + } 673 + } 674 + ], 675 + "typeArgs": [], 676 + "errors": [], 677 + "completions": {} 678 + }, 679 + { 680 + "location": { 681 + "start": { 682 + "line": 426, 683 + "char": 2 684 + }, 685 + "end": { 686 + "line": 426, 687 + "char": 25 688 + } 689 + }, 690 + "args": [ 691 + { 692 + "type": "{ $type: \"test.unionRequired\"; media: { [x: string]: unknown; $type: \"com.example.video\"; } | { [x: string]: unknown; $type: \"com.example.audio\"; }; }", 693 + "relationships": { 694 + "args": [ 695 + "equality" 696 + ], 697 + "typeArgs": [] 698 + } 699 + } 700 + ], 701 + "typeArgs": [], 702 + "errors": [], 703 + "completions": {} 704 + }, 705 + { 706 + "location": { 707 + "start": { 708 + "line": 446, 709 + "char": 2 710 + }, 711 + "end": { 712 + "line": 446, 713 + "char": 25 714 + } 715 + }, 716 + "args": [ 717 + { 718 + "type": "{ $type: \"test.unionMultiple\"; attachment?: { [x: string]: unknown; $type: \"com.example.image\"; } | { [x: string]: unknown; $type: \"com.example.video\"; } | { [x: string]: unknown; $type: \"com.example.audio\"; } | { [x: string]: unknown; $type: \"com.example.document\"; } | undefined; }", 719 + "relationships": { 720 + "args": [ 721 + "equality" 722 + ], 723 + "typeArgs": [] 724 + } 725 + } 726 + ], 727 + "typeArgs": [], 728 + "errors": [], 729 + "completions": {} 730 + }, 731 + { 732 + "location": { 733 + "start": { 734 + "line": 472, 735 + "char": 2 736 + }, 737 + "end": { 738 + "line": 472, 739 + "char": 25 740 + } 741 + }, 742 + "args": [ 743 + { 744 + "type": "{ $type: \"test.params\"; limit?: number | undefined; offset?: number | undefined; }", 745 + "relationships": { 746 + "args": [ 747 + "equality" 748 + ], 749 + "typeArgs": [] 750 + } 751 + } 752 + ], 753 + "typeArgs": [], 754 + "errors": [], 755 + "completions": {} 756 + }, 757 + { 758 + "location": { 759 + "start": { 760 + "line": 487, 761 + "char": 2 762 + }, 763 + "end": { 764 + "line": 487, 765 + "char": 25 766 + } 767 + }, 768 + "args": [ 769 + { 770 + "type": "{ $type: \"test.paramsRequired\"; limit?: number | undefined; query: string; }", 771 + "relationships": { 772 + "args": [ 773 + "equality" 774 + ], 775 + "typeArgs": [] 776 + } 777 + } 778 + ], 779 + "typeArgs": [], 780 + "errors": [], 781 + "completions": {} 782 + }, 783 + { 784 + "location": { 785 + "start": { 786 + "line": 510, 787 + "char": 2 788 + }, 789 + "end": { 790 + "line": 510, 791 + "char": 25 792 + } 793 + }, 794 + "args": [ 795 + { 796 + "type": "{ $type: \"test.record\"; published?: boolean | undefined; content: string; title: string; }", 797 + "relationships": { 798 + "args": [ 799 + "equality" 800 + ], 801 + "typeArgs": [] 802 + } 803 + } 804 + ], 805 + "typeArgs": [], 806 + "errors": [], 807 + "completions": {} 808 + }, 809 + { 810 + "location": { 811 + "start": { 812 + "line": 532, 813 + "char": 2 814 + }, 815 + "end": { 816 + "line": 532, 817 + "char": 25 818 + } 819 + }, 820 + "args": [ 821 + { 822 + "type": "{ $type: \"test.nested\"; user?: { name: string; email: string; } | undefined; }", 823 + "relationships": { 824 + "args": [ 825 + "equality" 826 + ], 827 + "typeArgs": [] 828 + } 829 + } 830 + ], 831 + "typeArgs": [], 832 + "errors": [], 833 + "completions": {} 834 + }, 835 + { 836 + "location": { 837 + "start": { 838 + "line": 551, 839 + "char": 2 840 + }, 841 + "end": { 842 + "line": 551, 843 + "char": 25 844 + } 845 + }, 846 + "args": [ 847 + { 848 + "type": "{ $type: \"test.deepNested\"; data?: { user?: { profile?: { name: string; } | undefined; } | undefined; } | undefined; }", 849 + "relationships": { 850 + "args": [ 851 + "equality" 852 + ], 853 + "typeArgs": [] 854 + } 855 + } 856 + ], 857 + "typeArgs": [], 858 + "errors": [], 859 + "completions": {} 860 + }, 861 + { 862 + "location": { 863 + "start": { 864 + "line": 579, 865 + "char": 2 866 + }, 867 + "end": { 868 + "line": 579, 869 + "char": 25 870 + } 871 + }, 872 + "args": [ 873 + { 874 + "type": "{ $type: \"test.arrayOfObjects\"; users?: { id: string; name: string; }[] | undefined; }", 875 + "relationships": { 876 + "args": [ 877 + "equality" 878 + ], 879 + "typeArgs": [] 880 + } 881 + } 882 + ], 883 + "typeArgs": [], 884 + "errors": [], 885 + "completions": {} 886 + }, 887 + { 888 + "location": { 889 + "start": { 890 + "line": 594, 891 + "char": 2 892 + }, 893 + "end": { 894 + "line": 594, 895 + "char": 25 896 + } 897 + }, 898 + "args": [ 899 + { 900 + "type": "{ $type: \"test.nestedArrays\"; matrix?: number[][] | undefined; }", 901 + "relationships": { 902 + "args": [ 903 + "equality" 904 + ], 905 + "typeArgs": [] 906 + } 907 + } 908 + ], 909 + "typeArgs": [], 910 + "errors": [], 911 + "completions": {} 912 + }, 913 + { 914 + "location": { 915 + "start": { 916 + "line": 607, 917 + "char": 2 918 + }, 919 + "end": { 920 + "line": 607, 921 + "char": 25 922 + } 923 + }, 924 + "args": [ 925 + { 926 + "type": "{ $type: \"test.arrayOfRefs\"; followers?: { [x: string]: unknown; $type: \"com.example.user\"; }[] | undefined; }", 927 + "relationships": { 928 + "args": [ 929 + "equality" 930 + ], 931 + "typeArgs": [] 932 + } 933 + } 934 + ], 935 + "typeArgs": [], 936 + "errors": [], 937 + "completions": {} 938 + }, 939 + { 940 + "location": { 941 + "start": { 942 + "line": 638, 943 + "char": 2 944 + }, 945 + "end": { 946 + "line": 638, 947 + "char": 25 948 + } 949 + }, 950 + "args": [ 951 + { 952 + "type": "{ $type: \"test.complex\"; tags?: string[] | undefined; content?: { [x: string]: unknown; $type: \"com.example.text\"; } | { [x: string]: unknown; $type: \"com.example.image\"; } | undefined; author?: { avatar?: string | undefined; did: string; handle: string; } | undefined; metadata?: { likes?: number | undefined; views?: number | undefined; shares?: number | undefined; } | undefined; id: string; }", 953 + "relationships": { 954 + "args": [ 955 + "equality" 956 + ], 957 + "typeArgs": [] 958 + } 959 + } 960 + ], 961 + "typeArgs": [], 962 + "errors": [], 963 + "completions": {} 964 + }, 965 + { 966 + "location": { 967 + "start": { 968 + "line": 683, 969 + "char": 2 970 + }, 971 + "end": { 972 + "line": 683, 973 + "char": 25 974 + } 975 + }, 976 + "args": [ 977 + { 978 + "type": "never", 979 + "relationships": { 980 + "args": [ 981 + "equality" 982 + ], 983 + "typeArgs": [] 984 + } 985 + } 986 + ], 987 + "typeArgs": [], 988 + "errors": [], 989 + "completions": {} 990 + }, 991 + { 992 + "location": { 993 + "start": { 994 + "line": 701, 995 + "char": 2 996 + }, 997 + "end": { 998 + "line": 701, 999 + "char": 25 1000 + } 1001 + }, 1002 + "args": [ 1003 + { 1004 + "type": "{ $type: \"com.example.blog\"; title: string; body: string; }", 1005 + "relationships": { 1006 + "args": [ 1007 + "equality" 1008 + ], 1009 + "typeArgs": [] 1010 + } 1011 + } 1012 + ], 1013 + "typeArgs": [], 1014 + "errors": [], 1015 + "completions": {} 1016 + }, 1017 + { 1018 + "location": { 1019 + "start": { 1020 + "line": 724, 1021 + "char": 2 1022 + }, 1023 + "end": { 1024 + "line": 724, 1025 + "char": 18 1026 + } 1027 + }, 1028 + "args": [ 1029 + { 1030 + "type": "{ $type: \"test\"; author?: { name: string; email: string; $type: \"#user\"; } | undefined; content: string; }", 1031 + "relationships": { 1032 + "args": [ 1033 + "equality" 1034 + ], 1035 + "typeArgs": [] 1036 + } 1037 + } 1038 + ], 1039 + "typeArgs": [], 1040 + "errors": [], 1041 + "completions": {} 1042 + }, 1043 + { 1044 + "location": { 1045 + "start": { 1046 + "line": 743, 1047 + "char": 2 1048 + }, 1049 + "end": { 1050 + "line": 743, 1051 + "char": 18 1052 + } 1053 + }, 1054 + "args": [ 1055 + { 1056 + "type": "{ $type: \"test\"; users?: { name: string; $type: \"#user\"; }[] | undefined; }", 1057 + "relationships": { 1058 + "args": [ 1059 + "equality" 1060 + ], 1061 + "typeArgs": [] 1062 + } 1063 + } 1064 + ], 1065 + "typeArgs": [], 1066 + "errors": [], 1067 + "completions": {} 1068 + }, 1069 + { 1070 + "location": { 1071 + "start": { 1072 + "line": 758, 1073 + "char": 2 1074 + }, 1075 + "end": { 1076 + "line": 758, 1077 + "char": 18 1078 + } 1079 + }, 1080 + "args": [ 1081 + { 1082 + "type": "{ $type: \"test\"; embed?: { content: string; $type: \"#text\"; } | { url: string; $type: \"#image\"; } | undefined; }", 1083 + "relationships": { 1084 + "args": [ 1085 + "equality" 1086 + ], 1087 + "typeArgs": [] 1088 + } 1089 + } 1090 + ], 1091 + "typeArgs": [], 1092 + "errors": [], 1093 + "completions": {} 1094 + }, 1095 + { 1096 + "location": { 1097 + "start": { 1098 + "line": 781, 1099 + "char": 2 1100 + }, 1101 + "end": { 1102 + "line": 781, 1103 + "char": 18 1104 + } 1105 + }, 1106 + "args": [ 1107 + { 1108 + "type": "{ $type: \"test\"; author?: { profile?: { bio: string; $type: \"#profile\"; } | undefined; name: string; $type: \"#user\"; } | undefined; }", 1109 + "relationships": { 1110 + "args": [ 1111 + "equality" 1112 + ], 1113 + "typeArgs": [] 1114 + } 1115 + } 1116 + ], 1117 + "typeArgs": [], 1118 + "errors": [], 1119 + "completions": {} 1120 + }, 1121 + { 1122 + "location": { 1123 + "start": { 1124 + "line": 807, 1125 + "char": 2 1126 + }, 1127 + "end": { 1128 + "line": 807, 1129 + "char": 18 1130 + } 1131 + }, 1132 + "args": [ 1133 + { 1134 + "type": "{ $type: \"test\"; parent?: { parent?: \"[Circular reference detected: #main]\" | undefined; value: string; $type: \"#main\"; } | undefined; value: string; }", 1135 + "relationships": { 1136 + "args": [ 1137 + "equality" 1138 + ], 1139 + "typeArgs": [] 1140 + } 1141 + } 1142 + ], 1143 + "typeArgs": [], 1144 + "errors": [], 1145 + "completions": {} 1146 + }, 1147 + { 1148 + "location": { 1149 + "start": { 1150 + "line": 837, 1151 + "char": 2 1152 + }, 1153 + "end": { 1154 + "line": 837, 1155 + "char": 18 1156 + } 1157 + }, 1158 + "args": [ 1159 + { 1160 + "type": "{ $type: \"test\"; users?: { posts?: { author?: \"[Circular reference detected: #user]\" | undefined; title: string; $type: \"#post\"; }[] | undefined; name: string; $type: \"#user\"; }[] | undefined; }", 1161 + "relationships": { 1162 + "args": [ 1163 + "equality" 1164 + ], 1165 + "typeArgs": [] 1166 + } 1167 + } 1168 + ], 1169 + "typeArgs": [], 1170 + "errors": [], 1171 + "completions": {} 1172 + }, 1173 + { 1174 + "location": { 1175 + "start": { 1176 + "line": 864, 1177 + "char": 2 1178 + }, 1179 + "end": { 1180 + "line": 864, 1181 + "char": 18 1182 + } 1183 + }, 1184 + "args": [ 1185 + { 1186 + "type": "{ $type: \"test\"; author?: \"[Reference not found: #user]\" | undefined; }", 1187 + "relationships": { 1188 + "args": [ 1189 + "equality" 1190 + ], 1191 + "typeArgs": [] 1192 + } 1193 + } 1194 + ], 1195 + "typeArgs": [], 1196 + "errors": [], 1197 + "completions": {} 1198 + } 1199 + ] 1200 + }
+42
packages/prototypey/package.json
··· 1 + { 2 + "name": "prototypey", 3 + "version": "0.0.0", 4 + "description": "Type-safe lexicon inference for ATProto schemas", 5 + "repository": { 6 + "type": "git", 7 + "url": "git+https://github.com/tylersayshi/prototypey.git", 8 + "directory": "packages/prototypey" 9 + }, 10 + "license": "MIT", 11 + "author": { 12 + "name": "tylersayshi", 13 + "email": "hi@tylur.dev" 14 + }, 15 + "type": "module", 16 + "main": "lib/index.js", 17 + "exports": { 18 + ".": "./lib/index.js", 19 + "./infer": "./lib/infer.js" 20 + }, 21 + "files": [ 22 + "lib/", 23 + "README.md" 24 + ], 25 + "scripts": { 26 + "build": "tsdown", 27 + "test": "vitest run", 28 + "test:bench": "node tests/infer.bench.ts", 29 + "test:update-snapshots": "vitest run -u", 30 + "tsc": "tsc" 31 + }, 32 + "devDependencies": { 33 + "@ark/attest": "^0.49.0", 34 + "@types/node": "24.0.4", 35 + "tsdown": "0.12.7", 36 + "typescript": "5.8.3", 37 + "vitest": "^3.2.4" 38 + }, 39 + "engines": { 40 + "node": ">=20.19.0" 41 + } 42 + }
+11
packages/prototypey/setup-vitest.ts
··· 1 + import { setup } from "@ark/attest"; 2 + 3 + // config options can be passed here 4 + export default () => 5 + setup({ 6 + // Set to true during development to skip type checking (faster) 7 + skipTypes: false, 8 + 9 + // Fail if benchmarks deviate by more than 20% 10 + benchPercentThreshold: 20, 11 + });
+2
packages/prototypey/src/index.ts
··· 1 + export * from "./lib.ts"; 2 + export * from "./infer.ts";
+141
packages/prototypey/src/infer.ts
··· 1 + import { Prettify } from "./type-utils.ts"; 2 + 3 + /* eslint-disable @typescript-eslint/no-empty-object-type */ 4 + type InferType<T> = T extends { type: "record" } 5 + ? InferRecord<T> 6 + : T extends { type: "object" } 7 + ? InferObject<T> 8 + : T extends { type: "array" } 9 + ? InferArray<T> 10 + : T extends { type: "params" } 11 + ? InferParams<T> 12 + : T extends { type: "union" } 13 + ? InferUnion<T> 14 + : T extends { type: "token" } 15 + ? InferToken<T> 16 + : T extends { type: "ref" } 17 + ? InferRef<T> 18 + : T extends { type: "unknown" } 19 + ? unknown 20 + : T extends { type: "null" } 21 + ? null 22 + : T extends { type: "boolean" } 23 + ? boolean 24 + : T extends { type: "integer" } 25 + ? number 26 + : T extends { type: "string" } 27 + ? string 28 + : T extends { type: "bytes" } 29 + ? Uint8Array 30 + : T extends { type: "cid-link" } 31 + ? string 32 + : T extends { type: "blob" } 33 + ? Blob 34 + : never; 35 + 36 + type InferToken<T> = T extends { enum: readonly (infer U)[] } ? U : string; 37 + 38 + export type GetRequired<T> = T extends { required: readonly (infer R)[] } 39 + ? R 40 + : never; 41 + export type GetNullable<T> = T extends { nullable: readonly (infer N)[] } 42 + ? N 43 + : never; 44 + 45 + type InferObject< 46 + T, 47 + Nullable extends string = GetNullable<T> & string, 48 + Required extends string = GetRequired<T> & string, 49 + NullableAndRequired extends string = Required & Nullable & string, 50 + Normal extends string = "properties" extends keyof T 51 + ? Exclude<keyof T["properties"], Required | Nullable> & string 52 + : never, 53 + > = Prettify< 54 + T extends { properties: infer P } 55 + ? { 56 + -readonly [K in Normal]?: InferType<P[K & keyof P]>; 57 + } & { 58 + -readonly [K in Exclude<Required, NullableAndRequired>]-?: InferType< 59 + P[K & keyof P] 60 + >; 61 + } & { 62 + -readonly [K in Exclude<Nullable, NullableAndRequired>]?: InferType< 63 + P[K & keyof P] 64 + > | null; 65 + } & { 66 + -readonly [K in NullableAndRequired]: InferType<P[K & keyof P]> | null; 67 + } 68 + : {} 69 + >; 70 + 71 + type InferArray<T> = T extends { items: infer Items } 72 + ? InferType<Items>[] 73 + : never[]; 74 + 75 + type InferUnion<T> = T extends { refs: readonly (infer R)[] } 76 + ? R extends string 77 + ? { $type: R; [key: string]: unknown } 78 + : never 79 + : never; 80 + 81 + type InferRef<T> = T extends { ref: infer R } 82 + ? R extends string 83 + ? { $type: R; [key: string]: unknown } 84 + : unknown 85 + : unknown; 86 + 87 + type InferParams<T> = InferObject<T>; 88 + 89 + type InferRecord<T> = T extends { record: infer R } 90 + ? R extends { type: "object" } 91 + ? InferObject<R> 92 + : R extends { type: "union" } 93 + ? InferUnion<R> 94 + : unknown 95 + : unknown; 96 + 97 + /** 98 + * Recursively replaces stub references in a type with their actual definitions. 99 + * Detects circular references and missing references, returning string literal error messages. 100 + */ 101 + type ReplaceRefsInType<T, Defs, Visited = never> = 102 + // Check if this is a ref stub type (has $type starting with #) 103 + T extends { $type: `#${infer DefName}` } 104 + ? DefName extends keyof Defs 105 + ? // Check for circular reference 106 + DefName extends Visited 107 + ? `[Circular reference detected: #${DefName}]` 108 + : // Recursively resolve the ref and preserve the $type marker 109 + Prettify< 110 + ReplaceRefsInType<Defs[DefName], Defs, Visited | DefName> & { 111 + $type: T["$type"]; 112 + } 113 + > 114 + : // Reference not found in definitions 115 + `[Reference not found: #${DefName}]` 116 + : // Handle arrays (but not Uint8Array or other typed arrays) 117 + T extends Uint8Array | Blob 118 + ? T 119 + : T extends readonly (infer Item)[] 120 + ? ReplaceRefsInType<Item, Defs, Visited>[] 121 + : // Handle plain objects (exclude built-in types and functions) 122 + T extends object 123 + ? T extends (...args: unknown[]) => unknown 124 + ? T 125 + : { [K in keyof T]: ReplaceRefsInType<T[K], Defs, Visited> } 126 + : // Primitives pass through unchanged 127 + T; 128 + 129 + /** 130 + * Infers the TypeScript type for a lexicon namespace, returning only the 'main' definition 131 + * with all local refs (#user, #post, etc.) resolved to their actual types. 132 + */ 133 + export type Infer<T extends { id: string; defs: Record<string, unknown> }> = 134 + Prettify< 135 + "main" extends keyof T["defs"] 136 + ? { $type: T["id"] } & ReplaceRefsInType< 137 + InferType<T["defs"]["main"]>, 138 + { [K in keyof T["defs"]]: InferType<T["defs"][K]> } 139 + > 140 + : never 141 + >;
+579
packages/prototypey/src/lib.ts
··· 1 + /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 + import type { Infer } from "./infer.ts"; 3 + import type { UnionToTuple } from "./type-utils.ts"; 4 + 5 + /** @see https://atproto.com/specs/lexicon#overview-of-types */ 6 + type LexiconType = 7 + // Concrete types 8 + | "null" 9 + | "boolean" 10 + | "integer" 11 + | "string" 12 + | "bytes" 13 + | "cid-link" 14 + | "blob" 15 + // Container types 16 + | "array" 17 + | "object" 18 + | "params" 19 + // Meta types 20 + | "token" 21 + | "ref" 22 + | "union" 23 + | "unknown" 24 + // Primary types 25 + | "record" 26 + | "query" 27 + | "procedure" 28 + | "subscription"; 29 + 30 + /** 31 + * Common options available for lexicon items. 32 + * @see https://atproto.com/specs/lexicon#string-formats 33 + */ 34 + interface LexiconItemCommonOptions { 35 + /** Indicates this field must be provided */ 36 + required?: boolean; 37 + /** Indicates this field can be explicitly set to null */ 38 + nullable?: boolean; 39 + } 40 + 41 + /** 42 + * Base interface for all lexicon items. 43 + * @see https://atproto.com/specs/lexicon#overview-of-types 44 + */ 45 + interface LexiconItem extends LexiconItemCommonOptions { 46 + type: LexiconType; 47 + } 48 + 49 + /** 50 + * Definition in a lexicon namespace. 51 + * @see https://atproto.com/specs/lexicon#lexicon-document 52 + */ 53 + interface Def { 54 + type: LexiconType; 55 + } 56 + 57 + /** 58 + * Lexicon namespace document structure. 59 + * @see https://atproto.com/specs/lexicon#lexicon-document 60 + */ 61 + interface LexiconNamespace { 62 + /** Namespaced identifier (NSID) for this lexicon */ 63 + id: string; 64 + /** Named definitions within this namespace */ 65 + defs: Record<string, Def>; 66 + } 67 + 68 + /** 69 + * String type options. 70 + * @see https://atproto.com/specs/lexicon#string 71 + */ 72 + interface StringOptions extends LexiconItemCommonOptions { 73 + /** 74 + * Semantic string format constraint. 75 + * @see https://atproto.com/specs/lexicon#string-formats 76 + */ 77 + format?: 78 + | "at-identifier" // Handle or DID 79 + | "at-uri" // AT Protocol URI 80 + | "cid" // Content Identifier 81 + | "datetime" // Timestamp (UTC, ISO 8601) 82 + | "did" // Decentralized Identifier 83 + | "handle" // User handle identifier 84 + | "nsid" // Namespaced Identifier 85 + | "tid" // Timestamp Identifier 86 + | "record-key" // Repository record key 87 + | "uri" // Generic URI 88 + | "language"; // IETF BCP 47 language tag 89 + /** Maximum string length in bytes */ 90 + maxLength?: number; 91 + /** Minimum string length in bytes */ 92 + minLength?: number; 93 + /** Maximum string length in Unicode graphemes */ 94 + maxGraphemes?: number; 95 + /** Minimum string length in Unicode graphemes */ 96 + minGraphemes?: number; 97 + /** Hints at expected values, not enforced */ 98 + knownValues?: string[]; 99 + /** Restricts to an exact set of string values */ 100 + enum?: string[]; 101 + /** Default value if not provided */ 102 + default?: string; 103 + /** Fixed, unchangeable value */ 104 + const?: string; 105 + } 106 + 107 + /** 108 + * Boolean type options. 109 + * @see https://atproto.com/specs/lexicon#boolean 110 + */ 111 + interface BooleanOptions extends LexiconItemCommonOptions { 112 + /** Default value if not provided */ 113 + default?: boolean; 114 + /** Fixed, unchangeable value */ 115 + const?: boolean; 116 + } 117 + 118 + /** 119 + * Integer type options. 120 + * @see https://atproto.com/specs/lexicon#integer 121 + */ 122 + interface IntegerOptions extends LexiconItemCommonOptions { 123 + /** Minimum allowed value (inclusive) */ 124 + minimum?: number; 125 + /** Maximum allowed value (inclusive) */ 126 + maximum?: number; 127 + /** Restricts to an exact set of integer values */ 128 + enum?: number[]; 129 + /** Default value if not provided */ 130 + default?: number; 131 + /** Fixed, unchangeable value */ 132 + const?: number; 133 + } 134 + 135 + /** 136 + * Bytes type options for arbitrary byte arrays. 137 + * @see https://atproto.com/specs/lexicon#bytes 138 + */ 139 + interface BytesOptions extends LexiconItemCommonOptions { 140 + /** Minimum byte array length */ 141 + minLength?: number; 142 + /** Maximum byte array length */ 143 + maxLength?: number; 144 + } 145 + 146 + /** 147 + * Blob type options for binary data with MIME types. 148 + * @see https://atproto.com/specs/lexicon#blob 149 + */ 150 + interface BlobOptions extends LexiconItemCommonOptions { 151 + /** Allowed MIME types (e.g., ["image/png", "image/jpeg"]) */ 152 + accept?: string[]; 153 + /** Maximum blob size in bytes */ 154 + maxSize?: number; 155 + } 156 + 157 + /** 158 + * Array type options. 159 + * @see https://atproto.com/specs/lexicon#array 160 + */ 161 + interface ArrayOptions extends LexiconItemCommonOptions { 162 + /** Minimum array length */ 163 + minLength?: number; 164 + /** Maximum array length */ 165 + maxLength?: number; 166 + } 167 + 168 + /** 169 + * Record type options for repository records. 170 + * @see https://atproto.com/specs/lexicon#record 171 + */ 172 + interface RecordOptions { 173 + /** Record key strategy: "self" for self-describing or "tid" for timestamp IDs */ 174 + key: "self" | "tid"; 175 + /** Object schema defining the record structure */ 176 + record: { type: "object" }; 177 + /** Human-readable description */ 178 + description?: string; 179 + } 180 + 181 + /** 182 + * Union type options for multiple possible types. 183 + * @see https://atproto.com/specs/lexicon#union 184 + */ 185 + interface UnionOptions extends LexiconItemCommonOptions { 186 + /** If true, only listed refs are allowed; if false, additional types may be added */ 187 + closed?: boolean; 188 + } 189 + 190 + /** 191 + * Map of property names to their lexicon item definitions. 192 + * @see https://atproto.com/specs/lexicon#object 193 + */ 194 + type ObjectProperties = Record< 195 + string, 196 + { 197 + type: LexiconType; 198 + } 199 + >; 200 + 201 + type RequiredKeys<T> = { 202 + [K in keyof T]: T[K] extends { required: true } ? K : never; 203 + }[keyof T]; 204 + 205 + type NullableKeys<T> = { 206 + [K in keyof T]: T[K] extends { nullable: true } ? K : never; 207 + }[keyof T]; 208 + 209 + /** 210 + * Resulting object schema with required and nullable fields extracted. 211 + * @see https://atproto.com/specs/lexicon#object 212 + */ 213 + type ObjectResult<T extends ObjectProperties> = { 214 + type: "object"; 215 + /** Property definitions */ 216 + properties: { 217 + [K in keyof T]: T[K] extends { type: "object" } 218 + ? T[K] 219 + : Omit<T[K], "required" | "nullable">; 220 + }; 221 + } & ([RequiredKeys<T>] extends [never] 222 + ? {} 223 + : { required: UnionToTuple<RequiredKeys<T>> }) & 224 + ([NullableKeys<T>] extends [never] 225 + ? {} 226 + : { nullable: UnionToTuple<NullableKeys<T>> }); 227 + 228 + /** 229 + * Map of parameter names to their lexicon item definitions. 230 + * @see https://atproto.com/specs/lexicon#params 231 + */ 232 + type ParamsProperties = Record<string, LexiconItem>; 233 + 234 + /** 235 + * Resulting params schema with required fields extracted. 236 + * @see https://atproto.com/specs/lexicon#params 237 + */ 238 + type ParamsResult<T extends ParamsProperties> = { 239 + type: "params"; 240 + /** Parameter definitions */ 241 + properties: { 242 + [K in keyof T]: Omit<T[K], "required" | "nullable">; 243 + }; 244 + } & ([RequiredKeys<T>] extends [never] 245 + ? {} 246 + : { required: UnionToTuple<RequiredKeys<T>> }); 247 + 248 + /** 249 + * HTTP request or response body schema. 250 + * @see https://atproto.com/specs/lexicon#http-endpoints 251 + */ 252 + interface BodySchema { 253 + /** MIME type encoding (typically "application/json") */ 254 + encoding: "application/json" | (string & {}); 255 + /** Human-readable description */ 256 + description?: string; 257 + /** Object schema defining the body structure */ 258 + schema?: ObjectResult<ObjectProperties>; 259 + } 260 + 261 + /** 262 + * Error definition for HTTP endpoints. 263 + * @see https://atproto.com/specs/lexicon#http-endpoints 264 + */ 265 + interface ErrorDef { 266 + /** Error name/code */ 267 + name: string; 268 + /** Human-readable error description */ 269 + description?: string; 270 + } 271 + 272 + /** 273 + * Query endpoint options (HTTP GET). 274 + * @see https://atproto.com/specs/lexicon#query 275 + */ 276 + interface QueryOptions { 277 + /** Human-readable description */ 278 + description?: string; 279 + /** Query string parameters */ 280 + parameters?: ParamsResult<ParamsProperties>; 281 + /** Response body schema */ 282 + output?: BodySchema; 283 + /** Possible error responses */ 284 + errors?: ErrorDef[]; 285 + } 286 + 287 + /** 288 + * Procedure endpoint options (HTTP POST). 289 + * @see https://atproto.com/specs/lexicon#procedure 290 + */ 291 + interface ProcedureOptions { 292 + /** Human-readable description */ 293 + description?: string; 294 + /** Query string parameters */ 295 + parameters?: ParamsResult<ParamsProperties>; 296 + /** Request body schema */ 297 + input?: BodySchema; 298 + /** Response body schema */ 299 + output?: BodySchema; 300 + /** Possible error responses */ 301 + errors?: ErrorDef[]; 302 + } 303 + 304 + /** 305 + * WebSocket message schema for subscriptions. 306 + * @see https://atproto.com/specs/lexicon#subscription 307 + */ 308 + interface MessageSchema { 309 + /** Human-readable description */ 310 + description?: string; 311 + /** Union of possible message types */ 312 + schema: { type: "union"; refs: readonly string[] }; 313 + } 314 + 315 + /** 316 + * Subscription endpoint options (WebSocket). 317 + * @see https://atproto.com/specs/lexicon#subscription 318 + */ 319 + interface SubscriptionOptions { 320 + /** Human-readable description */ 321 + description?: string; 322 + /** Query string parameters */ 323 + parameters?: ParamsResult<ParamsProperties>; 324 + /** Message schema for events */ 325 + message?: MessageSchema; 326 + /** Possible error responses */ 327 + errors?: ErrorDef[]; 328 + } 329 + 330 + class Namespace<T extends LexiconNamespace> { 331 + public json: T; 332 + public infer: Infer<T> = null as unknown as Infer<T>; 333 + 334 + constructor(json: T) { 335 + this.json = json; 336 + } 337 + } 338 + 339 + /** 340 + * Main API for creating lexicon schemas. 341 + * @see https://atproto.com/specs/lexicon 342 + */ 343 + export const lx = { 344 + /** 345 + * Creates a null type. 346 + * @see https://atproto.com/specs/lexicon#null 347 + */ 348 + null( 349 + options?: LexiconItemCommonOptions, 350 + ): { type: "null" } & LexiconItemCommonOptions { 351 + return { 352 + type: "null", 353 + ...options, 354 + }; 355 + }, 356 + /** 357 + * Creates a boolean type with optional constraints. 358 + * @see https://atproto.com/specs/lexicon#boolean 359 + */ 360 + boolean<T extends BooleanOptions>(options?: T): T & { type: "boolean" } { 361 + return { 362 + type: "boolean", 363 + ...options, 364 + } as T & { type: "boolean" }; 365 + }, 366 + /** 367 + * Creates an integer type with optional min/max and enum constraints. 368 + * @see https://atproto.com/specs/lexicon#integer 369 + */ 370 + integer<T extends IntegerOptions>(options?: T): T & { type: "integer" } { 371 + return { 372 + type: "integer", 373 + ...options, 374 + } as T & { type: "integer" }; 375 + }, 376 + /** 377 + * Creates a string type with optional format, length, and value constraints. 378 + * @see https://atproto.com/specs/lexicon#string 379 + */ 380 + string<T extends StringOptions>(options?: T): T & { type: "string" } { 381 + return { 382 + type: "string", 383 + ...options, 384 + } as T & { type: "string" }; 385 + }, 386 + /** 387 + * Creates an unknown type for flexible, unvalidated objects. 388 + * @see https://atproto.com/specs/lexicon#unknown 389 + */ 390 + unknown( 391 + options?: LexiconItemCommonOptions, 392 + ): { type: "unknown" } & LexiconItemCommonOptions { 393 + return { 394 + type: "unknown", 395 + ...options, 396 + }; 397 + }, 398 + /** 399 + * Creates a bytes type for arbitrary byte arrays. 400 + * @see https://atproto.com/specs/lexicon#bytes 401 + */ 402 + bytes<T extends BytesOptions>(options?: T): T & { type: "bytes" } { 403 + return { 404 + type: "bytes", 405 + ...options, 406 + } as T & { type: "bytes" }; 407 + }, 408 + /** 409 + * Creates a CID link reference to content-addressed data. 410 + * @see https://atproto.com/specs/lexicon#cid-link 411 + */ 412 + cidLink<Link extends string>(link: Link): { type: "cid-link"; $link: Link } { 413 + return { 414 + type: "cid-link", 415 + $link: link, 416 + }; 417 + }, 418 + /** 419 + * Creates a blob type for binary data with MIME type constraints. 420 + * @see https://atproto.com/specs/lexicon#blob 421 + */ 422 + blob<T extends BlobOptions>(options?: T): T & { type: "blob" } { 423 + return { 424 + type: "blob", 425 + ...options, 426 + } as T & { type: "blob" }; 427 + }, 428 + /** 429 + * Creates an array type with item schema and length constraints. 430 + * @see https://atproto.com/specs/lexicon#array 431 + */ 432 + array<Items extends { type: LexiconType }, Options extends ArrayOptions>( 433 + items: Items, 434 + options?: Options, 435 + ): Options & { type: "array"; items: Items } { 436 + return { 437 + type: "array", 438 + items, 439 + ...options, 440 + } as Options & { type: "array"; items: Items }; 441 + }, 442 + /** 443 + * Creates a token type for symbolic values in unions. 444 + * @see https://atproto.com/specs/lexicon#token 445 + */ 446 + token<Description extends string>( 447 + description: Description, 448 + ): { type: "token"; description: Description } { 449 + return { type: "token", description }; 450 + }, 451 + /** 452 + * Creates a reference to another schema definition. 453 + * @see https://atproto.com/specs/lexicon#ref 454 + */ 455 + ref<Ref extends string>( 456 + ref: Ref, 457 + options?: LexiconItemCommonOptions, 458 + ): LexiconItemCommonOptions & { type: "ref"; ref: Ref } { 459 + return { 460 + type: "ref", 461 + ref, 462 + ...options, 463 + } as LexiconItemCommonOptions & { type: "ref"; ref: Ref }; 464 + }, 465 + /** 466 + * Creates a union type for multiple possible type variants. 467 + * @see https://atproto.com/specs/lexicon#union 468 + */ 469 + union<const Refs extends readonly string[], Options extends UnionOptions>( 470 + refs: Refs, 471 + options?: Options, 472 + ): Options & { type: "union"; refs: Refs } { 473 + return { 474 + type: "union", 475 + refs, 476 + ...options, 477 + } as Options & { type: "union"; refs: Refs }; 478 + }, 479 + /** 480 + * Creates a record type for repository records. 481 + * @see https://atproto.com/specs/lexicon#record 482 + */ 483 + record<T extends RecordOptions>(options: T): T & { type: "record" } { 484 + return { 485 + type: "record", 486 + ...options, 487 + }; 488 + }, 489 + /** 490 + * Creates an object type with defined properties. 491 + * @see https://atproto.com/specs/lexicon#object 492 + */ 493 + object<T extends ObjectProperties>(options: T): ObjectResult<T> { 494 + const required = Object.keys(options).filter( 495 + (key) => "required" in options[key] && options[key].required, 496 + ); 497 + const nullable = Object.keys(options).filter( 498 + (key) => "nullable" in options[key] && options[key].nullable, 499 + ); 500 + const result: Record<string, unknown> = { 501 + type: "object", 502 + properties: options, 503 + }; 504 + if (required.length > 0) { 505 + result.required = required; 506 + } 507 + if (nullable.length > 0) { 508 + result.nullable = nullable; 509 + } 510 + return result as ObjectResult<T>; 511 + }, 512 + /** 513 + * Creates a params type for query string parameters. 514 + * @see https://atproto.com/specs/lexicon#params 515 + */ 516 + params<Properties extends ParamsProperties>( 517 + properties: Properties, 518 + ): ParamsResult<Properties> { 519 + const required = Object.keys(properties).filter( 520 + (key) => properties[key].required, 521 + ); 522 + const result: Record<string, unknown> = { 523 + type: "params", 524 + properties, 525 + }; 526 + if (required.length > 0) { 527 + result.required = required; 528 + } 529 + return result as ParamsResult<Properties>; 530 + }, 531 + /** 532 + * Creates a query endpoint definition (HTTP GET). 533 + * @see https://atproto.com/specs/lexicon#query 534 + */ 535 + query<T extends QueryOptions>(options?: T): T & { type: "query" } { 536 + return { 537 + type: "query", 538 + ...options, 539 + } as T & { type: "query" }; 540 + }, 541 + /** 542 + * Creates a procedure endpoint definition (HTTP POST). 543 + * @see https://atproto.com/specs/lexicon#procedure 544 + */ 545 + procedure<T extends ProcedureOptions>( 546 + options?: T, 547 + ): T & { type: "procedure" } { 548 + return { 549 + type: "procedure", 550 + ...options, 551 + } as T & { type: "procedure" }; 552 + }, 553 + /** 554 + * Creates a subscription endpoint definition (WebSocket). 555 + * @see https://atproto.com/specs/lexicon#subscription 556 + */ 557 + subscription<T extends SubscriptionOptions>( 558 + options?: T, 559 + ): T & { type: "subscription" } { 560 + return { 561 + type: "subscription", 562 + ...options, 563 + } as T & { type: "subscription" }; 564 + }, 565 + /** 566 + * Creates a lexicon namespace document. 567 + * @see https://atproto.com/specs/lexicon#lexicon-document 568 + */ 569 + namespace<ID extends string, D extends LexiconNamespace["defs"]>( 570 + id: ID, 571 + defs: D, 572 + ): Namespace<{ lexicon: 1; id: ID; defs: D }> { 573 + return new Namespace({ 574 + lexicon: 1, 575 + id, 576 + defs, 577 + }); 578 + }, 579 + };
+19
packages/prototypey/src/type-utils.ts
··· 1 + /** 2 + * Converts a string union type to a tuple type 3 + * @example 4 + * type Colors = "red" | "green" | "blue"; 5 + * type ColorTuple = UnionToTuple<Colors>; // ["red", "green", "blue"] 6 + */ 7 + export type UnionToTuple<T> = ( 8 + (T extends unknown ? (x: () => T) => void : never) extends ( 9 + x: infer I, 10 + ) => void 11 + ? I 12 + : never 13 + ) extends () => infer R 14 + ? [...UnionToTuple<Exclude<T, R>>, R] 15 + : []; 16 + 17 + export type Prettify<T> = { 18 + [K in keyof T]: T[K]; 19 + } & {};
+40
packages/prototypey/tests/base-case.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + test("app.bsky.actor.profile", () => { 5 + const profileNamespace = lx.namespace("app.bsky.actor.profile", { 6 + main: lx.record({ 7 + key: "self", 8 + record: lx.object({ 9 + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), 10 + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), 11 + }), 12 + }), 13 + }); 14 + 15 + expect(profileNamespace.json).toEqual({ 16 + lexicon: 1, 17 + id: "app.bsky.actor.profile", 18 + defs: { 19 + main: { 20 + type: "record", 21 + key: "self", 22 + record: { 23 + type: "object", 24 + properties: { 25 + displayName: { 26 + type: "string", 27 + maxLength: 64, 28 + maxGraphemes: 64, 29 + }, 30 + description: { 31 + type: "string", 32 + maxLength: 256, 33 + maxGraphemes: 256, 34 + }, 35 + }, 36 + }, 37 + }, 38 + }, 39 + }); 40 + });
+867
packages/prototypey/tests/bsky-actor.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + test("app.bsky.actor.defs - profileViewBasic", () => { 5 + const profileViewBasic = lx.object({ 6 + did: lx.string({ required: true, format: "did" }), 7 + handle: lx.string({ required: true, format: "handle" }), 8 + displayName: lx.string({ maxGraphemes: 64, maxLength: 640 }), 9 + pronouns: lx.string(), 10 + avatar: lx.string({ format: "uri" }), 11 + associated: lx.ref("#profileAssociated"), 12 + viewer: lx.ref("#viewerState"), 13 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 14 + createdAt: lx.string({ format: "datetime" }), 15 + verification: lx.ref("#verificationState"), 16 + status: lx.ref("#statusView"), 17 + }); 18 + 19 + expect(profileViewBasic).toEqual({ 20 + type: "object", 21 + properties: { 22 + did: { type: "string", required: true, format: "did" }, 23 + handle: { type: "string", required: true, format: "handle" }, 24 + displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 25 + pronouns: { type: "string" }, 26 + avatar: { type: "string", format: "uri" }, 27 + associated: { type: "ref", ref: "#profileAssociated" }, 28 + viewer: { type: "ref", ref: "#viewerState" }, 29 + labels: { 30 + type: "array", 31 + items: { type: "ref", ref: "com.atproto.label.defs#label" }, 32 + }, 33 + createdAt: { type: "string", format: "datetime" }, 34 + verification: { type: "ref", ref: "#verificationState" }, 35 + status: { type: "ref", ref: "#statusView" }, 36 + }, 37 + required: ["did", "handle"], 38 + }); 39 + }); 40 + 41 + test("app.bsky.actor.defs - profileView", () => { 42 + const profileView = lx.object({ 43 + did: lx.string({ required: true, format: "did" }), 44 + handle: lx.string({ required: true, format: "handle" }), 45 + displayName: lx.string({ maxGraphemes: 64, maxLength: 640 }), 46 + pronouns: lx.string(), 47 + description: lx.string({ maxGraphemes: 256, maxLength: 2560 }), 48 + avatar: lx.string({ format: "uri" }), 49 + associated: lx.ref("#profileAssociated"), 50 + indexedAt: lx.string({ format: "datetime" }), 51 + createdAt: lx.string({ format: "datetime" }), 52 + viewer: lx.ref("#viewerState"), 53 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 54 + verification: lx.ref("#verificationState"), 55 + status: lx.ref("#statusView"), 56 + }); 57 + 58 + expect(profileView).toEqual({ 59 + type: "object", 60 + properties: { 61 + did: { type: "string", required: true, format: "did" }, 62 + handle: { type: "string", required: true, format: "handle" }, 63 + displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 64 + pronouns: { type: "string" }, 65 + description: { type: "string", maxGraphemes: 256, maxLength: 2560 }, 66 + avatar: { type: "string", format: "uri" }, 67 + associated: { type: "ref", ref: "#profileAssociated" }, 68 + indexedAt: { type: "string", format: "datetime" }, 69 + createdAt: { type: "string", format: "datetime" }, 70 + viewer: { type: "ref", ref: "#viewerState" }, 71 + labels: { 72 + type: "array", 73 + items: { type: "ref", ref: "com.atproto.label.defs#label" }, 74 + }, 75 + verification: { type: "ref", ref: "#verificationState" }, 76 + status: { type: "ref", ref: "#statusView" }, 77 + }, 78 + required: ["did", "handle"], 79 + }); 80 + }); 81 + 82 + test("app.bsky.actor.defs - profileViewDetailed", () => { 83 + const profileViewDetailed = lx.object({ 84 + did: lx.string({ required: true, format: "did" }), 85 + handle: lx.string({ required: true, format: "handle" }), 86 + displayName: lx.string({ maxGraphemes: 64, maxLength: 640 }), 87 + description: lx.string({ maxGraphemes: 256, maxLength: 2560 }), 88 + pronouns: lx.string(), 89 + website: lx.string({ format: "uri" }), 90 + avatar: lx.string({ format: "uri" }), 91 + banner: lx.string({ format: "uri" }), 92 + followersCount: lx.integer(), 93 + followsCount: lx.integer(), 94 + postsCount: lx.integer(), 95 + associated: lx.ref("#profileAssociated"), 96 + joinedViaStarterPack: lx.ref("app.bsky.graph.defs#starterPackViewBasic"), 97 + indexedAt: lx.string({ format: "datetime" }), 98 + createdAt: lx.string({ format: "datetime" }), 99 + viewer: lx.ref("#viewerState"), 100 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 101 + pinnedPost: lx.ref("com.atproto.repo.strongRef"), 102 + verification: lx.ref("#verificationState"), 103 + status: lx.ref("#statusView"), 104 + }); 105 + 106 + expect(profileViewDetailed).toEqual({ 107 + type: "object", 108 + properties: { 109 + did: { type: "string", required: true, format: "did" }, 110 + handle: { type: "string", required: true, format: "handle" }, 111 + displayName: { type: "string", maxGraphemes: 64, maxLength: 640 }, 112 + description: { type: "string", maxGraphemes: 256, maxLength: 2560 }, 113 + pronouns: { type: "string" }, 114 + website: { type: "string", format: "uri" }, 115 + avatar: { type: "string", format: "uri" }, 116 + banner: { type: "string", format: "uri" }, 117 + followersCount: { type: "integer" }, 118 + followsCount: { type: "integer" }, 119 + postsCount: { type: "integer" }, 120 + associated: { type: "ref", ref: "#profileAssociated" }, 121 + joinedViaStarterPack: { 122 + type: "ref", 123 + ref: "app.bsky.graph.defs#starterPackViewBasic", 124 + }, 125 + indexedAt: { type: "string", format: "datetime" }, 126 + createdAt: { type: "string", format: "datetime" }, 127 + viewer: { type: "ref", ref: "#viewerState" }, 128 + labels: { 129 + type: "array", 130 + items: { type: "ref", ref: "com.atproto.label.defs#label" }, 131 + }, 132 + pinnedPost: { type: "ref", ref: "com.atproto.repo.strongRef" }, 133 + verification: { type: "ref", ref: "#verificationState" }, 134 + status: { type: "ref", ref: "#statusView" }, 135 + }, 136 + required: ["did", "handle"], 137 + }); 138 + }); 139 + 140 + test("app.bsky.actor.defs - profileAssociated", () => { 141 + const profileAssociated = lx.object({ 142 + lists: lx.integer(), 143 + feedgens: lx.integer(), 144 + starterPacks: lx.integer(), 145 + labeler: lx.boolean(), 146 + chat: lx.ref("#profileAssociatedChat"), 147 + activitySubscription: lx.ref("#profileAssociatedActivitySubscription"), 148 + }); 149 + 150 + expect(profileAssociated).toEqual({ 151 + type: "object", 152 + properties: { 153 + lists: { type: "integer" }, 154 + feedgens: { type: "integer" }, 155 + starterPacks: { type: "integer" }, 156 + labeler: { type: "boolean" }, 157 + chat: { type: "ref", ref: "#profileAssociatedChat" }, 158 + activitySubscription: { 159 + type: "ref", 160 + ref: "#profileAssociatedActivitySubscription", 161 + }, 162 + }, 163 + }); 164 + }); 165 + 166 + test("app.bsky.actor.defs - profileAssociatedChat", () => { 167 + const profileAssociatedChat = lx.object({ 168 + allowIncoming: lx.string({ 169 + required: true, 170 + knownValues: ["all", "none", "following"], 171 + }), 172 + }); 173 + 174 + expect(profileAssociatedChat).toEqual({ 175 + type: "object", 176 + properties: { 177 + allowIncoming: { 178 + type: "string", 179 + required: true, 180 + knownValues: ["all", "none", "following"], 181 + }, 182 + }, 183 + required: ["allowIncoming"], 184 + }); 185 + }); 186 + 187 + test("app.bsky.actor.defs - profileAssociatedActivitySubscription", () => { 188 + const profileAssociatedActivitySubscription = lx.object({ 189 + allowSubscriptions: lx.string({ 190 + required: true, 191 + knownValues: ["followers", "mutuals", "none"], 192 + }), 193 + }); 194 + 195 + expect(profileAssociatedActivitySubscription).toEqual({ 196 + type: "object", 197 + properties: { 198 + allowSubscriptions: { 199 + type: "string", 200 + required: true, 201 + knownValues: ["followers", "mutuals", "none"], 202 + }, 203 + }, 204 + required: ["allowSubscriptions"], 205 + }); 206 + }); 207 + 208 + test("app.bsky.actor.defs - viewerState", () => { 209 + const viewerState = lx.object({ 210 + muted: lx.boolean(), 211 + mutedByList: lx.ref("app.bsky.graph.defs#listViewBasic"), 212 + blockedBy: lx.boolean(), 213 + blocking: lx.string({ format: "at-uri" }), 214 + blockingByList: lx.ref("app.bsky.graph.defs#listViewBasic"), 215 + following: lx.string({ format: "at-uri" }), 216 + followedBy: lx.string({ format: "at-uri" }), 217 + knownFollowers: lx.ref("#knownFollowers"), 218 + activitySubscription: lx.ref( 219 + "app.bsky.notification.defs#activitySubscription", 220 + ), 221 + }); 222 + 223 + expect(viewerState).toEqual({ 224 + type: "object", 225 + properties: { 226 + muted: { type: "boolean" }, 227 + mutedByList: { type: "ref", ref: "app.bsky.graph.defs#listViewBasic" }, 228 + blockedBy: { type: "boolean" }, 229 + blocking: { type: "string", format: "at-uri" }, 230 + blockingByList: { type: "ref", ref: "app.bsky.graph.defs#listViewBasic" }, 231 + following: { type: "string", format: "at-uri" }, 232 + followedBy: { type: "string", format: "at-uri" }, 233 + knownFollowers: { type: "ref", ref: "#knownFollowers" }, 234 + activitySubscription: { 235 + type: "ref", 236 + ref: "app.bsky.notification.defs#activitySubscription", 237 + }, 238 + }, 239 + }); 240 + }); 241 + 242 + test("app.bsky.actor.defs - knownFollowers", () => { 243 + const knownFollowers = lx.object({ 244 + count: lx.integer({ required: true }), 245 + followers: lx.array(lx.ref("#profileViewBasic"), { 246 + required: true, 247 + minLength: 0, 248 + maxLength: 5, 249 + }), 250 + }); 251 + 252 + expect(knownFollowers).toEqual({ 253 + type: "object", 254 + properties: { 255 + count: { type: "integer", required: true }, 256 + followers: { 257 + type: "array", 258 + items: { type: "ref", ref: "#profileViewBasic" }, 259 + required: true, 260 + minLength: 0, 261 + maxLength: 5, 262 + }, 263 + }, 264 + required: ["count", "followers"], 265 + }); 266 + }); 267 + 268 + test("app.bsky.actor.defs - verificationState", () => { 269 + const verificationState = lx.object({ 270 + verifications: lx.array(lx.ref("#verificationView"), { required: true }), 271 + verifiedStatus: lx.string({ 272 + required: true, 273 + knownValues: ["valid", "invalid", "none"], 274 + }), 275 + trustedVerifierStatus: lx.string({ 276 + required: true, 277 + knownValues: ["valid", "invalid", "none"], 278 + }), 279 + }); 280 + 281 + expect(verificationState).toEqual({ 282 + type: "object", 283 + properties: { 284 + verifications: { 285 + type: "array", 286 + items: { type: "ref", ref: "#verificationView" }, 287 + required: true, 288 + }, 289 + verifiedStatus: { 290 + type: "string", 291 + required: true, 292 + knownValues: ["valid", "invalid", "none"], 293 + }, 294 + trustedVerifierStatus: { 295 + type: "string", 296 + required: true, 297 + knownValues: ["valid", "invalid", "none"], 298 + }, 299 + }, 300 + required: ["verifications", "verifiedStatus", "trustedVerifierStatus"], 301 + }); 302 + }); 303 + 304 + test("app.bsky.actor.defs - verificationView", () => { 305 + const verificationView = lx.object({ 306 + issuer: lx.string({ required: true, format: "did" }), 307 + uri: lx.string({ required: true, format: "at-uri" }), 308 + isValid: lx.boolean({ required: true }), 309 + createdAt: lx.string({ required: true, format: "datetime" }), 310 + }); 311 + 312 + expect(verificationView).toEqual({ 313 + type: "object", 314 + properties: { 315 + issuer: { type: "string", required: true, format: "did" }, 316 + uri: { type: "string", required: true, format: "at-uri" }, 317 + isValid: { type: "boolean", required: true }, 318 + createdAt: { type: "string", required: true, format: "datetime" }, 319 + }, 320 + required: ["issuer", "uri", "isValid", "createdAt"], 321 + }); 322 + }); 323 + 324 + test("app.bsky.actor.defs - preferences", () => { 325 + const preferences = lx.array( 326 + lx.union([ 327 + "#adultContentPref", 328 + "#contentLabelPref", 329 + "#savedFeedsPref", 330 + "#savedFeedsPrefV2", 331 + "#personalDetailsPref", 332 + "#feedViewPref", 333 + "#threadViewPref", 334 + "#interestsPref", 335 + "#mutedWordsPref", 336 + "#hiddenPostsPref", 337 + "#bskyAppStatePref", 338 + "#labelersPref", 339 + "#postInteractionSettingsPref", 340 + "#verificationPrefs", 341 + ]), 342 + ); 343 + 344 + expect(preferences).toEqual({ 345 + type: "array", 346 + items: { 347 + type: "union", 348 + refs: [ 349 + "#adultContentPref", 350 + "#contentLabelPref", 351 + "#savedFeedsPref", 352 + "#savedFeedsPrefV2", 353 + "#personalDetailsPref", 354 + "#feedViewPref", 355 + "#threadViewPref", 356 + "#interestsPref", 357 + "#mutedWordsPref", 358 + "#hiddenPostsPref", 359 + "#bskyAppStatePref", 360 + "#labelersPref", 361 + "#postInteractionSettingsPref", 362 + "#verificationPrefs", 363 + ], 364 + }, 365 + }); 366 + }); 367 + 368 + test("app.bsky.actor.defs - adultContentPref", () => { 369 + const adultContentPref = lx.object({ 370 + enabled: lx.boolean({ required: true, default: false }), 371 + }); 372 + 373 + expect(adultContentPref).toEqual({ 374 + type: "object", 375 + properties: { 376 + enabled: { type: "boolean", required: true, default: false }, 377 + }, 378 + required: ["enabled"], 379 + }); 380 + }); 381 + 382 + test("app.bsky.actor.defs - contentLabelPref", () => { 383 + const contentLabelPref = lx.object({ 384 + labelerDid: lx.string({ format: "did" }), 385 + label: lx.string({ required: true }), 386 + visibility: lx.string({ 387 + required: true, 388 + knownValues: ["ignore", "show", "warn", "hide"], 389 + }), 390 + }); 391 + 392 + expect(contentLabelPref).toEqual({ 393 + type: "object", 394 + properties: { 395 + labelerDid: { type: "string", format: "did" }, 396 + label: { type: "string", required: true }, 397 + visibility: { 398 + type: "string", 399 + required: true, 400 + knownValues: ["ignore", "show", "warn", "hide"], 401 + }, 402 + }, 403 + required: ["label", "visibility"], 404 + }); 405 + }); 406 + 407 + test("app.bsky.actor.defs - savedFeed", () => { 408 + const savedFeed = lx.object({ 409 + id: lx.string({ required: true }), 410 + type: lx.string({ 411 + required: true, 412 + knownValues: ["feed", "list", "timeline"], 413 + }), 414 + value: lx.string({ required: true }), 415 + pinned: lx.boolean({ required: true }), 416 + }); 417 + 418 + expect(savedFeed).toEqual({ 419 + type: "object", 420 + properties: { 421 + id: { type: "string", required: true }, 422 + type: { 423 + type: "string", 424 + required: true, 425 + knownValues: ["feed", "list", "timeline"], 426 + }, 427 + value: { type: "string", required: true }, 428 + pinned: { type: "boolean", required: true }, 429 + }, 430 + required: ["id", "type", "value", "pinned"], 431 + }); 432 + }); 433 + 434 + test("app.bsky.actor.defs - savedFeedsPrefV2", () => { 435 + const savedFeedsPrefV2 = lx.object({ 436 + items: lx.array(lx.ref("app.bsky.actor.defs#savedFeed"), { 437 + required: true, 438 + }), 439 + }); 440 + 441 + expect(savedFeedsPrefV2).toEqual({ 442 + type: "object", 443 + properties: { 444 + items: { 445 + type: "array", 446 + items: { type: "ref", ref: "app.bsky.actor.defs#savedFeed" }, 447 + required: true, 448 + }, 449 + }, 450 + required: ["items"], 451 + }); 452 + }); 453 + 454 + test("app.bsky.actor.defs - savedFeedsPref", () => { 455 + const savedFeedsPref = lx.object({ 456 + pinned: lx.array(lx.string({ format: "at-uri" }), { required: true }), 457 + saved: lx.array(lx.string({ format: "at-uri" }), { required: true }), 458 + timelineIndex: lx.integer(), 459 + }); 460 + 461 + expect(savedFeedsPref).toEqual({ 462 + type: "object", 463 + properties: { 464 + pinned: { 465 + type: "array", 466 + items: { type: "string", format: "at-uri" }, 467 + required: true, 468 + }, 469 + saved: { 470 + type: "array", 471 + items: { type: "string", format: "at-uri" }, 472 + required: true, 473 + }, 474 + timelineIndex: { type: "integer" }, 475 + }, 476 + required: ["pinned", "saved"], 477 + }); 478 + }); 479 + 480 + test("app.bsky.actor.defs - personalDetailsPref", () => { 481 + const personalDetailsPref = lx.object({ 482 + birthDate: lx.string({ format: "datetime" }), 483 + }); 484 + 485 + expect(personalDetailsPref).toEqual({ 486 + type: "object", 487 + properties: { 488 + birthDate: { type: "string", format: "datetime" }, 489 + }, 490 + }); 491 + }); 492 + 493 + test("app.bsky.actor.defs - feedViewPref", () => { 494 + const feedViewPref = lx.object({ 495 + feed: lx.string({ required: true }), 496 + hideReplies: lx.boolean(), 497 + hideRepliesByUnfollowed: lx.boolean({ default: true }), 498 + hideRepliesByLikeCount: lx.integer(), 499 + hideReposts: lx.boolean(), 500 + hideQuotePosts: lx.boolean(), 501 + }); 502 + 503 + expect(feedViewPref).toEqual({ 504 + type: "object", 505 + properties: { 506 + feed: { type: "string", required: true }, 507 + hideReplies: { type: "boolean" }, 508 + hideRepliesByUnfollowed: { type: "boolean", default: true }, 509 + hideRepliesByLikeCount: { type: "integer" }, 510 + hideReposts: { type: "boolean" }, 511 + hideQuotePosts: { type: "boolean" }, 512 + }, 513 + required: ["feed"], 514 + }); 515 + }); 516 + 517 + test("app.bsky.actor.defs - threadViewPref", () => { 518 + const threadViewPref = lx.object({ 519 + sort: lx.string({ 520 + knownValues: ["oldest", "newest", "most-likes", "random", "hotness"], 521 + }), 522 + prioritizeFollowedUsers: lx.boolean(), 523 + }); 524 + 525 + expect(threadViewPref).toEqual({ 526 + type: "object", 527 + properties: { 528 + sort: { 529 + type: "string", 530 + knownValues: ["oldest", "newest", "most-likes", "random", "hotness"], 531 + }, 532 + prioritizeFollowedUsers: { type: "boolean" }, 533 + }, 534 + }); 535 + }); 536 + 537 + test("app.bsky.actor.defs - interestsPref", () => { 538 + const interestsPref = lx.object({ 539 + tags: lx.array(lx.string({ maxLength: 640, maxGraphemes: 64 }), { 540 + required: true, 541 + maxLength: 100, 542 + }), 543 + }); 544 + 545 + expect(interestsPref).toEqual({ 546 + type: "object", 547 + properties: { 548 + tags: { 549 + type: "array", 550 + items: { type: "string", maxLength: 640, maxGraphemes: 64 }, 551 + required: true, 552 + maxLength: 100, 553 + }, 554 + }, 555 + required: ["tags"], 556 + }); 557 + }); 558 + 559 + test("app.bsky.actor.defs - mutedWordTarget", () => { 560 + const mutedWordTarget = lx.string({ 561 + knownValues: ["content", "tag"], 562 + maxLength: 640, 563 + maxGraphemes: 64, 564 + }); 565 + 566 + expect(mutedWordTarget).toEqual({ 567 + type: "string", 568 + knownValues: ["content", "tag"], 569 + maxLength: 640, 570 + maxGraphemes: 64, 571 + }); 572 + }); 573 + 574 + test("app.bsky.actor.defs - mutedWord", () => { 575 + const mutedWord = lx.object({ 576 + id: lx.string(), 577 + value: lx.string({ required: true, maxLength: 10000, maxGraphemes: 1000 }), 578 + targets: lx.array(lx.ref("app.bsky.actor.defs#mutedWordTarget"), { 579 + required: true, 580 + }), 581 + actorTarget: lx.string({ 582 + knownValues: ["all", "exclude-following"], 583 + default: "all", 584 + }), 585 + expiresAt: lx.string({ format: "datetime" }), 586 + }); 587 + 588 + expect(mutedWord).toEqual({ 589 + type: "object", 590 + properties: { 591 + id: { type: "string" }, 592 + value: { 593 + type: "string", 594 + required: true, 595 + maxLength: 10000, 596 + maxGraphemes: 1000, 597 + }, 598 + targets: { 599 + type: "array", 600 + items: { type: "ref", ref: "app.bsky.actor.defs#mutedWordTarget" }, 601 + required: true, 602 + }, 603 + actorTarget: { 604 + type: "string", 605 + knownValues: ["all", "exclude-following"], 606 + default: "all", 607 + }, 608 + expiresAt: { type: "string", format: "datetime" }, 609 + }, 610 + required: ["value", "targets"], 611 + }); 612 + }); 613 + 614 + test("app.bsky.actor.defs - mutedWordsPref", () => { 615 + const mutedWordsPref = lx.object({ 616 + items: lx.array(lx.ref("app.bsky.actor.defs#mutedWord"), { 617 + required: true, 618 + }), 619 + }); 620 + 621 + expect(mutedWordsPref).toEqual({ 622 + type: "object", 623 + properties: { 624 + items: { 625 + type: "array", 626 + items: { type: "ref", ref: "app.bsky.actor.defs#mutedWord" }, 627 + required: true, 628 + }, 629 + }, 630 + required: ["items"], 631 + }); 632 + }); 633 + 634 + test("app.bsky.actor.defs - hiddenPostsPref", () => { 635 + const hiddenPostsPref = lx.object({ 636 + items: lx.array(lx.string({ format: "at-uri" }), { required: true }), 637 + }); 638 + 639 + expect(hiddenPostsPref).toEqual({ 640 + type: "object", 641 + properties: { 642 + items: { 643 + type: "array", 644 + items: { type: "string", format: "at-uri" }, 645 + required: true, 646 + }, 647 + }, 648 + required: ["items"], 649 + }); 650 + }); 651 + 652 + test("app.bsky.actor.defs - labelersPref", () => { 653 + const labelersPref = lx.object({ 654 + labelers: lx.array(lx.ref("#labelerPrefItem"), { required: true }), 655 + }); 656 + 657 + expect(labelersPref).toEqual({ 658 + type: "object", 659 + properties: { 660 + labelers: { 661 + type: "array", 662 + items: { type: "ref", ref: "#labelerPrefItem" }, 663 + required: true, 664 + }, 665 + }, 666 + required: ["labelers"], 667 + }); 668 + }); 669 + 670 + test("app.bsky.actor.defs - labelerPrefItem", () => { 671 + const labelerPrefItem = lx.object({ 672 + did: lx.string({ required: true, format: "did" }), 673 + }); 674 + 675 + expect(labelerPrefItem).toEqual({ 676 + type: "object", 677 + properties: { 678 + did: { type: "string", required: true, format: "did" }, 679 + }, 680 + required: ["did"], 681 + }); 682 + }); 683 + 684 + test("app.bsky.actor.defs - bskyAppStatePref", () => { 685 + const bskyAppStatePref = lx.object({ 686 + activeProgressGuide: lx.ref("#bskyAppProgressGuide"), 687 + queuedNudges: lx.array(lx.string({ maxLength: 100 }), { maxLength: 1000 }), 688 + nuxs: lx.array(lx.ref("app.bsky.actor.defs#nux"), { maxLength: 100 }), 689 + }); 690 + 691 + expect(bskyAppStatePref).toEqual({ 692 + type: "object", 693 + properties: { 694 + activeProgressGuide: { type: "ref", ref: "#bskyAppProgressGuide" }, 695 + queuedNudges: { 696 + type: "array", 697 + items: { type: "string", maxLength: 100 }, 698 + maxLength: 1000, 699 + }, 700 + nuxs: { 701 + type: "array", 702 + items: { type: "ref", ref: "app.bsky.actor.defs#nux" }, 703 + maxLength: 100, 704 + }, 705 + }, 706 + }); 707 + }); 708 + 709 + test("app.bsky.actor.defs - bskyAppProgressGuide", () => { 710 + const bskyAppProgressGuide = lx.object({ 711 + guide: lx.string({ required: true, maxLength: 100 }), 712 + }); 713 + 714 + expect(bskyAppProgressGuide).toEqual({ 715 + type: "object", 716 + properties: { 717 + guide: { type: "string", required: true, maxLength: 100 }, 718 + }, 719 + required: ["guide"], 720 + }); 721 + }); 722 + 723 + test("app.bsky.actor.defs - nux", () => { 724 + const nux = lx.object({ 725 + id: lx.string({ required: true, maxLength: 100 }), 726 + completed: lx.boolean({ required: true, default: false }), 727 + data: lx.string({ maxLength: 3000, maxGraphemes: 300 }), 728 + expiresAt: lx.string({ format: "datetime" }), 729 + }); 730 + 731 + expect(nux).toEqual({ 732 + type: "object", 733 + properties: { 734 + id: { type: "string", required: true, maxLength: 100 }, 735 + completed: { type: "boolean", required: true, default: false }, 736 + data: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 737 + expiresAt: { type: "string", format: "datetime" }, 738 + }, 739 + required: ["id", "completed"], 740 + }); 741 + }); 742 + 743 + test("app.bsky.actor.defs - verificationPrefs", () => { 744 + const verificationPrefs = lx.object({ 745 + hideBadges: lx.boolean({ default: false }), 746 + }); 747 + 748 + expect(verificationPrefs).toEqual({ 749 + type: "object", 750 + properties: { 751 + hideBadges: { type: "boolean", default: false }, 752 + }, 753 + }); 754 + }); 755 + 756 + test("app.bsky.actor.defs - postInteractionSettingsPref", () => { 757 + const postInteractionSettingsPref = lx.object({ 758 + threadgateAllowRules: lx.array( 759 + lx.union([ 760 + "app.bsky.feed.threadgate#mentionRule", 761 + "app.bsky.feed.threadgate#followerRule", 762 + "app.bsky.feed.threadgate#followingRule", 763 + "app.bsky.feed.threadgate#listRule", 764 + ]), 765 + { maxLength: 5 }, 766 + ), 767 + postgateEmbeddingRules: lx.array( 768 + lx.union(["app.bsky.feed.postgate#disableRule"]), 769 + { maxLength: 5 }, 770 + ), 771 + }); 772 + 773 + expect(postInteractionSettingsPref).toEqual({ 774 + type: "object", 775 + properties: { 776 + threadgateAllowRules: { 777 + type: "array", 778 + items: { 779 + type: "union", 780 + refs: [ 781 + "app.bsky.feed.threadgate#mentionRule", 782 + "app.bsky.feed.threadgate#followerRule", 783 + "app.bsky.feed.threadgate#followingRule", 784 + "app.bsky.feed.threadgate#listRule", 785 + ], 786 + }, 787 + maxLength: 5, 788 + }, 789 + postgateEmbeddingRules: { 790 + type: "array", 791 + items: { 792 + type: "union", 793 + refs: ["app.bsky.feed.postgate#disableRule"], 794 + }, 795 + maxLength: 5, 796 + }, 797 + }, 798 + }); 799 + }); 800 + 801 + test("app.bsky.actor.defs - statusView", () => { 802 + const statusView = lx.object({ 803 + status: lx.string({ 804 + required: true, 805 + knownValues: ["app.bsky.actor.status#live"], 806 + }), 807 + record: lx.unknown({ required: true }), 808 + embed: lx.union(["app.bsky.embed.external#view"]), 809 + expiresAt: lx.string({ format: "datetime" }), 810 + isActive: lx.boolean(), 811 + }); 812 + 813 + expect(statusView).toEqual({ 814 + type: "object", 815 + properties: { 816 + status: { 817 + type: "string", 818 + required: true, 819 + knownValues: ["app.bsky.actor.status#live"], 820 + }, 821 + record: { type: "unknown", required: true }, 822 + embed: { 823 + type: "union", 824 + refs: ["app.bsky.embed.external#view"], 825 + }, 826 + expiresAt: { type: "string", format: "datetime" }, 827 + isActive: { type: "boolean" }, 828 + }, 829 + required: ["status", "record"], 830 + }); 831 + }); 832 + 833 + test("app.bsky.actor.defs - full namespace", () => { 834 + const actorDefs = lx.namespace("app.bsky.actor.defs", { 835 + profileViewBasic: lx.object({ 836 + did: lx.string({ required: true, format: "did" }), 837 + handle: lx.string({ required: true, format: "handle" }), 838 + displayName: lx.string({ maxGraphemes: 64, maxLength: 640 }), 839 + pronouns: lx.string(), 840 + avatar: lx.string({ format: "uri" }), 841 + associated: lx.ref("#profileAssociated"), 842 + viewer: lx.ref("#viewerState"), 843 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 844 + createdAt: lx.string({ format: "datetime" }), 845 + verification: lx.ref("#verificationState"), 846 + status: lx.ref("#statusView"), 847 + }), 848 + viewerState: lx.object({ 849 + muted: lx.boolean(), 850 + mutedByList: lx.ref("app.bsky.graph.defs#listViewBasic"), 851 + blockedBy: lx.boolean(), 852 + blocking: lx.string({ format: "at-uri" }), 853 + blockingByList: lx.ref("app.bsky.graph.defs#listViewBasic"), 854 + following: lx.string({ format: "at-uri" }), 855 + followedBy: lx.string({ format: "at-uri" }), 856 + knownFollowers: lx.ref("#knownFollowers"), 857 + activitySubscription: lx.ref( 858 + "app.bsky.notification.defs#activitySubscription", 859 + ), 860 + }), 861 + }); 862 + 863 + expect(actorDefs.json.lexicon).toEqual(1); 864 + expect(actorDefs.json.id).toEqual("app.bsky.actor.defs"); 865 + expect(actorDefs.json.defs.profileViewBasic.type).toEqual("object"); 866 + expect(actorDefs.json.defs.viewerState.type).toEqual("object"); 867 + });
+681
packages/prototypey/tests/bsky-feed.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + test("app.bsky.feed.defs - postView", () => { 5 + const postView = lx.object({ 6 + uri: lx.string({ required: true, format: "at-uri" }), 7 + cid: lx.string({ required: true, format: "cid" }), 8 + author: lx.ref("app.bsky.actor.defs#profileViewBasic", { required: true }), 9 + record: lx.unknown({ required: true }), 10 + embed: lx.union([ 11 + "app.bsky.embed.images#view", 12 + "app.bsky.embed.video#view", 13 + "app.bsky.embed.external#view", 14 + "app.bsky.embed.record#view", 15 + "app.bsky.embed.recordWithMedia#view", 16 + ]), 17 + bookmarkCount: lx.integer(), 18 + replyCount: lx.integer(), 19 + repostCount: lx.integer(), 20 + likeCount: lx.integer(), 21 + quoteCount: lx.integer(), 22 + indexedAt: lx.string({ required: true, format: "datetime" }), 23 + viewer: lx.ref("#viewerState"), 24 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 25 + threadgate: lx.ref("#threadgateView"), 26 + }); 27 + 28 + expect(postView).toEqual({ 29 + type: "object", 30 + properties: { 31 + uri: { type: "string", required: true, format: "at-uri" }, 32 + cid: { type: "string", required: true, format: "cid" }, 33 + author: { 34 + type: "ref", 35 + ref: "app.bsky.actor.defs#profileViewBasic", 36 + required: true, 37 + }, 38 + record: { type: "unknown", required: true }, 39 + embed: { 40 + type: "union", 41 + refs: [ 42 + "app.bsky.embed.images#view", 43 + "app.bsky.embed.video#view", 44 + "app.bsky.embed.external#view", 45 + "app.bsky.embed.record#view", 46 + "app.bsky.embed.recordWithMedia#view", 47 + ], 48 + }, 49 + bookmarkCount: { type: "integer" }, 50 + replyCount: { type: "integer" }, 51 + repostCount: { type: "integer" }, 52 + likeCount: { type: "integer" }, 53 + quoteCount: { type: "integer" }, 54 + indexedAt: { type: "string", required: true, format: "datetime" }, 55 + viewer: { type: "ref", ref: "#viewerState" }, 56 + labels: { 57 + type: "array", 58 + items: { type: "ref", ref: "com.atproto.label.defs#label" }, 59 + }, 60 + threadgate: { type: "ref", ref: "#threadgateView" }, 61 + }, 62 + required: ["uri", "cid", "author", "record", "indexedAt"], 63 + }); 64 + }); 65 + 66 + test("app.bsky.feed.defs - viewerState", () => { 67 + const viewerState = lx.object({ 68 + repost: lx.string({ format: "at-uri" }), 69 + like: lx.string({ format: "at-uri" }), 70 + bookmarked: lx.boolean(), 71 + threadMuted: lx.boolean(), 72 + replyDisabled: lx.boolean(), 73 + embeddingDisabled: lx.boolean(), 74 + pinned: lx.boolean(), 75 + }); 76 + 77 + expect(viewerState).toEqual({ 78 + type: "object", 79 + properties: { 80 + repost: { type: "string", format: "at-uri" }, 81 + like: { type: "string", format: "at-uri" }, 82 + bookmarked: { type: "boolean" }, 83 + threadMuted: { type: "boolean" }, 84 + replyDisabled: { type: "boolean" }, 85 + embeddingDisabled: { type: "boolean" }, 86 + pinned: { type: "boolean" }, 87 + }, 88 + }); 89 + }); 90 + 91 + test("app.bsky.feed.defs - threadContext", () => { 92 + const threadContext = lx.object({ 93 + rootAuthorLike: lx.string({ format: "at-uri" }), 94 + }); 95 + 96 + expect(threadContext).toEqual({ 97 + type: "object", 98 + properties: { 99 + rootAuthorLike: { type: "string", format: "at-uri" }, 100 + }, 101 + }); 102 + }); 103 + 104 + test("app.bsky.feed.defs - feedViewPost", () => { 105 + const feedViewPost = lx.object({ 106 + post: lx.ref("#postView", { required: true }), 107 + reply: lx.ref("#replyRef"), 108 + reason: lx.union(["#reasonRepost", "#reasonPin"]), 109 + feedContext: lx.string({ maxLength: 2000 }), 110 + reqId: lx.string({ maxLength: 100 }), 111 + }); 112 + 113 + expect(feedViewPost).toEqual({ 114 + type: "object", 115 + properties: { 116 + post: { type: "ref", ref: "#postView", required: true }, 117 + reply: { type: "ref", ref: "#replyRef" }, 118 + reason: { 119 + type: "union", 120 + refs: ["#reasonRepost", "#reasonPin"], 121 + }, 122 + feedContext: { type: "string", maxLength: 2000 }, 123 + reqId: { type: "string", maxLength: 100 }, 124 + }, 125 + required: ["post"], 126 + }); 127 + }); 128 + 129 + test("app.bsky.feed.defs - replyRef", () => { 130 + const replyRef = lx.object({ 131 + root: lx.union(["#postView", "#notFoundPost", "#blockedPost"], { 132 + required: true, 133 + }), 134 + parent: lx.union(["#postView", "#notFoundPost", "#blockedPost"], { 135 + required: true, 136 + }), 137 + grandparentAuthor: lx.ref("app.bsky.actor.defs#profileViewBasic"), 138 + }); 139 + 140 + expect(replyRef).toEqual({ 141 + type: "object", 142 + properties: { 143 + root: { 144 + type: "union", 145 + refs: ["#postView", "#notFoundPost", "#blockedPost"], 146 + required: true, 147 + }, 148 + parent: { 149 + type: "union", 150 + refs: ["#postView", "#notFoundPost", "#blockedPost"], 151 + required: true, 152 + }, 153 + grandparentAuthor: { 154 + type: "ref", 155 + ref: "app.bsky.actor.defs#profileViewBasic", 156 + }, 157 + }, 158 + required: ["root", "parent"], 159 + }); 160 + }); 161 + 162 + test("app.bsky.feed.defs - reasonRepost", () => { 163 + const reasonRepost = lx.object({ 164 + by: lx.ref("app.bsky.actor.defs#profileViewBasic", { required: true }), 165 + uri: lx.string({ format: "at-uri" }), 166 + cid: lx.string({ format: "cid" }), 167 + indexedAt: lx.string({ required: true, format: "datetime" }), 168 + }); 169 + 170 + expect(reasonRepost).toEqual({ 171 + type: "object", 172 + properties: { 173 + by: { 174 + type: "ref", 175 + ref: "app.bsky.actor.defs#profileViewBasic", 176 + required: true, 177 + }, 178 + uri: { type: "string", format: "at-uri" }, 179 + cid: { type: "string", format: "cid" }, 180 + indexedAt: { type: "string", required: true, format: "datetime" }, 181 + }, 182 + required: ["by", "indexedAt"], 183 + }); 184 + }); 185 + 186 + test("app.bsky.feed.defs - reasonPin", () => { 187 + const reasonPin = lx.object({}); 188 + 189 + expect(reasonPin).toEqual({ 190 + type: "object", 191 + properties: {}, 192 + }); 193 + }); 194 + 195 + test("app.bsky.feed.defs - threadViewPost", () => { 196 + const threadViewPost = lx.object({ 197 + post: lx.ref("#postView", { required: true }), 198 + parent: lx.union(["#threadViewPost", "#notFoundPost", "#blockedPost"]), 199 + replies: lx.array( 200 + lx.union(["#threadViewPost", "#notFoundPost", "#blockedPost"]), 201 + ), 202 + threadContext: lx.ref("#threadContext"), 203 + }); 204 + 205 + expect(threadViewPost).toEqual({ 206 + type: "object", 207 + properties: { 208 + post: { type: "ref", ref: "#postView", required: true }, 209 + parent: { 210 + type: "union", 211 + refs: ["#threadViewPost", "#notFoundPost", "#blockedPost"], 212 + }, 213 + replies: { 214 + type: "array", 215 + items: { 216 + type: "union", 217 + refs: ["#threadViewPost", "#notFoundPost", "#blockedPost"], 218 + }, 219 + }, 220 + threadContext: { type: "ref", ref: "#threadContext" }, 221 + }, 222 + required: ["post"], 223 + }); 224 + }); 225 + 226 + test("app.bsky.feed.defs - notFoundPost", () => { 227 + const notFoundPost = lx.object({ 228 + uri: lx.string({ required: true, format: "at-uri" }), 229 + notFound: lx.boolean({ required: true, const: true }), 230 + }); 231 + 232 + expect(notFoundPost).toEqual({ 233 + type: "object", 234 + properties: { 235 + uri: { type: "string", required: true, format: "at-uri" }, 236 + notFound: { type: "boolean", required: true, const: true }, 237 + }, 238 + required: ["uri", "notFound"], 239 + }); 240 + }); 241 + 242 + test("app.bsky.feed.defs - blockedPost", () => { 243 + const blockedPost = lx.object({ 244 + uri: lx.string({ required: true, format: "at-uri" }), 245 + blocked: lx.boolean({ required: true, const: true }), 246 + author: lx.ref("#blockedAuthor", { required: true }), 247 + }); 248 + 249 + expect(blockedPost).toEqual({ 250 + type: "object", 251 + properties: { 252 + uri: { type: "string", required: true, format: "at-uri" }, 253 + blocked: { type: "boolean", required: true, const: true }, 254 + author: { type: "ref", ref: "#blockedAuthor", required: true }, 255 + }, 256 + required: ["uri", "blocked", "author"], 257 + }); 258 + }); 259 + 260 + test("app.bsky.feed.defs - blockedAuthor", () => { 261 + const blockedAuthor = lx.object({ 262 + did: lx.string({ required: true, format: "did" }), 263 + viewer: lx.ref("app.bsky.actor.defs#viewerState"), 264 + }); 265 + 266 + expect(blockedAuthor).toEqual({ 267 + type: "object", 268 + properties: { 269 + did: { type: "string", required: true, format: "did" }, 270 + viewer: { type: "ref", ref: "app.bsky.actor.defs#viewerState" }, 271 + }, 272 + required: ["did"], 273 + }); 274 + }); 275 + 276 + test("app.bsky.feed.defs - generatorView", () => { 277 + const generatorView = lx.object({ 278 + uri: lx.string({ required: true, format: "at-uri" }), 279 + cid: lx.string({ required: true, format: "cid" }), 280 + did: lx.string({ required: true, format: "did" }), 281 + creator: lx.ref("app.bsky.actor.defs#profileView", { required: true }), 282 + displayName: lx.string({ required: true }), 283 + description: lx.string({ maxGraphemes: 300, maxLength: 3000 }), 284 + descriptionFacets: lx.array(lx.ref("app.bsky.richtext.facet")), 285 + avatar: lx.string({ format: "uri" }), 286 + likeCount: lx.integer({ minimum: 0 }), 287 + acceptsInteractions: lx.boolean(), 288 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 289 + viewer: lx.ref("#generatorViewerState"), 290 + contentMode: lx.string({ 291 + knownValues: [ 292 + "app.bsky.feed.defs#contentModeUnspecified", 293 + "app.bsky.feed.defs#contentModeVideo", 294 + ], 295 + }), 296 + indexedAt: lx.string({ required: true, format: "datetime" }), 297 + }); 298 + 299 + expect(generatorView).toEqual({ 300 + type: "object", 301 + properties: { 302 + uri: { type: "string", required: true, format: "at-uri" }, 303 + cid: { type: "string", required: true, format: "cid" }, 304 + did: { type: "string", required: true, format: "did" }, 305 + creator: { 306 + type: "ref", 307 + ref: "app.bsky.actor.defs#profileView", 308 + required: true, 309 + }, 310 + displayName: { type: "string", required: true }, 311 + description: { type: "string", maxGraphemes: 300, maxLength: 3000 }, 312 + descriptionFacets: { 313 + type: "array", 314 + items: { type: "ref", ref: "app.bsky.richtext.facet" }, 315 + }, 316 + avatar: { type: "string", format: "uri" }, 317 + likeCount: { type: "integer", minimum: 0 }, 318 + acceptsInteractions: { type: "boolean" }, 319 + labels: { 320 + type: "array", 321 + items: { type: "ref", ref: "com.atproto.label.defs#label" }, 322 + }, 323 + viewer: { type: "ref", ref: "#generatorViewerState" }, 324 + contentMode: { 325 + type: "string", 326 + knownValues: [ 327 + "app.bsky.feed.defs#contentModeUnspecified", 328 + "app.bsky.feed.defs#contentModeVideo", 329 + ], 330 + }, 331 + indexedAt: { type: "string", required: true, format: "datetime" }, 332 + }, 333 + required: ["uri", "cid", "did", "creator", "displayName", "indexedAt"], 334 + }); 335 + }); 336 + 337 + test("app.bsky.feed.defs - generatorViewerState", () => { 338 + const generatorViewerState = lx.object({ 339 + like: lx.string({ format: "at-uri" }), 340 + }); 341 + 342 + expect(generatorViewerState).toEqual({ 343 + type: "object", 344 + properties: { 345 + like: { type: "string", format: "at-uri" }, 346 + }, 347 + }); 348 + }); 349 + 350 + test("app.bsky.feed.defs - skeletonFeedPost", () => { 351 + const skeletonFeedPost = lx.object({ 352 + post: lx.string({ required: true, format: "at-uri" }), 353 + reason: lx.union(["#skeletonReasonRepost", "#skeletonReasonPin"]), 354 + feedContext: lx.string({ maxLength: 2000 }), 355 + }); 356 + 357 + expect(skeletonFeedPost).toEqual({ 358 + type: "object", 359 + properties: { 360 + post: { type: "string", required: true, format: "at-uri" }, 361 + reason: { 362 + type: "union", 363 + refs: ["#skeletonReasonRepost", "#skeletonReasonPin"], 364 + }, 365 + feedContext: { type: "string", maxLength: 2000 }, 366 + }, 367 + required: ["post"], 368 + }); 369 + }); 370 + 371 + test("app.bsky.feed.defs - skeletonReasonRepost", () => { 372 + const skeletonReasonRepost = lx.object({ 373 + repost: lx.string({ required: true, format: "at-uri" }), 374 + }); 375 + 376 + expect(skeletonReasonRepost).toEqual({ 377 + type: "object", 378 + properties: { 379 + repost: { type: "string", required: true, format: "at-uri" }, 380 + }, 381 + required: ["repost"], 382 + }); 383 + }); 384 + 385 + test("app.bsky.feed.defs - skeletonReasonPin", () => { 386 + const skeletonReasonPin = lx.object({}); 387 + 388 + expect(skeletonReasonPin).toEqual({ 389 + type: "object", 390 + properties: {}, 391 + }); 392 + }); 393 + 394 + test("app.bsky.feed.defs - threadgateView", () => { 395 + const threadgateView = lx.object({ 396 + uri: lx.string({ format: "at-uri" }), 397 + cid: lx.string({ format: "cid" }), 398 + record: lx.unknown(), 399 + lists: lx.array(lx.ref("app.bsky.graph.defs#listViewBasic")), 400 + }); 401 + 402 + expect(threadgateView).toEqual({ 403 + type: "object", 404 + properties: { 405 + uri: { type: "string", format: "at-uri" }, 406 + cid: { type: "string", format: "cid" }, 407 + record: { type: "unknown" }, 408 + lists: { 409 + type: "array", 410 + items: { type: "ref", ref: "app.bsky.graph.defs#listViewBasic" }, 411 + }, 412 + }, 413 + }); 414 + }); 415 + 416 + test("app.bsky.feed.defs - interaction", () => { 417 + const interaction = lx.object({ 418 + item: lx.string({ format: "at-uri" }), 419 + event: lx.string({ 420 + knownValues: [ 421 + "app.bsky.feed.defs#requestLess", 422 + "app.bsky.feed.defs#requestMore", 423 + "app.bsky.feed.defs#clickthroughItem", 424 + "app.bsky.feed.defs#clickthroughAuthor", 425 + "app.bsky.feed.defs#clickthroughReposter", 426 + "app.bsky.feed.defs#clickthroughEmbed", 427 + "app.bsky.feed.defs#interactionSeen", 428 + "app.bsky.feed.defs#interactionLike", 429 + "app.bsky.feed.defs#interactionRepost", 430 + "app.bsky.feed.defs#interactionReply", 431 + "app.bsky.feed.defs#interactionQuote", 432 + "app.bsky.feed.defs#interactionShare", 433 + ], 434 + }), 435 + feedContext: lx.string({ maxLength: 2000 }), 436 + reqId: lx.string({ maxLength: 100 }), 437 + }); 438 + 439 + expect(interaction).toEqual({ 440 + type: "object", 441 + properties: { 442 + item: { type: "string", format: "at-uri" }, 443 + event: { 444 + type: "string", 445 + knownValues: [ 446 + "app.bsky.feed.defs#requestLess", 447 + "app.bsky.feed.defs#requestMore", 448 + "app.bsky.feed.defs#clickthroughItem", 449 + "app.bsky.feed.defs#clickthroughAuthor", 450 + "app.bsky.feed.defs#clickthroughReposter", 451 + "app.bsky.feed.defs#clickthroughEmbed", 452 + "app.bsky.feed.defs#interactionSeen", 453 + "app.bsky.feed.defs#interactionLike", 454 + "app.bsky.feed.defs#interactionRepost", 455 + "app.bsky.feed.defs#interactionReply", 456 + "app.bsky.feed.defs#interactionQuote", 457 + "app.bsky.feed.defs#interactionShare", 458 + ], 459 + }, 460 + feedContext: { type: "string", maxLength: 2000 }, 461 + reqId: { type: "string", maxLength: 100 }, 462 + }, 463 + }); 464 + }); 465 + 466 + test("app.bsky.feed.defs - requestLess token", () => { 467 + const requestLess = lx.token( 468 + "Request that less content like the given feed item be shown in the feed", 469 + ); 470 + 471 + expect(requestLess).toEqual({ 472 + type: "token", 473 + description: 474 + "Request that less content like the given feed item be shown in the feed", 475 + }); 476 + }); 477 + 478 + test("app.bsky.feed.defs - requestMore token", () => { 479 + const requestMore = lx.token( 480 + "Request that more content like the given feed item be shown in the feed", 481 + ); 482 + 483 + expect(requestMore).toEqual({ 484 + type: "token", 485 + description: 486 + "Request that more content like the given feed item be shown in the feed", 487 + }); 488 + }); 489 + 490 + test("app.bsky.feed.defs - clickthroughItem token", () => { 491 + const clickthroughItem = lx.token("User clicked through to the feed item"); 492 + 493 + expect(clickthroughItem).toEqual({ 494 + type: "token", 495 + description: "User clicked through to the feed item", 496 + }); 497 + }); 498 + 499 + test("app.bsky.feed.defs - clickthroughAuthor token", () => { 500 + const clickthroughAuthor = lx.token( 501 + "User clicked through to the author of the feed item", 502 + ); 503 + 504 + expect(clickthroughAuthor).toEqual({ 505 + type: "token", 506 + description: "User clicked through to the author of the feed item", 507 + }); 508 + }); 509 + 510 + test("app.bsky.feed.defs - clickthroughReposter token", () => { 511 + const clickthroughReposter = lx.token( 512 + "User clicked through to the reposter of the feed item", 513 + ); 514 + 515 + expect(clickthroughReposter).toEqual({ 516 + type: "token", 517 + description: "User clicked through to the reposter of the feed item", 518 + }); 519 + }); 520 + 521 + test("app.bsky.feed.defs - clickthroughEmbed token", () => { 522 + const clickthroughEmbed = lx.token( 523 + "User clicked through to the embedded content of the feed item", 524 + ); 525 + 526 + expect(clickthroughEmbed).toEqual({ 527 + type: "token", 528 + description: 529 + "User clicked through to the embedded content of the feed item", 530 + }); 531 + }); 532 + 533 + test("app.bsky.feed.defs - contentModeUnspecified token", () => { 534 + const contentModeUnspecified = lx.token( 535 + "Declares the feed generator returns any types of posts.", 536 + ); 537 + 538 + expect(contentModeUnspecified).toEqual({ 539 + type: "token", 540 + description: "Declares the feed generator returns any types of posts.", 541 + }); 542 + }); 543 + 544 + test("app.bsky.feed.defs - contentModeVideo token", () => { 545 + const contentModeVideo = lx.token( 546 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds.", 547 + ); 548 + 549 + expect(contentModeVideo).toEqual({ 550 + type: "token", 551 + description: 552 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds.", 553 + }); 554 + }); 555 + 556 + test("app.bsky.feed.defs - interactionSeen token", () => { 557 + const interactionSeen = lx.token("Feed item was seen by user"); 558 + 559 + expect(interactionSeen).toEqual({ 560 + type: "token", 561 + description: "Feed item was seen by user", 562 + }); 563 + }); 564 + 565 + test("app.bsky.feed.defs - interactionLike token", () => { 566 + const interactionLike = lx.token("User liked the feed item"); 567 + 568 + expect(interactionLike).toEqual({ 569 + type: "token", 570 + description: "User liked the feed item", 571 + }); 572 + }); 573 + 574 + test("app.bsky.feed.defs - interactionRepost token", () => { 575 + const interactionRepost = lx.token("User reposted the feed item"); 576 + 577 + expect(interactionRepost).toEqual({ 578 + type: "token", 579 + description: "User reposted the feed item", 580 + }); 581 + }); 582 + 583 + test("app.bsky.feed.defs - interactionReply token", () => { 584 + const interactionReply = lx.token("User replied to the feed item"); 585 + 586 + expect(interactionReply).toEqual({ 587 + type: "token", 588 + description: "User replied to the feed item", 589 + }); 590 + }); 591 + 592 + test("app.bsky.feed.defs - interactionQuote token", () => { 593 + const interactionQuote = lx.token("User quoted the feed item"); 594 + 595 + expect(interactionQuote).toEqual({ 596 + type: "token", 597 + description: "User quoted the feed item", 598 + }); 599 + }); 600 + 601 + test("app.bsky.feed.defs - interactionShare token", () => { 602 + const interactionShare = lx.token("User shared the feed item"); 603 + 604 + expect(interactionShare).toEqual({ 605 + type: "token", 606 + description: "User shared the feed item", 607 + }); 608 + }); 609 + 610 + test("app.bsky.feed.defs - full namespace", () => { 611 + const feedDefs = lx.namespace("app.bsky.feed.defs", { 612 + postView: lx.object({ 613 + uri: lx.string({ required: true, format: "at-uri" }), 614 + cid: lx.string({ required: true, format: "cid" }), 615 + author: lx.ref("app.bsky.actor.defs#profileViewBasic", { 616 + required: true, 617 + }), 618 + record: lx.unknown({ required: true }), 619 + embed: lx.union([ 620 + "app.bsky.embed.images#view", 621 + "app.bsky.embed.video#view", 622 + "app.bsky.embed.external#view", 623 + "app.bsky.embed.record#view", 624 + "app.bsky.embed.recordWithMedia#view", 625 + ]), 626 + bookmarkCount: lx.integer(), 627 + replyCount: lx.integer(), 628 + repostCount: lx.integer(), 629 + likeCount: lx.integer(), 630 + quoteCount: lx.integer(), 631 + indexedAt: lx.string({ required: true, format: "datetime" }), 632 + viewer: lx.ref("#viewerState"), 633 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 634 + threadgate: lx.ref("#threadgateView"), 635 + }), 636 + viewerState: lx.object({ 637 + repost: lx.string({ format: "at-uri" }), 638 + like: lx.string({ format: "at-uri" }), 639 + bookmarked: lx.boolean(), 640 + threadMuted: lx.boolean(), 641 + replyDisabled: lx.boolean(), 642 + embeddingDisabled: lx.boolean(), 643 + pinned: lx.boolean(), 644 + }), 645 + requestLess: lx.token( 646 + "Request that less content like the given feed item be shown in the feed", 647 + ), 648 + requestMore: lx.token( 649 + "Request that more content like the given feed item be shown in the feed", 650 + ), 651 + clickthroughItem: lx.token("User clicked through to the feed item"), 652 + clickthroughAuthor: lx.token( 653 + "User clicked through to the author of the feed item", 654 + ), 655 + clickthroughReposter: lx.token( 656 + "User clicked through to the reposter of the feed item", 657 + ), 658 + clickthroughEmbed: lx.token( 659 + "User clicked through to the embedded content of the feed item", 660 + ), 661 + contentModeUnspecified: lx.token( 662 + "Declares the feed generator returns any types of posts.", 663 + ), 664 + contentModeVideo: lx.token( 665 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds.", 666 + ), 667 + interactionSeen: lx.token("Feed item was seen by user"), 668 + interactionLike: lx.token("User liked the feed item"), 669 + interactionRepost: lx.token("User reposted the feed item"), 670 + interactionReply: lx.token("User replied to the feed item"), 671 + interactionQuote: lx.token("User quoted the feed item"), 672 + interactionShare: lx.token("User shared the feed item"), 673 + }); 674 + 675 + expect(feedDefs.json.lexicon).toEqual(1); 676 + expect(feedDefs.json.id).toEqual("app.bsky.feed.defs"); 677 + expect(feedDefs.json.defs.postView.type).toEqual("object"); 678 + expect(feedDefs.json.defs.viewerState.type).toEqual("object"); 679 + expect(feedDefs.json.defs.requestLess.type).toEqual("token"); 680 + expect(feedDefs.json.defs.contentModeVideo.type).toEqual("token"); 681 + });
+119
packages/prototypey/tests/infer.bench.ts
··· 1 + import { bench } from "@ark/attest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + bench("infer with simple object", () => { 5 + const schema = lx.namespace("test.simple", { 6 + main: lx.object({ 7 + id: lx.string({ required: true }), 8 + name: lx.string({ required: true }), 9 + }), 10 + }); 11 + return schema.infer; 12 + }).types([899, "instantiations"]); 13 + 14 + bench("infer with complex nested structure", () => { 15 + const schema = lx.namespace("test.complex", { 16 + user: lx.object({ 17 + handle: lx.string({ required: true }), 18 + displayName: lx.string(), 19 + }), 20 + reply: lx.object({ 21 + text: lx.string({ required: true }), 22 + author: lx.ref("#user", { required: true }), 23 + }), 24 + main: lx.record({ 25 + key: "tid", 26 + record: lx.object({ 27 + author: lx.ref("#user", { required: true }), 28 + replies: lx.array(lx.ref("#reply")), 29 + content: lx.string({ required: true }), 30 + createdAt: lx.string({ required: true, format: "datetime" }), 31 + }), 32 + }), 33 + }); 34 + return schema.infer; 35 + }).types([1040, "instantiations"]); 36 + 37 + bench("infer with circular reference", () => { 38 + const ns = lx.namespace("test", { 39 + user: lx.object({ 40 + name: lx.string({ required: true }), 41 + posts: lx.array(lx.ref("#post")), 42 + }), 43 + post: lx.object({ 44 + title: lx.string({ required: true }), 45 + author: lx.ref("#user", { required: true }), 46 + }), 47 + main: lx.object({ 48 + users: lx.array(lx.ref("#user")), 49 + }), 50 + }); 51 + return ns.infer; 52 + }).types([692, "instantiations"]); 53 + 54 + bench("infer with app.bsky.feed.defs namespace", () => { 55 + const schema = lx.namespace("app.bsky.feed.defs", { 56 + viewerState: lx.object({ 57 + repost: lx.string({ format: "at-uri" }), 58 + like: lx.string({ format: "at-uri" }), 59 + bookmarked: lx.boolean(), 60 + threadMuted: lx.boolean(), 61 + replyDisabled: lx.boolean(), 62 + embeddingDisabled: lx.boolean(), 63 + pinned: lx.boolean(), 64 + }), 65 + main: lx.object({ 66 + uri: lx.string({ required: true, format: "at-uri" }), 67 + cid: lx.string({ required: true, format: "cid" }), 68 + author: lx.ref("app.bsky.actor.defs#profileViewBasic", { 69 + required: true, 70 + }), 71 + record: lx.unknown({ required: true }), 72 + embed: lx.union([ 73 + "app.bsky.embed.images#view", 74 + "app.bsky.embed.video#view", 75 + "app.bsky.embed.external#view", 76 + "app.bsky.embed.record#view", 77 + "app.bsky.embed.recordWithMedia#view", 78 + ]), 79 + bookmarkCount: lx.integer(), 80 + replyCount: lx.integer(), 81 + repostCount: lx.integer(), 82 + likeCount: lx.integer(), 83 + quoteCount: lx.integer(), 84 + indexedAt: lx.string({ required: true, format: "datetime" }), 85 + viewer: lx.ref("#viewerState"), 86 + labels: lx.array(lx.ref("com.atproto.label.defs#label")), 87 + threadgate: lx.ref("#threadgateView"), 88 + }), 89 + requestLess: lx.token( 90 + "Request that less content like the given feed item be shown in the feed", 91 + ), 92 + requestMore: lx.token( 93 + "Request that more content like the given feed item be shown in the feed", 94 + ), 95 + clickthroughItem: lx.token("User clicked through to the feed item"), 96 + clickthroughAuthor: lx.token( 97 + "User clicked through to the author of the feed item", 98 + ), 99 + clickthroughReposter: lx.token( 100 + "User clicked through to the reposter of the feed item", 101 + ), 102 + clickthroughEmbed: lx.token( 103 + "User clicked through to the embedded content of the feed item", 104 + ), 105 + contentModeUnspecified: lx.token( 106 + "Declares the feed generator returns any types of posts.", 107 + ), 108 + contentModeVideo: lx.token( 109 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds.", 110 + ), 111 + interactionSeen: lx.token("Feed item was seen by user"), 112 + interactionLike: lx.token("User liked the feed item"), 113 + interactionRepost: lx.token("User reposted the feed item"), 114 + interactionReply: lx.token("User replied to the feed item"), 115 + interactionQuote: lx.token("User quoted the feed item"), 116 + interactionShare: lx.token("User shared the feed item"), 117 + }); 118 + return schema.infer; 119 + }).types([1285, "instantiations"]);
+868
packages/prototypey/tests/infer.test.ts
··· 1 + import { test } from "vitest"; 2 + import { attest } from "@ark/attest"; 3 + import { lx } from "../src/lib.ts"; 4 + 5 + test("InferNS produces expected type shape", () => { 6 + const exampleLexicon = lx.namespace("com.example.post", { 7 + main: lx.record({ 8 + key: "tid", 9 + record: lx.object({ 10 + text: lx.string({ required: true }), 11 + createdAt: lx.string({ required: true, format: "datetime" }), 12 + likes: lx.integer(), 13 + tags: lx.array(lx.string(), { maxLength: 5 }), 14 + }), 15 + }), 16 + }); 17 + 18 + // Type snapshot - this captures how types appear on hover 19 + attest(exampleLexicon.infer).type.toString.snap(`{ 20 + $type: "com.example.post" 21 + tags?: string[] | undefined 22 + likes?: number | undefined 23 + createdAt: string 24 + text: string 25 + }`); 26 + }); 27 + 28 + test("InferObject handles required fields", () => { 29 + const schema = lx.namespace("test", { 30 + main: lx.object({ 31 + required: lx.string({ required: true }), 32 + optional: lx.string(), 33 + }), 34 + }); 35 + 36 + attest(schema.infer).type.toString.snap(`{ 37 + $type: "test" 38 + optional?: string | undefined 39 + required: string 40 + }`); 41 + }); 42 + 43 + test("InferObject handles nullable fields", () => { 44 + const schema = lx.namespace("test", { 45 + main: lx.object({ 46 + nullable: lx.string({ nullable: true, required: true }), 47 + }), 48 + }); 49 + 50 + attest(schema.infer).type.toString.snap( 51 + '{ $type: "test"; nullable: string | null }', 52 + ); 53 + }); 54 + 55 + // ============================================================================ 56 + // PRIMITIVE TYPES TESTS 57 + // ============================================================================ 58 + 59 + test("InferType handles string primitive", () => { 60 + const namespace = lx.namespace("test.string", { 61 + main: lx.object({ 62 + simpleString: lx.string(), 63 + }), 64 + }); 65 + 66 + attest(namespace.infer).type.toString.snap(`{ 67 + $type: "test.string" 68 + simpleString?: string | undefined 69 + }`); 70 + }); 71 + 72 + test("InferType handles integer primitive", () => { 73 + const namespace = lx.namespace("test.integer", { 74 + main: lx.object({ 75 + count: lx.integer(), 76 + age: lx.integer({ minimum: 0, maximum: 120 }), 77 + }), 78 + }); 79 + 80 + attest(namespace.infer).type.toString.snap(`{ 81 + $type: "test.integer" 82 + count?: number | undefined 83 + age?: number | undefined 84 + }`); 85 + }); 86 + 87 + test("InferType handles boolean primitive", () => { 88 + const namespace = lx.namespace("test.boolean", { 89 + main: lx.object({ 90 + isActive: lx.boolean(), 91 + hasAccess: lx.boolean({ required: true }), 92 + }), 93 + }); 94 + 95 + attest(namespace.infer).type.toString.snap(`{ 96 + $type: "test.boolean" 97 + isActive?: boolean | undefined 98 + hasAccess: boolean 99 + }`); 100 + }); 101 + 102 + test("InferType handles null primitive", () => { 103 + const namespace = lx.namespace("test.null", { 104 + main: lx.object({ 105 + nullValue: lx.null(), 106 + }), 107 + }); 108 + 109 + attest(namespace.infer).type.toString.snap(`{ 110 + $type: "test.null" 111 + nullValue?: null | undefined 112 + }`); 113 + }); 114 + 115 + test("InferType handles unknown primitive", () => { 116 + const namespace = lx.namespace("test.unknown", { 117 + main: lx.object({ 118 + metadata: lx.unknown(), 119 + }), 120 + }); 121 + 122 + attest(namespace.infer).type.toString.snap( 123 + '{ $type: "test.unknown"; metadata?: unknown }', 124 + ); 125 + }); 126 + 127 + test("InferType handles bytes primitive", () => { 128 + const namespace = lx.namespace("test.bytes", { 129 + main: lx.object({ 130 + data: lx.bytes(), 131 + }), 132 + }); 133 + 134 + attest(namespace.infer).type.toString.snap(`{ 135 + $type: "test.bytes" 136 + data?: Uint8Array<ArrayBufferLike> | undefined 137 + }`); 138 + }); 139 + 140 + test("InferType handles blob primitive", () => { 141 + const namespace = lx.namespace("test.blob", { 142 + main: lx.object({ 143 + image: lx.blob({ accept: ["image/png", "image/jpeg"] }), 144 + }), 145 + }); 146 + 147 + attest(namespace.infer).type.toString.snap( 148 + '{ $type: "test.blob"; image?: Blob | undefined }', 149 + ); 150 + }); 151 + 152 + // ============================================================================ 153 + // TOKEN TYPE TESTS 154 + // ============================================================================ 155 + 156 + test("InferToken handles basic token without enum", () => { 157 + const namespace = lx.namespace("test.token", { 158 + main: lx.object({ 159 + symbol: lx.token("A symbolic value"), 160 + }), 161 + }); 162 + 163 + attest(namespace.infer).type.toString.snap(`{ 164 + $type: "test.token" 165 + symbol?: string | undefined 166 + }`); 167 + }); 168 + 169 + // ============================================================================ 170 + // ARRAY TYPE TESTS 171 + // ============================================================================ 172 + 173 + test("InferArray handles string arrays", () => { 174 + const namespace = lx.namespace("test.array.string", { 175 + main: lx.object({ 176 + tags: lx.array(lx.string()), 177 + }), 178 + }); 179 + 180 + attest(namespace.infer).type.toString.snap(`{ 181 + $type: "test.array.string" 182 + tags?: string[] | undefined 183 + }`); 184 + }); 185 + 186 + test("InferArray handles integer arrays", () => { 187 + const namespace = lx.namespace("test.array.integer", { 188 + main: lx.object({ 189 + scores: lx.array(lx.integer(), { minLength: 1, maxLength: 10 }), 190 + }), 191 + }); 192 + 193 + attest(namespace.infer).type.toString.snap(`{ 194 + $type: "test.array.integer" 195 + scores?: number[] | undefined 196 + }`); 197 + }); 198 + 199 + test("InferArray handles boolean arrays", () => { 200 + const namespace = lx.namespace("test.array.boolean", { 201 + main: lx.object({ 202 + flags: lx.array(lx.boolean()), 203 + }), 204 + }); 205 + 206 + attest(namespace.infer).type.toString.snap(`{ 207 + $type: "test.array.boolean" 208 + flags?: boolean[] | undefined 209 + }`); 210 + }); 211 + 212 + test("InferArray handles unknown arrays", () => { 213 + const namespace = lx.namespace("test.array.unknown", { 214 + main: lx.object({ 215 + items: lx.array(lx.unknown()), 216 + }), 217 + }); 218 + 219 + attest(namespace.infer).type.toString.snap(`{ 220 + $type: "test.array.unknown" 221 + items?: unknown[] | undefined 222 + }`); 223 + }); 224 + 225 + // ============================================================================ 226 + // OBJECT PROPERTY COMBINATIONS 227 + // ============================================================================ 228 + 229 + test("InferObject handles mixed optional and required fields", () => { 230 + const namespace = lx.namespace("test.mixed", { 231 + main: lx.object({ 232 + id: lx.string({ required: true }), 233 + name: lx.string({ required: true }), 234 + email: lx.string(), 235 + age: lx.integer(), 236 + }), 237 + }); 238 + 239 + attest(namespace.infer).type.toString.snap(`{ 240 + $type: "test.mixed" 241 + age?: number | undefined 242 + email?: string | undefined 243 + id: string 244 + name: string 245 + }`); 246 + }); 247 + 248 + test("InferObject handles all optional fields", () => { 249 + const namespace = lx.namespace("test.allOptional", { 250 + main: lx.object({ 251 + field1: lx.string(), 252 + field2: lx.integer(), 253 + field3: lx.boolean(), 254 + }), 255 + }); 256 + 257 + attest(namespace.infer).type.toString.snap(`{ 258 + $type: "test.allOptional" 259 + field1?: string | undefined 260 + field2?: number | undefined 261 + field3?: boolean | undefined 262 + }`); 263 + }); 264 + 265 + test("InferObject handles all required fields", () => { 266 + const namespace = lx.namespace("test.allRequired", { 267 + main: lx.object({ 268 + field1: lx.string({ required: true }), 269 + field2: lx.integer({ required: true }), 270 + field3: lx.boolean({ required: true }), 271 + }), 272 + }); 273 + 274 + attest(namespace.infer).type.toString.snap(`{ 275 + $type: "test.allRequired" 276 + field1: string 277 + field2: number 278 + field3: boolean 279 + }`); 280 + }); 281 + 282 + // ============================================================================ 283 + // NULLABLE FIELDS TESTS 284 + // ============================================================================ 285 + 286 + test("InferObject handles nullable optional field", () => { 287 + const namespace = lx.namespace("test.nullableOptional", { 288 + main: lx.object({ 289 + description: lx.string({ nullable: true }), 290 + }), 291 + }); 292 + 293 + attest(namespace.infer).type.toString.snap(`{ 294 + $type: "test.nullableOptional" 295 + description?: string | null | undefined 296 + }`); 297 + }); 298 + 299 + test("InferObject handles multiple nullable fields", () => { 300 + const namespace = lx.namespace("test.multipleNullable", { 301 + main: lx.object({ 302 + field1: lx.string({ nullable: true }), 303 + field2: lx.integer({ nullable: true }), 304 + field3: lx.boolean({ nullable: true }), 305 + }), 306 + }); 307 + 308 + attest(namespace.infer).type.toString.snap(`{ 309 + $type: "test.multipleNullable" 310 + field1?: string | null | undefined 311 + field2?: number | null | undefined 312 + field3?: boolean | null | undefined 313 + }`); 314 + }); 315 + 316 + test("InferObject handles nullable and required field", () => { 317 + const namespace = lx.namespace("test.nullableRequired", { 318 + main: lx.object({ 319 + value: lx.string({ nullable: true, required: true }), 320 + }), 321 + }); 322 + 323 + attest(namespace.infer).type.toString.snap(`{ 324 + $type: "test.nullableRequired" 325 + value: string | null 326 + }`); 327 + }); 328 + 329 + test("InferObject handles mixed nullable, required, and optional", () => { 330 + const namespace = lx.namespace("test.mixedNullable", { 331 + main: lx.object({ 332 + requiredNullable: lx.string({ required: true, nullable: true }), 333 + optionalNullable: lx.string({ nullable: true }), 334 + required: lx.string({ required: true }), 335 + optional: lx.string(), 336 + }), 337 + }); 338 + 339 + attest(namespace.infer).type.toString.snap(`{ 340 + $type: "test.mixedNullable" 341 + optional?: string | undefined 342 + required: string 343 + optionalNullable?: string | null | undefined 344 + requiredNullable: string | null 345 + }`); 346 + }); 347 + 348 + // ============================================================================ 349 + // REF TYPE TESTS 350 + // ============================================================================ 351 + 352 + test("InferRef handles basic reference", () => { 353 + const namespace = lx.namespace("test.ref", { 354 + main: lx.object({ 355 + post: lx.ref("com.example.post"), 356 + }), 357 + }); 358 + 359 + attest(namespace.infer).type.toString.snap(`{ 360 + $type: "test.ref" 361 + post?: 362 + | { [x: string]: unknown; $type: "com.example.post" } 363 + | undefined 364 + }`); 365 + }); 366 + 367 + test("InferRef handles required reference", () => { 368 + const namespace = lx.namespace("test.refRequired", { 369 + main: lx.object({ 370 + author: lx.ref("com.example.user", { required: true }), 371 + }), 372 + }); 373 + 374 + attest(namespace.infer).type.toString.snap(`{ 375 + $type: "test.refRequired" 376 + author?: 377 + | { [x: string]: unknown; $type: "com.example.user" } 378 + | undefined 379 + }`); 380 + }); 381 + 382 + test("InferRef handles nullable reference", () => { 383 + const namespace = lx.namespace("test.refNullable", { 384 + main: lx.object({ 385 + parent: lx.ref("com.example.node", { nullable: true }), 386 + }), 387 + }); 388 + 389 + attest(namespace.infer).type.toString.snap(`{ 390 + $type: "test.refNullable" 391 + parent?: 392 + | { [x: string]: unknown; $type: "com.example.node" } 393 + | undefined 394 + }`); 395 + }); 396 + 397 + // ============================================================================ 398 + // UNION TYPE TESTS 399 + // ============================================================================ 400 + 401 + test("InferUnion handles basic union", () => { 402 + const namespace = lx.namespace("test.union", { 403 + main: lx.object({ 404 + content: lx.union(["com.example.text", "com.example.image"]), 405 + }), 406 + }); 407 + 408 + attest(namespace.infer).type.toString.snap(`{ 409 + $type: "test.union" 410 + content?: 411 + | { [x: string]: unknown; $type: "com.example.text" } 412 + | { [x: string]: unknown; $type: "com.example.image" } 413 + | undefined 414 + }`); 415 + }); 416 + 417 + test("InferUnion handles required union", () => { 418 + const namespace = lx.namespace("test.unionRequired", { 419 + main: lx.object({ 420 + media: lx.union(["com.example.video", "com.example.audio"], { 421 + required: true, 422 + }), 423 + }), 424 + }); 425 + 426 + attest(namespace.infer).type.toString.snap(`{ 427 + $type: "test.unionRequired" 428 + media: 429 + | { [x: string]: unknown; $type: "com.example.video" } 430 + | { [x: string]: unknown; $type: "com.example.audio" } 431 + }`); 432 + }); 433 + 434 + test("InferUnion handles union with many types", () => { 435 + const namespace = lx.namespace("test.unionMultiple", { 436 + main: lx.object({ 437 + attachment: lx.union([ 438 + "com.example.image", 439 + "com.example.video", 440 + "com.example.audio", 441 + "com.example.document", 442 + ]), 443 + }), 444 + }); 445 + 446 + attest(namespace.infer).type.toString.snap(`{ 447 + $type: "test.unionMultiple" 448 + attachment?: 449 + | { [x: string]: unknown; $type: "com.example.image" } 450 + | { [x: string]: unknown; $type: "com.example.video" } 451 + | { [x: string]: unknown; $type: "com.example.audio" } 452 + | { 453 + [x: string]: unknown 454 + $type: "com.example.document" 455 + } 456 + | undefined 457 + }`); 458 + }); 459 + 460 + // ============================================================================ 461 + // PARAMS TYPE TESTS 462 + // ============================================================================ 463 + 464 + test("InferParams handles basic params", () => { 465 + const namespace = lx.namespace("test.params", { 466 + main: lx.params({ 467 + limit: lx.integer(), 468 + offset: lx.integer(), 469 + }), 470 + }); 471 + 472 + attest(namespace.infer).type.toString.snap(`{ 473 + $type: "test.params" 474 + limit?: number | undefined 475 + offset?: number | undefined 476 + }`); 477 + }); 478 + 479 + test("InferParams handles required params", () => { 480 + const namespace = lx.namespace("test.paramsRequired", { 481 + main: lx.params({ 482 + query: lx.string({ required: true }), 483 + limit: lx.integer(), 484 + }), 485 + }); 486 + 487 + attest(namespace.infer).type.toString.snap(`{ 488 + $type: "test.paramsRequired" 489 + limit?: number | undefined 490 + query: string 491 + }`); 492 + }); 493 + 494 + // ============================================================================ 495 + // RECORD TYPE TESTS 496 + // ============================================================================ 497 + 498 + test("InferRecord handles record with object schema", () => { 499 + const namespace = lx.namespace("test.record", { 500 + main: lx.record({ 501 + key: "tid", 502 + record: lx.object({ 503 + title: lx.string({ required: true }), 504 + content: lx.string({ required: true }), 505 + published: lx.boolean(), 506 + }), 507 + }), 508 + }); 509 + 510 + attest(namespace.infer).type.toString.snap(`{ 511 + $type: "test.record" 512 + published?: boolean | undefined 513 + content: string 514 + title: string 515 + }`); 516 + }); 517 + 518 + // ============================================================================ 519 + // NESTED OBJECTS TESTS 520 + // ============================================================================ 521 + 522 + test("InferObject handles nested objects", () => { 523 + const namespace = lx.namespace("test.nested", { 524 + main: lx.object({ 525 + user: lx.object({ 526 + name: lx.string({ required: true }), 527 + email: lx.string({ required: true }), 528 + }), 529 + }), 530 + }); 531 + 532 + attest(namespace.infer).type.toString.snap(`{ 533 + $type: "test.nested" 534 + user?: { name: string; email: string } | undefined 535 + }`); 536 + }); 537 + 538 + test("InferObject handles deeply nested objects", () => { 539 + const namespace = lx.namespace("test.deepNested", { 540 + main: lx.object({ 541 + data: lx.object({ 542 + user: lx.object({ 543 + profile: lx.object({ 544 + name: lx.string({ required: true }), 545 + }), 546 + }), 547 + }), 548 + }), 549 + }); 550 + 551 + attest(namespace.infer).type.toString.snap(`{ 552 + $type: "test.deepNested" 553 + data?: 554 + | { 555 + user?: 556 + | { profile?: { name: string } | undefined } 557 + | undefined 558 + } 559 + | undefined 560 + }`); 561 + }); 562 + 563 + // ============================================================================ 564 + // NESTED ARRAYS TESTS 565 + // ============================================================================ 566 + 567 + test("InferArray handles arrays of objects", () => { 568 + const namespace = lx.namespace("test.arrayOfObjects", { 569 + main: lx.object({ 570 + users: lx.array( 571 + lx.object({ 572 + id: lx.string({ required: true }), 573 + name: lx.string({ required: true }), 574 + }), 575 + ), 576 + }), 577 + }); 578 + 579 + attest(namespace.infer).type.toString.snap(`{ 580 + $type: "test.arrayOfObjects" 581 + users?: { id: string; name: string }[] | undefined 582 + }`); 583 + }); 584 + 585 + test("InferArray handles arrays of arrays", () => { 586 + const schema = lx.object({ 587 + matrix: lx.array(lx.array(lx.integer())), 588 + }); 589 + 590 + const namespace = lx.namespace("test.nestedArrays", { 591 + main: schema, 592 + }); 593 + 594 + attest(namespace.infer).type.toString.snap(`{ 595 + $type: "test.nestedArrays" 596 + matrix?: number[][] | undefined 597 + }`); 598 + }); 599 + 600 + test("InferArray handles arrays of refs", () => { 601 + const namespace = lx.namespace("test.arrayOfRefs", { 602 + main: lx.object({ 603 + followers: lx.array(lx.ref("com.example.user")), 604 + }), 605 + }); 606 + 607 + attest(namespace.infer).type.toString.snap(`{ 608 + $type: "test.arrayOfRefs" 609 + followers?: 610 + | { [x: string]: unknown; $type: "com.example.user" }[] 611 + | undefined 612 + }`); 613 + }); 614 + 615 + // ============================================================================ 616 + // COMPLEX NESTED STRUCTURES 617 + // ============================================================================ 618 + 619 + test("InferObject handles complex nested structure", () => { 620 + const namespace = lx.namespace("test.complex", { 621 + main: lx.object({ 622 + id: lx.string({ required: true }), 623 + author: lx.object({ 624 + did: lx.string({ required: true, format: "did" }), 625 + handle: lx.string({ required: true, format: "handle" }), 626 + avatar: lx.string(), 627 + }), 628 + content: lx.union(["com.example.text", "com.example.image"]), 629 + tags: lx.array(lx.string(), { maxLength: 10 }), 630 + metadata: lx.object({ 631 + views: lx.integer(), 632 + likes: lx.integer(), 633 + shares: lx.integer(), 634 + }), 635 + }), 636 + }); 637 + 638 + attest(namespace.infer).type.toString.snap(`{ 639 + $type: "test.complex" 640 + tags?: string[] | undefined 641 + content?: 642 + | { [x: string]: unknown; $type: "com.example.text" } 643 + | { [x: string]: unknown; $type: "com.example.image" } 644 + | undefined 645 + author?: 646 + | { 647 + avatar?: string | undefined 648 + did: string 649 + handle: string 650 + } 651 + | undefined 652 + metadata?: 653 + | { 654 + likes?: number | undefined 655 + views?: number | undefined 656 + shares?: number | undefined 657 + } 658 + | undefined 659 + id: string 660 + }`); 661 + }); 662 + 663 + // ============================================================================ 664 + // MULTIPLE DEFS IN NAMESPACE 665 + // ============================================================================ 666 + 667 + test("InferNS handles multiple defs in namespace", () => { 668 + const namespace = lx.namespace("com.example.app", { 669 + user: lx.object({ 670 + name: lx.string({ required: true }), 671 + email: lx.string({ required: true }), 672 + }), 673 + post: lx.object({ 674 + title: lx.string({ required: true }), 675 + content: lx.string({ required: true }), 676 + }), 677 + comment: lx.object({ 678 + text: lx.string({ required: true }), 679 + author: lx.ref("com.example.user"), 680 + }), 681 + }); 682 + 683 + attest(namespace.infer).type.toString.snap("never"); 684 + }); 685 + 686 + test("InferNS handles namespace with record and object defs", () => { 687 + const namespace = lx.namespace("com.example.blog", { 688 + main: lx.record({ 689 + key: "tid", 690 + record: lx.object({ 691 + title: lx.string({ required: true }), 692 + body: lx.string({ required: true }), 693 + }), 694 + }), 695 + metadata: lx.object({ 696 + category: lx.string(), 697 + tags: lx.array(lx.string()), 698 + }), 699 + }); 700 + 701 + attest(namespace.infer).type.toString.snap(`{ 702 + $type: "com.example.blog" 703 + title: string 704 + body: string 705 + }`); 706 + }); 707 + 708 + // ============================================================================ 709 + // LOCAL REF RESOLUTION TESTS 710 + // ============================================================================ 711 + 712 + test("Local ref resolution: resolves refs to actual types", () => { 713 + const ns = lx.namespace("test", { 714 + user: lx.object({ 715 + name: lx.string({ required: true }), 716 + email: lx.string({ required: true }), 717 + }), 718 + main: lx.object({ 719 + author: lx.ref("#user", { required: true }), 720 + content: lx.string({ required: true }), 721 + }), 722 + }); 723 + 724 + attest(ns.infer).type.toString.snap(`{ 725 + $type: "test" 726 + author?: 727 + | { name: string; email: string; $type: "#user" } 728 + | undefined 729 + content: string 730 + }`); 731 + }); 732 + 733 + test("Local ref resolution: refs in arrays", () => { 734 + const ns = lx.namespace("test", { 735 + user: lx.object({ 736 + name: lx.string({ required: true }), 737 + }), 738 + main: lx.object({ 739 + users: lx.array(lx.ref("#user")), 740 + }), 741 + }); 742 + 743 + attest(ns.infer).type.toString.snap(`{ 744 + $type: "test" 745 + users?: { name: string; $type: "#user" }[] | undefined 746 + }`); 747 + }); 748 + 749 + test("Local ref resolution: refs in unions", () => { 750 + const ns = lx.namespace("test", { 751 + text: lx.object({ content: lx.string({ required: true }) }), 752 + image: lx.object({ url: lx.string({ required: true }) }), 753 + main: lx.object({ 754 + embed: lx.union(["#text", "#image"]), 755 + }), 756 + }); 757 + 758 + attest(ns.infer).type.toString.snap(`{ 759 + $type: "test" 760 + embed?: 761 + | { content: string; $type: "#text" } 762 + | { url: string; $type: "#image" } 763 + | undefined 764 + }`); 765 + }); 766 + 767 + test("Local ref resolution: nested refs", () => { 768 + const ns = lx.namespace("test", { 769 + profile: lx.object({ 770 + bio: lx.string({ required: true }), 771 + }), 772 + user: lx.object({ 773 + name: lx.string({ required: true }), 774 + profile: lx.ref("#profile", { required: true }), 775 + }), 776 + main: lx.object({ 777 + author: lx.ref("#user", { required: true }), 778 + }), 779 + }); 780 + 781 + attest(ns.infer).type.toString.snap(`{ 782 + $type: "test" 783 + author?: 784 + | { 785 + profile?: 786 + | { bio: string; $type: "#profile" } 787 + | undefined 788 + name: string 789 + $type: "#user" 790 + } 791 + | undefined 792 + }`); 793 + }); 794 + 795 + // ============================================================================ 796 + // EDGE CASE TESTS 797 + // ============================================================================ 798 + 799 + test("Edge case: circular reference detection", () => { 800 + const ns = lx.namespace("test", { 801 + main: lx.object({ 802 + value: lx.string({ required: true }), 803 + parent: lx.ref("#main"), 804 + }), 805 + }); 806 + 807 + attest(ns.infer).type.toString.snap(`{ 808 + $type: "test" 809 + parent?: 810 + | { 811 + parent?: 812 + | "[Circular reference detected: #main]" 813 + | undefined 814 + value: string 815 + $type: "#main" 816 + } 817 + | undefined 818 + value: string 819 + }`); 820 + }); 821 + 822 + test("Edge case: circular reference between multiple types", () => { 823 + const ns = lx.namespace("test", { 824 + user: lx.object({ 825 + name: lx.string({ required: true }), 826 + posts: lx.array(lx.ref("#post")), 827 + }), 828 + post: lx.object({ 829 + title: lx.string({ required: true }), 830 + author: lx.ref("#user", { required: true }), 831 + }), 832 + main: lx.object({ 833 + users: lx.array(lx.ref("#user")), 834 + }), 835 + }); 836 + 837 + attest(ns.infer).type.toString.snap(`{ 838 + $type: "test" 839 + users?: 840 + | { 841 + posts?: 842 + | { 843 + author?: 844 + | "[Circular reference detected: #user]" 845 + | undefined 846 + title: string 847 + $type: "#post" 848 + }[] 849 + | undefined 850 + name: string 851 + $type: "#user" 852 + }[] 853 + | undefined 854 + }`); 855 + }); 856 + 857 + test("Edge case: missing reference detection", () => { 858 + const ns = lx.namespace("test", { 859 + main: lx.object({ 860 + author: lx.ref("#user", { required: true }), 861 + }), 862 + }); 863 + 864 + attest(ns.infer).type.toString.snap(`{ 865 + $type: "test" 866 + author?: "[Reference not found: #user]" | undefined 867 + }`); 868 + });
+789
packages/prototypey/tests/primitives.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + test("lx.null()", () => { 5 + const result = lx.null(); 6 + expect(result).toEqual({ type: "null" }); 7 + }); 8 + 9 + test("lx.boolean()", () => { 10 + const result = lx.boolean(); 11 + expect(result).toEqual({ type: "boolean" }); 12 + }); 13 + 14 + test("lx.boolean() with default", () => { 15 + const result = lx.boolean({ default: true }); 16 + expect(result).toEqual({ type: "boolean", default: true }); 17 + }); 18 + 19 + test("lx.boolean() with const", () => { 20 + const result = lx.boolean({ const: false }); 21 + expect(result).toEqual({ type: "boolean", const: false }); 22 + }); 23 + 24 + test("lx.integer()", () => { 25 + const result = lx.integer(); 26 + expect(result).toEqual({ type: "integer" }); 27 + }); 28 + 29 + test("lx.integer() with minimum", () => { 30 + const result = lx.integer({ minimum: 0 }); 31 + expect(result).toEqual({ type: "integer", minimum: 0 }); 32 + }); 33 + 34 + test("lx.integer() with maximum", () => { 35 + const result = lx.integer({ maximum: 100 }); 36 + expect(result).toEqual({ type: "integer", maximum: 100 }); 37 + }); 38 + 39 + test("lx.integer() with minimum and maximum", () => { 40 + const result = lx.integer({ minimum: 0, maximum: 100 }); 41 + expect(result).toEqual({ type: "integer", minimum: 0, maximum: 100 }); 42 + }); 43 + 44 + test("lx.integer() with enum", () => { 45 + const result = lx.integer({ enum: [1, 2, 3, 5, 8, 13] }); 46 + expect(result).toEqual({ type: "integer", enum: [1, 2, 3, 5, 8, 13] }); 47 + }); 48 + 49 + test("lx.integer() with default", () => { 50 + const result = lx.integer({ default: 42 }); 51 + expect(result).toEqual({ type: "integer", default: 42 }); 52 + }); 53 + 54 + test("lx.integer() with const", () => { 55 + const result = lx.integer({ const: 7 }); 56 + expect(result).toEqual({ type: "integer", const: 7 }); 57 + }); 58 + 59 + test("lx.string()", () => { 60 + const result = lx.string(); 61 + expect(result).toEqual({ type: "string" }); 62 + }); 63 + 64 + test("lx.string() with maxLength", () => { 65 + const result = lx.string({ maxLength: 64 }); 66 + expect(result).toEqual({ type: "string", maxLength: 64 }); 67 + }); 68 + 69 + test("lx.string() with enum", () => { 70 + const result = lx.string({ enum: ["light", "dark", "auto"] }); 71 + expect(result).toEqual({ type: "string", enum: ["light", "dark", "auto"] }); 72 + }); 73 + 74 + test("lx.unknown()", () => { 75 + const result = lx.unknown(); 76 + expect(result).toEqual({ type: "unknown" }); 77 + }); 78 + 79 + test("lx.bytes()", () => { 80 + const result = lx.bytes(); 81 + expect(result).toEqual({ type: "bytes" }); 82 + }); 83 + 84 + test("lx.bytes() with minLength", () => { 85 + const result = lx.bytes({ minLength: 1 }); 86 + expect(result).toEqual({ type: "bytes", minLength: 1 }); 87 + }); 88 + 89 + test("lx.bytes() with maxLength", () => { 90 + const result = lx.bytes({ maxLength: 1024 }); 91 + expect(result).toEqual({ type: "bytes", maxLength: 1024 }); 92 + }); 93 + 94 + test("lx.bytes() with minLength and maxLength", () => { 95 + const result = lx.bytes({ minLength: 1, maxLength: 1024 }); 96 + expect(result).toEqual({ type: "bytes", minLength: 1, maxLength: 1024 }); 97 + }); 98 + 99 + test("lx.cidLink()", () => { 100 + const result = lx.cidLink( 101 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 102 + ); 103 + expect(result).toEqual({ 104 + type: "cid-link", 105 + $link: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 106 + }); 107 + }); 108 + 109 + test("lx.blob()", () => { 110 + const result = lx.blob(); 111 + expect(result).toEqual({ type: "blob" }); 112 + }); 113 + 114 + test("lx.blob() with accept", () => { 115 + const result = lx.blob({ accept: ["image/png", "image/jpeg"] }); 116 + expect(result).toEqual({ 117 + type: "blob", 118 + accept: ["image/png", "image/jpeg"], 119 + }); 120 + }); 121 + 122 + test("lx.blob() with maxSize", () => { 123 + const result = lx.blob({ maxSize: 1000000 }); 124 + expect(result).toEqual({ type: "blob", maxSize: 1000000 }); 125 + }); 126 + 127 + test("lx.blob() with accept and maxSize", () => { 128 + const result = lx.blob({ 129 + accept: ["image/png", "image/jpeg"], 130 + maxSize: 5000000, 131 + }); 132 + expect(result).toEqual({ 133 + type: "blob", 134 + accept: ["image/png", "image/jpeg"], 135 + maxSize: 5000000, 136 + }); 137 + }); 138 + 139 + test("lx.array() with string items", () => { 140 + const result = lx.array(lx.string()); 141 + expect(result).toEqual({ type: "array", items: { type: "string" } }); 142 + }); 143 + 144 + test("lx.array() with integer items", () => { 145 + const result = lx.array(lx.integer()); 146 + expect(result).toEqual({ type: "array", items: { type: "integer" } }); 147 + }); 148 + 149 + test("lx.array() with minLength", () => { 150 + const result = lx.array(lx.string(), { minLength: 1 }); 151 + expect(result).toEqual({ 152 + type: "array", 153 + items: { type: "string" }, 154 + minLength: 1, 155 + }); 156 + }); 157 + 158 + test("lx.array() with maxLength", () => { 159 + const result = lx.array(lx.string(), { maxLength: 10 }); 160 + expect(result).toEqual({ 161 + type: "array", 162 + items: { type: "string" }, 163 + maxLength: 10, 164 + }); 165 + }); 166 + 167 + test("lx.array() with minLength and maxLength", () => { 168 + const result = lx.array(lx.string(), { minLength: 1, maxLength: 10 }); 169 + expect(result).toEqual({ 170 + type: "array", 171 + items: { type: "string" }, 172 + minLength: 1, 173 + maxLength: 10, 174 + }); 175 + }); 176 + 177 + test("lx.array() with required", () => { 178 + const result = lx.array(lx.string(), { required: true }); 179 + expect(result).toEqual({ 180 + type: "array", 181 + items: { type: "string" }, 182 + required: true, 183 + }); 184 + }); 185 + 186 + test("lx.token() with interaction event", () => { 187 + const result = lx.token( 188 + "Request that less content like the given feed item be shown in the feed", 189 + ); 190 + expect(result).toEqual({ 191 + type: "token", 192 + description: 193 + "Request that less content like the given feed item be shown in the feed", 194 + }); 195 + }); 196 + 197 + test("lx.token() with content mode", () => { 198 + const result = lx.token( 199 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds", 200 + ); 201 + expect(result).toEqual({ 202 + type: "token", 203 + description: 204 + "Declares the feed generator returns posts containing app.bsky.embed.video embeds", 205 + }); 206 + }); 207 + 208 + test("lx.ref() with local definition", () => { 209 + const result = lx.ref("#profileAssociated"); 210 + expect(result).toEqual({ 211 + type: "ref", 212 + ref: "#profileAssociated", 213 + }); 214 + }); 215 + 216 + test("lx.ref() with external schema", () => { 217 + const result = lx.ref("com.atproto.label.defs#label"); 218 + expect(result).toEqual({ 219 + type: "ref", 220 + ref: "com.atproto.label.defs#label", 221 + }); 222 + }); 223 + 224 + test("lx.ref() with required option", () => { 225 + const result = lx.ref("#profileView", { required: true }); 226 + expect(result).toEqual({ 227 + type: "ref", 228 + ref: "#profileView", 229 + required: true, 230 + }); 231 + }); 232 + 233 + test("lx.ref() with nullable option", () => { 234 + const result = lx.ref("#profileView", { nullable: true }); 235 + expect(result).toEqual({ 236 + type: "ref", 237 + ref: "#profileView", 238 + nullable: true, 239 + }); 240 + }); 241 + 242 + test("lx.ref() with both required and nullable", () => { 243 + const result = lx.ref("app.bsky.actor.defs#profileView", { 244 + required: true, 245 + nullable: true, 246 + }); 247 + expect(result).toEqual({ 248 + type: "ref", 249 + ref: "app.bsky.actor.defs#profileView", 250 + required: true, 251 + nullable: true, 252 + }); 253 + }); 254 + 255 + test("lx.union() with local refs", () => { 256 + const result = lx.union(["#reasonRepost", "#reasonPin"]); 257 + expect(result).toEqual({ 258 + type: "union", 259 + refs: ["#reasonRepost", "#reasonPin"], 260 + }); 261 + }); 262 + 263 + test("lx.union() with external refs", () => { 264 + const result = lx.union([ 265 + "app.bsky.embed.images#view", 266 + "app.bsky.embed.video#view", 267 + "app.bsky.embed.external#view", 268 + "app.bsky.embed.record#view", 269 + "app.bsky.embed.recordWithMedia#view", 270 + ]); 271 + expect(result).toEqual({ 272 + type: "union", 273 + refs: [ 274 + "app.bsky.embed.images#view", 275 + "app.bsky.embed.video#view", 276 + "app.bsky.embed.external#view", 277 + "app.bsky.embed.record#view", 278 + "app.bsky.embed.recordWithMedia#view", 279 + ], 280 + }); 281 + }); 282 + 283 + test("lx.union() with closed option", () => { 284 + const result = lx.union(["#postView", "#notFoundPost", "#blockedPost"], { 285 + closed: true, 286 + }); 287 + expect(result).toEqual({ 288 + type: "union", 289 + refs: ["#postView", "#notFoundPost", "#blockedPost"], 290 + closed: true, 291 + }); 292 + }); 293 + 294 + test("lx.union() with closed: false (open union)", () => { 295 + const result = lx.union(["#threadViewPost", "#notFoundPost"], { 296 + closed: false, 297 + }); 298 + expect(result).toEqual({ 299 + type: "union", 300 + refs: ["#threadViewPost", "#notFoundPost"], 301 + closed: false, 302 + }); 303 + }); 304 + 305 + test("lx.params() with basic properties", () => { 306 + const result = lx.params({ 307 + q: lx.string(), 308 + limit: lx.integer(), 309 + }); 310 + expect(result).toEqual({ 311 + type: "params", 312 + properties: { 313 + q: { type: "string" }, 314 + limit: { type: "integer" }, 315 + }, 316 + }); 317 + }); 318 + 319 + test("lx.params() with required properties", () => { 320 + const result = lx.params({ 321 + q: lx.string({ required: true }), 322 + limit: lx.integer(), 323 + }); 324 + expect(result).toEqual({ 325 + type: "params", 326 + properties: { 327 + q: { type: "string", required: true }, 328 + limit: { type: "integer" }, 329 + }, 330 + required: ["q"], 331 + }); 332 + }); 333 + 334 + test("lx.params() with property options", () => { 335 + const result = lx.params({ 336 + q: lx.string(), 337 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 338 + cursor: lx.string(), 339 + }); 340 + expect(result).toEqual({ 341 + type: "params", 342 + properties: { 343 + q: { type: "string" }, 344 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 345 + cursor: { type: "string" }, 346 + }, 347 + }); 348 + }); 349 + 350 + test("lx.params() with array properties", () => { 351 + const result = lx.params({ 352 + tags: lx.array(lx.string()), 353 + ids: lx.array(lx.integer()), 354 + }); 355 + expect(result).toEqual({ 356 + type: "params", 357 + properties: { 358 + tags: { type: "array", items: { type: "string" } }, 359 + ids: { type: "array", items: { type: "integer" } }, 360 + }, 361 + }); 362 + }); 363 + 364 + test("lx.params() real-world example from searchActors", () => { 365 + const result = lx.params({ 366 + q: lx.string({ required: true }), 367 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 368 + cursor: lx.string(), 369 + }); 370 + expect(result).toEqual({ 371 + type: "params", 372 + properties: { 373 + q: { type: "string", required: true }, 374 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 375 + cursor: { type: "string" }, 376 + }, 377 + required: ["q"], 378 + }); 379 + }); 380 + 381 + test("lx.query() basic", () => { 382 + const result = lx.query(); 383 + expect(result).toEqual({ type: "query" }); 384 + }); 385 + 386 + test("lx.query() with description", () => { 387 + const result = lx.query({ description: "Search for actors" }); 388 + expect(result).toEqual({ type: "query", description: "Search for actors" }); 389 + }); 390 + 391 + test("lx.query() with parameters", () => { 392 + const result = lx.query({ 393 + parameters: lx.params({ 394 + q: lx.string({ required: true }), 395 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 396 + }), 397 + }); 398 + expect(result).toEqual({ 399 + type: "query", 400 + parameters: { 401 + type: "params", 402 + properties: { 403 + q: { type: "string", required: true }, 404 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 405 + }, 406 + required: ["q"], 407 + }, 408 + }); 409 + }); 410 + 411 + test("lx.query() with output", () => { 412 + const result = lx.query({ 413 + output: { 414 + encoding: "application/json", 415 + schema: lx.object({ 416 + posts: lx.array(lx.ref("app.bsky.feed.defs#postView"), { 417 + required: true, 418 + }), 419 + cursor: lx.string(), 420 + }), 421 + }, 422 + }); 423 + expect(result).toEqual({ 424 + type: "query", 425 + output: { 426 + encoding: "application/json", 427 + schema: { 428 + type: "object", 429 + properties: { 430 + posts: { 431 + type: "array", 432 + items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 433 + required: true, 434 + }, 435 + cursor: { type: "string" }, 436 + }, 437 + required: ["posts"], 438 + }, 439 + }, 440 + }); 441 + }); 442 + 443 + test("lx.query() with errors", () => { 444 + const result = lx.query({ 445 + errors: [{ name: "BadQueryString" }], 446 + }); 447 + expect(result).toEqual({ 448 + type: "query", 449 + errors: [{ name: "BadQueryString" }], 450 + }); 451 + }); 452 + 453 + test("lx.query() real-world example: searchPosts", () => { 454 + const result = lx.query({ 455 + description: "Find posts matching search criteria", 456 + parameters: lx.params({ 457 + q: lx.string({ required: true }), 458 + sort: lx.string({ enum: ["top", "latest"], default: "latest" }), 459 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 460 + cursor: lx.string(), 461 + }), 462 + output: { 463 + encoding: "application/json", 464 + schema: lx.object({ 465 + cursor: lx.string(), 466 + hitsTotal: lx.integer(), 467 + posts: lx.array(lx.ref("app.bsky.feed.defs#postView"), { 468 + required: true, 469 + }), 470 + }), 471 + }, 472 + errors: [{ name: "BadQueryString" }], 473 + }); 474 + expect(result).toEqual({ 475 + type: "query", 476 + description: "Find posts matching search criteria", 477 + parameters: { 478 + type: "params", 479 + properties: { 480 + q: { type: "string", required: true }, 481 + sort: { type: "string", enum: ["top", "latest"], default: "latest" }, 482 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 483 + cursor: { type: "string" }, 484 + }, 485 + required: ["q"], 486 + }, 487 + output: { 488 + encoding: "application/json", 489 + schema: { 490 + type: "object", 491 + properties: { 492 + cursor: { type: "string" }, 493 + hitsTotal: { type: "integer" }, 494 + posts: { 495 + type: "array", 496 + items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 497 + required: true, 498 + }, 499 + }, 500 + required: ["posts"], 501 + }, 502 + }, 503 + errors: [{ name: "BadQueryString" }], 504 + }); 505 + }); 506 + 507 + test("lx.procedure() basic", () => { 508 + const result = lx.procedure(); 509 + expect(result).toEqual({ type: "procedure" }); 510 + }); 511 + 512 + test("lx.procedure() with description", () => { 513 + const result = lx.procedure({ description: "Create a new post" }); 514 + expect(result).toEqual({ 515 + type: "procedure", 516 + description: "Create a new post", 517 + }); 518 + }); 519 + 520 + test("lx.procedure() with parameters", () => { 521 + const result = lx.procedure({ 522 + parameters: lx.params({ 523 + validate: lx.boolean({ default: true }), 524 + }), 525 + }); 526 + expect(result).toEqual({ 527 + type: "procedure", 528 + parameters: { 529 + type: "params", 530 + properties: { 531 + validate: { type: "boolean", default: true }, 532 + }, 533 + }, 534 + }); 535 + }); 536 + 537 + test("lx.procedure() with input", () => { 538 + const result = lx.procedure({ 539 + input: { 540 + encoding: "application/json", 541 + schema: lx.object({ 542 + text: lx.string({ required: true, maxGraphemes: 300 }), 543 + createdAt: lx.string({ format: "datetime" }), 544 + }), 545 + }, 546 + }); 547 + expect(result).toEqual({ 548 + type: "procedure", 549 + input: { 550 + encoding: "application/json", 551 + schema: { 552 + type: "object", 553 + properties: { 554 + text: { type: "string", required: true, maxGraphemes: 300 }, 555 + createdAt: { type: "string", format: "datetime" }, 556 + }, 557 + required: ["text"], 558 + }, 559 + }, 560 + }); 561 + }); 562 + 563 + test("lx.procedure() with output", () => { 564 + const result = lx.procedure({ 565 + output: { 566 + encoding: "application/json", 567 + schema: lx.object({ 568 + uri: lx.string({ required: true }), 569 + cid: lx.string({ required: true }), 570 + }), 571 + }, 572 + }); 573 + expect(result).toEqual({ 574 + type: "procedure", 575 + output: { 576 + encoding: "application/json", 577 + schema: { 578 + type: "object", 579 + properties: { 580 + uri: { type: "string", required: true }, 581 + cid: { type: "string", required: true }, 582 + }, 583 + required: ["uri", "cid"], 584 + }, 585 + }, 586 + }); 587 + }); 588 + 589 + test("lx.procedure() with errors", () => { 590 + const result = lx.procedure({ 591 + errors: [ 592 + { name: "InvalidRequest" }, 593 + { name: "RateLimitExceeded", description: "Too many requests" }, 594 + ], 595 + }); 596 + expect(result).toEqual({ 597 + type: "procedure", 598 + errors: [ 599 + { name: "InvalidRequest" }, 600 + { name: "RateLimitExceeded", description: "Too many requests" }, 601 + ], 602 + }); 603 + }); 604 + 605 + test("lx.procedure() real-world example: createPost", () => { 606 + const result = lx.procedure({ 607 + description: "Create a post", 608 + input: { 609 + encoding: "application/json", 610 + schema: lx.object({ 611 + repo: lx.string({ required: true }), 612 + collection: lx.string({ required: true }), 613 + record: lx.unknown({ required: true }), 614 + validate: lx.boolean({ default: true }), 615 + }), 616 + }, 617 + output: { 618 + encoding: "application/json", 619 + schema: lx.object({ 620 + uri: lx.string({ required: true }), 621 + cid: lx.string({ required: true }), 622 + }), 623 + }, 624 + errors: [{ name: "InvalidSwap" }, { name: "InvalidRecord" }], 625 + }); 626 + expect(result).toEqual({ 627 + type: "procedure", 628 + description: "Create a post", 629 + input: { 630 + encoding: "application/json", 631 + schema: { 632 + type: "object", 633 + properties: { 634 + repo: { type: "string", required: true }, 635 + collection: { type: "string", required: true }, 636 + record: { type: "unknown", required: true }, 637 + validate: { type: "boolean", default: true }, 638 + }, 639 + required: ["repo", "collection", "record"], 640 + }, 641 + }, 642 + output: { 643 + encoding: "application/json", 644 + schema: { 645 + type: "object", 646 + properties: { 647 + uri: { type: "string", required: true }, 648 + cid: { type: "string", required: true }, 649 + }, 650 + required: ["uri", "cid"], 651 + }, 652 + }, 653 + errors: [{ name: "InvalidSwap" }, { name: "InvalidRecord" }], 654 + }); 655 + }); 656 + 657 + test("lx.subscription() basic", () => { 658 + const result = lx.subscription(); 659 + expect(result).toEqual({ type: "subscription" }); 660 + }); 661 + 662 + test("lx.subscription() with description", () => { 663 + const result = lx.subscription({ 664 + description: "Repository event stream", 665 + }); 666 + expect(result).toEqual({ 667 + type: "subscription", 668 + description: "Repository event stream", 669 + }); 670 + }); 671 + 672 + test("lx.subscription() with parameters", () => { 673 + const result = lx.subscription({ 674 + parameters: lx.params({ 675 + cursor: lx.integer(), 676 + }), 677 + }); 678 + expect(result).toEqual({ 679 + type: "subscription", 680 + parameters: { 681 + type: "params", 682 + properties: { 683 + cursor: { type: "integer" }, 684 + }, 685 + }, 686 + }); 687 + }); 688 + 689 + test("lx.subscription() with message", () => { 690 + const result = lx.subscription({ 691 + message: { 692 + schema: lx.union(["#commit", "#identity", "#account"]), 693 + }, 694 + }); 695 + expect(result).toEqual({ 696 + type: "subscription", 697 + message: { 698 + schema: { 699 + type: "union", 700 + refs: ["#commit", "#identity", "#account"], 701 + }, 702 + }, 703 + }); 704 + }); 705 + 706 + test("lx.subscription() with message description", () => { 707 + const result = lx.subscription({ 708 + message: { 709 + description: "Event message types", 710 + schema: lx.union(["#commit", "#handle", "#migrate"]), 711 + }, 712 + }); 713 + expect(result).toEqual({ 714 + type: "subscription", 715 + message: { 716 + description: "Event message types", 717 + schema: { 718 + type: "union", 719 + refs: ["#commit", "#handle", "#migrate"], 720 + }, 721 + }, 722 + }); 723 + }); 724 + 725 + test("lx.subscription() with errors", () => { 726 + const result = lx.subscription({ 727 + errors: [ 728 + { name: "FutureCursor" }, 729 + { name: "ConsumerTooSlow", description: "Consumer is too slow" }, 730 + ], 731 + }); 732 + expect(result).toEqual({ 733 + type: "subscription", 734 + errors: [ 735 + { name: "FutureCursor" }, 736 + { name: "ConsumerTooSlow", description: "Consumer is too slow" }, 737 + ], 738 + }); 739 + }); 740 + 741 + test("lx.subscription() real-world example: subscribeRepos", () => { 742 + const result = lx.subscription({ 743 + description: "Repository event stream, aka Firehose endpoint", 744 + parameters: lx.params({ 745 + cursor: lx.integer(), 746 + }), 747 + message: { 748 + description: "Represents an update of repository state", 749 + schema: lx.union([ 750 + "#commit", 751 + "#identity", 752 + "#account", 753 + "#handle", 754 + "#migrate", 755 + "#tombstone", 756 + "#info", 757 + ]), 758 + }, 759 + errors: [{ name: "FutureCursor" }, { name: "ConsumerTooSlow" }], 760 + }); 761 + expect(result).toEqual({ 762 + type: "subscription", 763 + description: "Repository event stream, aka Firehose endpoint", 764 + parameters: { 765 + type: "params", 766 + properties: { 767 + cursor: { 768 + type: "integer", 769 + }, 770 + }, 771 + }, 772 + message: { 773 + description: "Represents an update of repository state", 774 + schema: { 775 + type: "union", 776 + refs: [ 777 + "#commit", 778 + "#identity", 779 + "#account", 780 + "#handle", 781 + "#migrate", 782 + "#tombstone", 783 + "#info", 784 + ], 785 + }, 786 + }, 787 + errors: [{ name: "FutureCursor" }, { name: "ConsumerTooSlow" }], 788 + }); 789 + });
+17
packages/prototypey/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "declaration": true, 4 + "declarationMap": true, 5 + "esModuleInterop": true, 6 + "module": "NodeNext", 7 + "moduleResolution": "NodeNext", 8 + "noEmit": true, 9 + "resolveJsonModule": true, 10 + "noErrorTruncation": true, 11 + "skipLibCheck": true, 12 + "strict": true, 13 + "target": "ES2022", 14 + "allowImportingTsExtensions": true 15 + }, 16 + "include": ["src", "tests"] 17 + }
+8
packages/prototypey/tsdown.config.ts
··· 1 + import { defineConfig } from "tsdown"; 2 + 3 + export default defineConfig({ 4 + dts: true, 5 + entry: ["src/index.ts"], 6 + outDir: "lib", 7 + unbundle: true, 8 + });
+8
packages/prototypey/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ["tests/*.test.ts"], 6 + globalSetup: ["setup-vitest.ts"], 7 + }, 8 + });
+30 -12
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + devDependencies: 11 + '@eslint/js': 12 + specifier: 9.29.0 13 + version: 9.29.0 14 + eslint: 15 + specifier: 9.29.0 16 + version: 9.29.0(jiti@2.6.1) 17 + prettier: 18 + specifier: 3.6.1 19 + version: 3.6.1 20 + typescript-eslint: 21 + specifier: 8.35.0 22 + version: 8.35.0(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3) 23 + 24 + packages/cli: 10 25 dependencies: 26 + prototypey: 27 + specifier: workspace:* 28 + version: link:../prototypey 11 29 sade: 12 30 specifier: ^1.8.1 13 31 version: 1.8.1 ··· 15 33 specifier: ^0.2.15 16 34 version: 0.2.15 17 35 devDependencies: 36 + '@types/node': 37 + specifier: 24.0.4 38 + version: 24.0.4 39 + tsdown: 40 + specifier: 0.12.7 41 + version: 0.12.7(typescript@5.8.3) 42 + typescript: 43 + specifier: 5.8.3 44 + version: 5.8.3 45 + 46 + packages/prototypey: 47 + devDependencies: 18 48 '@ark/attest': 19 49 specifier: ^0.49.0 20 50 version: 0.49.0(typescript@5.8.3) 21 - '@eslint/js': 22 - specifier: 9.29.0 23 - version: 9.29.0 24 51 '@types/node': 25 52 specifier: 24.0.4 26 53 version: 24.0.4 27 - eslint: 28 - specifier: 9.29.0 29 - version: 9.29.0(jiti@2.6.1) 30 - prettier: 31 - specifier: 3.6.1 32 - version: 3.6.1 33 54 tsdown: 34 55 specifier: 0.12.7 35 56 version: 0.12.7(typescript@5.8.3) 36 57 typescript: 37 58 specifier: 5.8.3 38 59 version: 5.8.3 39 - typescript-eslint: 40 - specifier: 8.35.0 41 - version: 8.35.0(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3) 42 60 vitest: 43 61 specifier: ^3.2.4 44 62 version: 3.2.4(@types/node@24.0.4)(jiti@2.6.1)
+2
pnpm-workspace.yaml
··· 1 + packages: 2 + - 'packages/*'