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

Configure Feed

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

Merge pull request #9 from tylersayshi/emit

emit cli & pnpm monorepo refactor

authored by

tyler and committed by
GitHub
ce934b8b 8303c7e0

+1597 -37
+5 -2
.github/workflows/ci.yml
··· 6 6 - uses: actions/checkout@v4 7 7 - uses: ./.github/actions/prepare 8 8 - run: pnpm build 9 - - run: node lib/index.js 9 + - run: node packages/prototypey/lib/index.js 10 10 lint: 11 11 name: Lint 12 12 runs-on: ubuntu-latest ··· 27 27 steps: 28 28 - uses: actions/checkout@v4 29 29 - uses: ./.github/actions/prepare 30 + - run: pnpm build 30 31 - run: pnpm tsc 31 32 test: 32 33 name: Test ··· 34 35 steps: 35 36 - uses: actions/checkout@v4 36 37 - uses: ./.github/actions/prepare 38 + - run: pnpm build 39 + - run: pnpm codegen:samples 37 40 - run: pnpm test 38 41 benchmark_types: 39 42 name: Benchmark Types ··· 41 44 steps: 42 45 - uses: actions/checkout@v4 43 46 - uses: ./.github/actions/prepare 44 - - run: pnpm test:bench 47 + - run: pnpm -F prototypey test:bench 45 48 46 49 name: CI 47 50
+4 -3
.gitignore
··· 1 - /lib 2 - /node_modules 3 - /.attest 1 + lib/ 2 + node_modules/ 3 + .attest/ 4 + generated/
+9 -22
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 - "files": [ 17 - "LICENSE.md", 18 - "README.md", 19 - "lib/", 20 - "package.json" 21 - ], 22 15 "scripts": { 23 - "build": "tsdown", 16 + "build": "pnpm -r build", 17 + "codegen:samples": "node packages/cli/src/index.ts gen-inferred ./generated/inferred './samples/*.json'", 24 18 "format": "prettier . --list-different", 25 19 "format:fix": "prettier . --write", 26 - "lint": "eslint . --max-warnings 0", 27 - "test": "vitest run", 28 - "test:bench": "node tests/infer.bench.ts", 29 - "test:update-snapshots": "vitest run -u", 30 - "tsc": "tsc" 20 + "lint": "pnpm -r lint", 21 + "test": "pnpm -r test", 22 + "tsc": "pnpm -r tsc" 31 23 }, 32 24 "devDependencies": { 33 - "@ark/attest": "^0.49.0", 34 25 "@eslint/js": "9.29.0", 35 - "@types/node": "24.0.4", 36 26 "eslint": "9.29.0", 37 27 "prettier": "3.6.1", 38 - "tsdown": "0.12.7", 39 - "typescript": "5.8.3", 40 - "typescript-eslint": "8.35.0", 41 - "vitest": "^3.2.4" 28 + "typescript-eslint": "8.35.0" 42 29 }, 43 30 "packageManager": "pnpm@10.4.0", 44 31 "engines": {
+100
packages/cli/README.md
··· 1 + # @prototypey/cli 2 + 3 + CLI tool for generating types from ATProto lexicon schemas. 4 + 5 + ## Installation 6 + 7 + ```bash 8 + npm install -g @prototypey/cli 9 + ``` 10 + 11 + Or use directly with npx: 12 + 13 + ```bash 14 + npx @prototypey/cli 15 + ``` 16 + 17 + ## Commands 18 + 19 + ### `gen-inferred` 20 + 21 + Generate type-inferred TypeScript code from JSON lexicon schemas. 22 + 23 + **Usage:** 24 + 25 + ```bash 26 + prototypey gen-inferred <outdir> <schemas...> 27 + ``` 28 + 29 + **Arguments:** 30 + 31 + - `outdir` - Output directory for generated TypeScript files 32 + - `schemas...` - One or more glob patterns matching lexicon JSON schema files 33 + 34 + **Example:** 35 + 36 + ```bash 37 + prototypey gen-inferred ./generated/inferred ./lexicons/**/*.json 38 + ``` 39 + 40 + **What it does:** 41 + 42 + - Reads ATProto lexicon JSON schemas 43 + - Generates TypeScript types that match the schema structure 44 + - Organizes output files by namespace (e.g., `app.bsky.feed.post` → `app/bsky/feed/post.ts`) 45 + - Provides type-safe interfaces for working with lexicon data 46 + 47 + ### `gen-emit` 48 + 49 + Emit JSON lexicon schemas from authored TypeScript files. 50 + 51 + **Usage:** 52 + 53 + ```bash 54 + prototypey gen-emit <outdir> <sources...> 55 + ``` 56 + 57 + **Arguments:** 58 + 59 + - `outdir` - Output directory for emitted JSON schema files 60 + - `sources...` - One or more glob patterns matching TypeScript source files 61 + 62 + **Example:** 63 + 64 + ```bash 65 + prototypey gen-emit ./lexicons ./src/lexicons/**/*.ts 66 + ``` 67 + 68 + **What it does:** 69 + 70 + - Scans TypeScript files for exported lexicon namespace definitions 71 + - Extracts the `.json` property from each namespace 72 + - Emits properly formatted JSON lexicon schema files 73 + - Names output files by lexicon ID (e.g., `app.bsky.feed.post.json`) 74 + 75 + ## Workflow 76 + 77 + The typical workflow combines both commands for bidirectional type safety: 78 + 79 + 1. **Author lexicons in TypeScript** using the `prototypey` library 80 + 2. **Emit to JSON** with `gen-emit` for runtime validation and API contracts 81 + 3. **Generate inferred types** with `gen-inferred` for consuming code 82 + 83 + ```bash 84 + # Write your lexicons in TypeScript 85 + # src/lexicons/app.bsky.actor.profile.ts 86 + 87 + # Emit JSON schemas 88 + prototypey gen-emit ./schemas ./src/lexicons/**/*.ts 89 + 90 + # Generate TypeScript types from schemas 91 + prototypey gen-inferred ./generated ./schemas/**/*.json 92 + ``` 93 + 94 + ## Requirements 95 + 96 + - Node.js >= 20.19.0 97 + 98 + ## License 99 + 100 + MIT
+42
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 + "test": "vitest run", 26 + "tsc": "tsc" 27 + }, 28 + "dependencies": { 29 + "prototypey": "workspace:*", 30 + "sade": "^1.8.1", 31 + "tinyglobby": "^0.2.15" 32 + }, 33 + "devDependencies": { 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 + }
+102
packages/cli/src/commands/gen-emit.ts
··· 1 + import { glob } from "tinyglobby"; 2 + import { mkdir, writeFile } from "node:fs/promises"; 3 + import { join } from "node:path"; 4 + import { pathToFileURL } from "node:url"; 5 + 6 + interface LexiconNamespace { 7 + json: { 8 + lexicon: number; 9 + id: string; 10 + defs: Record<string, unknown>; 11 + }; 12 + } 13 + 14 + export async function genEmit( 15 + outdir: string, 16 + sources: string | string[], 17 + ): Promise<void> { 18 + try { 19 + const sourcePatterns = Array.isArray(sources) ? sources : [sources]; 20 + 21 + // Find all source files matching the patterns 22 + const sourceFiles = await glob(sourcePatterns, { 23 + absolute: true, 24 + onlyFiles: true, 25 + }); 26 + 27 + if (sourceFiles.length === 0) { 28 + console.log("No source files found matching patterns:", sourcePatterns); 29 + return; 30 + } 31 + 32 + console.log(`Found ${sourceFiles.length} source file(s)`); 33 + 34 + // Ensure output directory exists 35 + await mkdir(outdir, { recursive: true }); 36 + 37 + // Process each source file 38 + for (const sourcePath of sourceFiles) { 39 + await processSourceFile(sourcePath, outdir); 40 + } 41 + 42 + console.log(`\nEmitted lexicon schemas to ${outdir}`); 43 + } catch (error) { 44 + console.error("Error emitting lexicon schemas:", error); 45 + process.exit(1); 46 + } 47 + } 48 + 49 + async function processSourceFile( 50 + sourcePath: string, 51 + outdir: string, 52 + ): Promise<void> { 53 + try { 54 + // Convert file path to file URL for dynamic import 55 + const fileUrl = pathToFileURL(sourcePath).href; 56 + 57 + // Dynamically import the module 58 + const module = await import(fileUrl); 59 + 60 + // Find all exported namespaces 61 + const namespaces: LexiconNamespace[] = []; 62 + for (const key of Object.keys(module)) { 63 + const exported = module[key]; 64 + // Check if it's a namespace with a json property 65 + if ( 66 + exported && 67 + typeof exported === "object" && 68 + "json" in exported && 69 + exported.json && 70 + typeof exported.json === "object" && 71 + "lexicon" in exported.json && 72 + "id" in exported.json && 73 + "defs" in exported.json 74 + ) { 75 + namespaces.push(exported as LexiconNamespace); 76 + } 77 + } 78 + 79 + if (namespaces.length === 0) { 80 + console.warn(` ⚠ ${sourcePath}: No lexicon namespaces found`); 81 + return; 82 + } 83 + 84 + // Emit JSON for each namespace 85 + for (const namespace of namespaces) { 86 + const { id } = namespace.json; 87 + const outputPath = join(outdir, `${id}.json`); 88 + 89 + // Write the JSON file 90 + await writeFile( 91 + outputPath, 92 + JSON.stringify(namespace.json, null, "\t"), 93 + "utf-8", 94 + ); 95 + 96 + console.log(` ✓ ${id} -> ${id}.json`); 97 + } 98 + } catch (error) { 99 + console.error(` ✗ Error processing ${sourcePath}:`, error); 100 + throw error; 101 + } 102 + }
+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 + }
+28
packages/cli/src/index.ts
··· 1 + import { readFile } from "node:fs/promises"; 2 + import sade from "sade"; 3 + import { genInferred } from "./commands/gen-inferred.ts"; 4 + import { genEmit } from "./commands/gen-emit.ts"; 5 + 6 + const pkg = JSON.parse( 7 + await readFile(new URL("../package.json", import.meta.url), "utf-8"), 8 + ) as { version: string }; 9 + 10 + const prog = sade("prototypey"); 11 + 12 + prog 13 + .version(pkg.version) 14 + .describe("Type-safe lexicon inference and code generation"); 15 + 16 + prog 17 + .command("gen-inferred <outdir> <schemas...>") 18 + .describe("Generate type-inferred code from lexicon schemas") 19 + .example("gen-inferred ./generated/inferred ./lexicons/**/*.json") 20 + .action(genInferred); 21 + 22 + prog 23 + .command("gen-emit <outdir> <sources...>") 24 + .describe("Emit JSON lexicon schemas from authored TypeScript") 25 + .example("gen-emit ./lexicons ./src/lexicons/**/*.ts") 26 + .action(genEmit); 27 + 28 + 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 + }
+525
packages/cli/tests/commands/gen-emit.test.ts
··· 1 + import { expect, test, describe, beforeEach, afterEach } from "vitest"; 2 + import { mkdir, writeFile, rm, readFile } from "node:fs/promises"; 3 + import { join } from "node:path"; 4 + import { genEmit } from "../../src/commands/gen-emit.ts"; 5 + import { tmpdir } from "node:os"; 6 + 7 + describe("genEmit", () => { 8 + let testDir: string; 9 + let outDir: string; 10 + 11 + beforeEach(async () => { 12 + // Create a temporary directory for test files 13 + testDir = join(tmpdir(), `prototypey-test-${Date.now()}`); 14 + outDir = join(testDir, "output"); 15 + await mkdir(testDir, { recursive: true }); 16 + await mkdir(outDir, { recursive: true }); 17 + }); 18 + 19 + afterEach(async () => { 20 + // Clean up test directory 21 + await rm(testDir, { recursive: true, force: true }); 22 + }); 23 + 24 + test("emits JSON from a simple lexicon file", async () => { 25 + // Create a test lexicon file 26 + const lexiconFile = join(testDir, "profile.ts"); 27 + await writeFile( 28 + lexiconFile, 29 + ` 30 + import { lx } from "prototypey"; 31 + 32 + export const profileNamespace = lx.namespace("app.bsky.actor.profile", { 33 + main: lx.record({ 34 + key: "self", 35 + record: lx.object({ 36 + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), 37 + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), 38 + }), 39 + }), 40 + }); 41 + `, 42 + ); 43 + 44 + // Run the emit command 45 + await genEmit(outDir, lexiconFile); 46 + 47 + // Read the emitted JSON file 48 + const outputFile = join(outDir, "app.bsky.actor.profile.json"); 49 + const content = await readFile(outputFile, "utf-8"); 50 + const json = JSON.parse(content); 51 + 52 + // Verify the structure 53 + expect(json).toEqual({ 54 + lexicon: 1, 55 + id: "app.bsky.actor.profile", 56 + defs: { 57 + main: { 58 + type: "record", 59 + key: "self", 60 + record: { 61 + type: "object", 62 + properties: { 63 + displayName: { 64 + type: "string", 65 + maxLength: 64, 66 + maxGraphemes: 64, 67 + }, 68 + description: { 69 + type: "string", 70 + maxLength: 256, 71 + maxGraphemes: 256, 72 + }, 73 + }, 74 + }, 75 + }, 76 + }, 77 + }); 78 + }); 79 + 80 + test("emits JSON from multiple lexicon exports in one file", async () => { 81 + // Create a test file with multiple exports 82 + const lexiconFile = join(testDir, "multiple.ts"); 83 + await writeFile( 84 + lexiconFile, 85 + ` 86 + import { lx } from "prototypey"; 87 + 88 + export const profile = lx.namespace("app.bsky.actor.profile", { 89 + main: lx.record({ 90 + key: "self", 91 + record: lx.object({ 92 + displayName: lx.string({ maxLength: 64 }), 93 + }), 94 + }), 95 + }); 96 + 97 + export const post = lx.namespace("app.bsky.feed.post", { 98 + main: lx.record({ 99 + key: "tid", 100 + record: lx.object({ 101 + text: lx.string({ maxLength: 300 }), 102 + }), 103 + }), 104 + }); 105 + `, 106 + ); 107 + 108 + // Run the emit command 109 + await genEmit(outDir, lexiconFile); 110 + 111 + // Verify both files were created 112 + const profileJson = JSON.parse( 113 + await readFile(join(outDir, "app.bsky.actor.profile.json"), "utf-8"), 114 + ); 115 + const postJson = JSON.parse( 116 + await readFile(join(outDir, "app.bsky.feed.post.json"), "utf-8"), 117 + ); 118 + 119 + expect(profileJson.id).toBe("app.bsky.actor.profile"); 120 + expect(postJson.id).toBe("app.bsky.feed.post"); 121 + }); 122 + 123 + test("handles glob patterns for multiple files", async () => { 124 + // Create multiple test files 125 + const lexicons = join(testDir, "lexicons"); 126 + await mkdir(lexicons, { recursive: true }); 127 + 128 + await writeFile( 129 + join(lexicons, "profile.ts"), 130 + ` 131 + import { lx } from "prototypey"; 132 + export const schema = lx.namespace("app.bsky.actor.profile", { 133 + main: lx.record({ key: "self", record: lx.object({}) }), 134 + }); 135 + `, 136 + ); 137 + 138 + await writeFile( 139 + join(lexicons, "post.ts"), 140 + ` 141 + import { lx } from "prototypey"; 142 + export const schema = lx.namespace("app.bsky.feed.post", { 143 + main: lx.record({ key: "tid", record: lx.object({}) }), 144 + }); 145 + `, 146 + ); 147 + 148 + // Run with glob pattern 149 + await genEmit(outDir, `${lexicons}/*.ts`); 150 + 151 + // Verify both files were created 152 + const profileExists = await readFile( 153 + join(outDir, "app.bsky.actor.profile.json"), 154 + "utf-8", 155 + ); 156 + const postExists = await readFile( 157 + join(outDir, "app.bsky.feed.post.json"), 158 + "utf-8", 159 + ); 160 + 161 + expect(profileExists).toBeTruthy(); 162 + expect(postExists).toBeTruthy(); 163 + }); 164 + 165 + test("emits query endpoint with parameters and output", async () => { 166 + const lexiconFile = join(testDir, "search.ts"); 167 + await writeFile( 168 + lexiconFile, 169 + ` 170 + import { lx } from "prototypey"; 171 + 172 + export const searchPosts = lx.namespace("app.bsky.feed.searchPosts", { 173 + main: lx.query({ 174 + description: "Find posts matching search criteria", 175 + parameters: lx.params({ 176 + q: lx.string({ required: true }), 177 + limit: lx.integer({ minimum: 1, maximum: 100, default: 25 }), 178 + cursor: lx.string(), 179 + }), 180 + output: { 181 + encoding: "application/json", 182 + schema: lx.object({ 183 + cursor: lx.string(), 184 + posts: lx.array(lx.ref("app.bsky.feed.defs#postView"), { required: true }), 185 + }), 186 + }, 187 + }), 188 + }); 189 + `, 190 + ); 191 + 192 + await genEmit(outDir, lexiconFile); 193 + 194 + const outputFile = join(outDir, "app.bsky.feed.searchPosts.json"); 195 + const content = await readFile(outputFile, "utf-8"); 196 + const json = JSON.parse(content); 197 + 198 + expect(json).toEqual({ 199 + lexicon: 1, 200 + id: "app.bsky.feed.searchPosts", 201 + defs: { 202 + main: { 203 + type: "query", 204 + description: "Find posts matching search criteria", 205 + parameters: { 206 + type: "params", 207 + properties: { 208 + q: { type: "string", required: true }, 209 + limit: { type: "integer", minimum: 1, maximum: 100, default: 25 }, 210 + cursor: { type: "string" }, 211 + }, 212 + required: ["q"], 213 + }, 214 + output: { 215 + encoding: "application/json", 216 + schema: { 217 + type: "object", 218 + properties: { 219 + cursor: { type: "string" }, 220 + posts: { 221 + type: "array", 222 + items: { type: "ref", ref: "app.bsky.feed.defs#postView" }, 223 + required: true, 224 + }, 225 + }, 226 + required: ["posts"], 227 + }, 228 + }, 229 + }, 230 + }, 231 + }); 232 + }); 233 + 234 + test("emits procedure endpoint with input and output", async () => { 235 + const lexiconFile = join(testDir, "create-post.ts"); 236 + await writeFile( 237 + lexiconFile, 238 + ` 239 + import { lx } from "prototypey"; 240 + 241 + export const createPost = lx.namespace("com.atproto.repo.createRecord", { 242 + main: lx.procedure({ 243 + description: "Create a record", 244 + input: { 245 + encoding: "application/json", 246 + schema: lx.object({ 247 + repo: lx.string({ required: true }), 248 + collection: lx.string({ required: true }), 249 + record: lx.unknown({ required: true }), 250 + }), 251 + }, 252 + output: { 253 + encoding: "application/json", 254 + schema: lx.object({ 255 + uri: lx.string({ required: true }), 256 + cid: lx.string({ required: true }), 257 + }), 258 + }, 259 + }), 260 + }); 261 + `, 262 + ); 263 + 264 + await genEmit(outDir, lexiconFile); 265 + 266 + const outputFile = join(outDir, "com.atproto.repo.createRecord.json"); 267 + const content = await readFile(outputFile, "utf-8"); 268 + const json = JSON.parse(content); 269 + 270 + expect(json).toEqual({ 271 + lexicon: 1, 272 + id: "com.atproto.repo.createRecord", 273 + defs: { 274 + main: { 275 + type: "procedure", 276 + description: "Create a record", 277 + input: { 278 + encoding: "application/json", 279 + schema: { 280 + type: "object", 281 + properties: { 282 + repo: { type: "string", required: true }, 283 + collection: { type: "string", required: true }, 284 + record: { type: "unknown", required: true }, 285 + }, 286 + required: ["repo", "collection", "record"], 287 + }, 288 + }, 289 + output: { 290 + encoding: "application/json", 291 + schema: { 292 + type: "object", 293 + properties: { 294 + uri: { type: "string", required: true }, 295 + cid: { type: "string", required: true }, 296 + }, 297 + required: ["uri", "cid"], 298 + }, 299 + }, 300 + }, 301 + }, 302 + }); 303 + }); 304 + 305 + test("emits subscription endpoint with message union", async () => { 306 + const lexiconFile = join(testDir, "subscription.ts"); 307 + await writeFile( 308 + lexiconFile, 309 + ` 310 + import { lx } from "prototypey"; 311 + 312 + export const subscribeRepos = lx.namespace("com.atproto.sync.subscribeRepos", { 313 + main: lx.subscription({ 314 + description: "Repository event stream", 315 + parameters: lx.params({ 316 + cursor: lx.integer(), 317 + }), 318 + message: { 319 + schema: lx.union(["#commit", "#identity", "#account"]), 320 + }, 321 + }), 322 + commit: lx.object({ 323 + seq: lx.integer({ required: true }), 324 + rebase: lx.boolean({ required: true }), 325 + }), 326 + identity: lx.object({ 327 + seq: lx.integer({ required: true }), 328 + did: lx.string({ required: true, format: "did" }), 329 + }), 330 + account: lx.object({ 331 + seq: lx.integer({ required: true }), 332 + active: lx.boolean({ required: true }), 333 + }), 334 + }); 335 + `, 336 + ); 337 + 338 + await genEmit(outDir, lexiconFile); 339 + 340 + const outputFile = join(outDir, "com.atproto.sync.subscribeRepos.json"); 341 + const content = await readFile(outputFile, "utf-8"); 342 + const json = JSON.parse(content); 343 + 344 + expect(json).toEqual({ 345 + lexicon: 1, 346 + id: "com.atproto.sync.subscribeRepos", 347 + defs: { 348 + main: { 349 + type: "subscription", 350 + description: "Repository event stream", 351 + parameters: { 352 + type: "params", 353 + properties: { 354 + cursor: { type: "integer" }, 355 + }, 356 + }, 357 + message: { 358 + schema: { 359 + type: "union", 360 + refs: ["#commit", "#identity", "#account"], 361 + }, 362 + }, 363 + }, 364 + commit: { 365 + type: "object", 366 + properties: { 367 + seq: { type: "integer", required: true }, 368 + rebase: { type: "boolean", required: true }, 369 + }, 370 + required: ["seq", "rebase"], 371 + }, 372 + identity: { 373 + type: "object", 374 + properties: { 375 + seq: { type: "integer", required: true }, 376 + did: { type: "string", format: "did", required: true }, 377 + }, 378 + required: ["seq", "did"], 379 + }, 380 + account: { 381 + type: "object", 382 + properties: { 383 + seq: { type: "integer", required: true }, 384 + active: { type: "boolean", required: true }, 385 + }, 386 + required: ["seq", "active"], 387 + }, 388 + }, 389 + }); 390 + }); 391 + 392 + test("emits complex namespace with tokens, refs, and unions", async () => { 393 + const lexiconFile = join(testDir, "complex.ts"); 394 + await writeFile( 395 + lexiconFile, 396 + ` 397 + import { lx } from "prototypey"; 398 + 399 + export const feedDefs = lx.namespace("app.bsky.feed.defs", { 400 + postView: lx.object({ 401 + uri: lx.string({ required: true, format: "at-uri" }), 402 + cid: lx.string({ required: true, format: "cid" }), 403 + author: lx.ref("app.bsky.actor.defs#profileViewBasic", { required: true }), 404 + embed: lx.union([ 405 + "app.bsky.embed.images#view", 406 + "app.bsky.embed.video#view", 407 + ]), 408 + likeCount: lx.integer({ minimum: 0 }), 409 + }), 410 + requestLess: lx.token("Request less content like this"), 411 + requestMore: lx.token("Request more content like this"), 412 + }); 413 + `, 414 + ); 415 + 416 + await genEmit(outDir, lexiconFile); 417 + 418 + const outputFile = join(outDir, "app.bsky.feed.defs.json"); 419 + const content = await readFile(outputFile, "utf-8"); 420 + const json = JSON.parse(content); 421 + 422 + expect(json).toEqual({ 423 + lexicon: 1, 424 + id: "app.bsky.feed.defs", 425 + defs: { 426 + postView: { 427 + type: "object", 428 + properties: { 429 + uri: { type: "string", format: "at-uri", required: true }, 430 + cid: { type: "string", format: "cid", required: true }, 431 + author: { 432 + type: "ref", 433 + ref: "app.bsky.actor.defs#profileViewBasic", 434 + required: true, 435 + }, 436 + embed: { 437 + type: "union", 438 + refs: ["app.bsky.embed.images#view", "app.bsky.embed.video#view"], 439 + }, 440 + likeCount: { type: "integer", minimum: 0 }, 441 + }, 442 + required: ["uri", "cid", "author"], 443 + }, 444 + requestLess: { 445 + type: "token", 446 + description: "Request less content like this", 447 + }, 448 + requestMore: { 449 + type: "token", 450 + description: "Request more content like this", 451 + }, 452 + }, 453 + }); 454 + }); 455 + 456 + test("emits lexicon with arrays, blobs, and string formats", async () => { 457 + const lexiconFile = join(testDir, "primitives.ts"); 458 + await writeFile( 459 + lexiconFile, 460 + ` 461 + import { lx } from "prototypey"; 462 + 463 + export const imagePost = lx.namespace("app.example.imagePost", { 464 + main: lx.record({ 465 + key: "tid", 466 + record: lx.object({ 467 + text: lx.string({ maxLength: 300, maxGraphemes: 300, required: true }), 468 + createdAt: lx.string({ format: "datetime", required: true }), 469 + images: lx.array(lx.blob({ accept: ["image/png", "image/jpeg"], maxSize: 1000000 }), { maxLength: 4 }), 470 + tags: lx.array(lx.string({ maxLength: 64 })), 471 + langs: lx.array(lx.string()), 472 + }), 473 + }), 474 + }); 475 + `, 476 + ); 477 + 478 + await genEmit(outDir, lexiconFile); 479 + 480 + const outputFile = join(outDir, "app.example.imagePost.json"); 481 + const content = await readFile(outputFile, "utf-8"); 482 + const json = JSON.parse(content); 483 + 484 + expect(json).toEqual({ 485 + lexicon: 1, 486 + id: "app.example.imagePost", 487 + defs: { 488 + main: { 489 + type: "record", 490 + key: "tid", 491 + record: { 492 + type: "object", 493 + properties: { 494 + text: { 495 + type: "string", 496 + maxLength: 300, 497 + maxGraphemes: 300, 498 + required: true, 499 + }, 500 + createdAt: { type: "string", format: "datetime", required: true }, 501 + images: { 502 + type: "array", 503 + items: { 504 + type: "blob", 505 + accept: ["image/png", "image/jpeg"], 506 + maxSize: 1000000, 507 + }, 508 + maxLength: 4, 509 + }, 510 + tags: { 511 + type: "array", 512 + items: { type: "string", maxLength: 64 }, 513 + }, 514 + langs: { 515 + type: "array", 516 + items: { type: "string" }, 517 + }, 518 + }, 519 + required: ["text", "createdAt"], 520 + }, 521 + }, 522 + }, 523 + }); 524 + }); 525 + });
+369
packages/cli/tests/commands/gen-inferred.test.ts
··· 1 + import { expect, test, describe, beforeEach, afterEach } from "vitest"; 2 + import { mkdir, writeFile, rm, readFile } from "node:fs/promises"; 3 + import { join } from "node:path"; 4 + import { genInferred } from "../../src/commands/gen-inferred.ts"; 5 + import { tmpdir } from "node:os"; 6 + 7 + describe("genInferred", () => { 8 + let testDir: string; 9 + let outDir: string; 10 + let schemasDir: string; 11 + 12 + beforeEach(async () => { 13 + // Create a temporary directory for test files 14 + testDir = join(tmpdir(), `prototypey-inferred-test-${Date.now()}`); 15 + outDir = join(testDir, "output"); 16 + schemasDir = join(testDir, "schemas"); 17 + await mkdir(testDir, { recursive: true }); 18 + await mkdir(outDir, { recursive: true }); 19 + await mkdir(schemasDir, { recursive: true }); 20 + }); 21 + 22 + afterEach(async () => { 23 + // Clean up test directory 24 + await rm(testDir, { recursive: true, force: true }); 25 + }); 26 + 27 + test("generates inferred types from a simple schema", async () => { 28 + // Create a test schema file 29 + const schemaFile = join(schemasDir, "app.bsky.actor.profile.json"); 30 + await writeFile( 31 + schemaFile, 32 + JSON.stringify( 33 + { 34 + lexicon: 1, 35 + id: "app.bsky.actor.profile", 36 + defs: { 37 + main: { 38 + type: "record", 39 + key: "self", 40 + record: { 41 + type: "object", 42 + properties: { 43 + displayName: { 44 + type: "string", 45 + maxLength: 64, 46 + maxGraphemes: 64, 47 + }, 48 + description: { 49 + type: "string", 50 + maxLength: 256, 51 + maxGraphemes: 256, 52 + }, 53 + }, 54 + }, 55 + }, 56 + }, 57 + }, 58 + null, 59 + "\t", 60 + ), 61 + ); 62 + 63 + // Run the inferred command 64 + await genInferred(outDir, schemaFile); 65 + 66 + // Read the generated TypeScript file 67 + const outputFile = join(outDir, "app/bsky/actor/profile.ts"); 68 + const content = await readFile(outputFile, "utf-8"); 69 + 70 + // Verify the generated code structure 71 + expect(content).toContain("// Generated by prototypey - DO NOT EDIT"); 72 + expect(content).toContain("// Source: app.bsky.actor.profile"); 73 + expect(content).toContain('import type { Infer } from "prototypey"'); 74 + expect(content).toContain('with { type: "json" }'); 75 + expect(content).toContain("export type Profile = Infer<typeof schema>"); 76 + expect(content).toContain("export const ProfileSchema = schema"); 77 + expect(content).toContain( 78 + "export function isProfile(v: unknown): v is Profile", 79 + ); 80 + expect(content).toContain('v.$type === "app.bsky.actor.profile"'); 81 + }); 82 + 83 + test("generates correct directory structure from NSID", async () => { 84 + // Create a test schema with nested NSID 85 + const schemaFile = join(schemasDir, "app.bsky.feed.post.json"); 86 + await writeFile( 87 + schemaFile, 88 + JSON.stringify({ 89 + lexicon: 1, 90 + id: "app.bsky.feed.post", 91 + defs: { 92 + main: { 93 + type: "record", 94 + key: "tid", 95 + record: { 96 + type: "object", 97 + properties: { 98 + text: { type: "string" }, 99 + }, 100 + }, 101 + }, 102 + }, 103 + }), 104 + ); 105 + 106 + await genInferred(outDir, schemaFile); 107 + 108 + // Verify the directory structure matches NSID 109 + const outputFile = join(outDir, "app/bsky/feed/post.ts"); 110 + const content = await readFile(outputFile, "utf-8"); 111 + 112 + expect(content).toBeTruthy(); 113 + expect(content).toContain("export type Post = Infer<typeof schema>"); 114 + }); 115 + 116 + test("handles multiple schema files with glob patterns", async () => { 117 + // Create multiple schema files 118 + await writeFile( 119 + join(schemasDir, "app.bsky.actor.profile.json"), 120 + JSON.stringify({ 121 + lexicon: 1, 122 + id: "app.bsky.actor.profile", 123 + defs: { main: { type: "record" } }, 124 + }), 125 + ); 126 + 127 + await writeFile( 128 + join(schemasDir, "app.bsky.feed.post.json"), 129 + JSON.stringify({ 130 + lexicon: 1, 131 + id: "app.bsky.feed.post", 132 + defs: { main: { type: "record" } }, 133 + }), 134 + ); 135 + 136 + // Run with glob pattern 137 + await genInferred(outDir, `${schemasDir}/*.json`); 138 + 139 + // Verify both files were created 140 + const profileContent = await readFile( 141 + join(outDir, "app/bsky/actor/profile.ts"), 142 + "utf-8", 143 + ); 144 + const postContent = await readFile( 145 + join(outDir, "app/bsky/feed/post.ts"), 146 + "utf-8", 147 + ); 148 + 149 + expect(profileContent).toContain("export type Profile"); 150 + expect(postContent).toContain("export type Post"); 151 + }); 152 + 153 + test("generates correct relative import path", async () => { 154 + // Create a deeply nested schema 155 + const schemaFile = join(schemasDir, "com.atproto.repo.createRecord.json"); 156 + await writeFile( 157 + schemaFile, 158 + JSON.stringify({ 159 + lexicon: 1, 160 + id: "com.atproto.repo.createRecord", 161 + defs: { 162 + main: { 163 + type: "procedure", 164 + input: { encoding: "application/json" }, 165 + }, 166 + }, 167 + }), 168 + ); 169 + 170 + await genInferred(outDir, schemaFile); 171 + 172 + // Read generated file and check the import path is relative 173 + const outputFile = join(outDir, "com/atproto/repo/createRecord.ts"); 174 + const content = await readFile(outputFile, "utf-8"); 175 + 176 + // The import should be relative to the generated file location 177 + expect(content).toContain('import schema from "'); 178 + expect(content).toContain('.json" with { type: "json" }'); 179 + // Should navigate up from com/atproto/repo/ to schemas/ 180 + expect(content).toMatch(/import schema from ".*createRecord\.json"/); 181 + }); 182 + 183 + test("generates proper type name from NSID", async () => { 184 + // Test various NSID formats 185 + const testCases = [ 186 + { id: "app.bsky.feed.post", expectedType: "Post" }, 187 + { id: "com.atproto.repo.createRecord", expectedType: "CreateRecord" }, 188 + { id: "app.bsky.actor.profile", expectedType: "Profile" }, 189 + { 190 + id: "app.bsky.feed.searchPosts", 191 + expectedType: "SearchPosts", 192 + }, 193 + ]; 194 + 195 + for (const { id, expectedType } of testCases) { 196 + const schemaFile = join(schemasDir, `${id}.json`); 197 + await writeFile( 198 + schemaFile, 199 + JSON.stringify({ 200 + lexicon: 1, 201 + id, 202 + defs: { main: { type: "record" } }, 203 + }), 204 + ); 205 + 206 + const testOutDir = join(testDir, `out-${id}`); 207 + await mkdir(testOutDir, { recursive: true }); 208 + await genInferred(testOutDir, schemaFile); 209 + 210 + const nsidParts = id.split("."); 211 + const outputFile = join(testOutDir, ...nsidParts) + ".ts"; 212 + const content = await readFile(outputFile, "utf-8"); 213 + 214 + expect(content).toContain(`export type ${expectedType}`); 215 + expect(content).toContain(`export const ${expectedType}Schema`); 216 + expect(content).toContain(`export function is${expectedType}`); 217 + } 218 + }); 219 + 220 + test("handles schema without id gracefully", async () => { 221 + // Create an invalid schema without id 222 + const schemaFile = join(schemasDir, "invalid.json"); 223 + await writeFile( 224 + schemaFile, 225 + JSON.stringify({ 226 + lexicon: 1, 227 + defs: { main: { type: "record" } }, 228 + }), 229 + ); 230 + 231 + // Should not throw, but should skip the file 232 + await expect(genInferred(outDir, schemaFile)).resolves.not.toThrow(); 233 + 234 + // Output directory should be empty or not contain generated files 235 + const files = await readFile(outDir, "utf-8").catch(() => null); 236 + expect(files).toBeNull(); 237 + }); 238 + 239 + test("handles schema without defs gracefully", async () => { 240 + // Create an invalid schema without defs 241 + const schemaFile = join(schemasDir, "invalid2.json"); 242 + await writeFile( 243 + schemaFile, 244 + JSON.stringify({ 245 + lexicon: 1, 246 + id: "app.test.invalid", 247 + }), 248 + ); 249 + 250 + // Should not throw, but should skip the file 251 + await expect(genInferred(outDir, schemaFile)).resolves.not.toThrow(); 252 + }); 253 + 254 + test("processes array of schema patterns", async () => { 255 + // Create schemas in different directories 256 + const schemasDir1 = join(testDir, "schemas1"); 257 + const schemasDir2 = join(testDir, "schemas2"); 258 + await mkdir(schemasDir1, { recursive: true }); 259 + await mkdir(schemasDir2, { recursive: true }); 260 + 261 + await writeFile( 262 + join(schemasDir1, "app.one.json"), 263 + JSON.stringify({ 264 + lexicon: 1, 265 + id: "app.one", 266 + defs: { main: { type: "record" } }, 267 + }), 268 + ); 269 + 270 + await writeFile( 271 + join(schemasDir2, "app.two.json"), 272 + JSON.stringify({ 273 + lexicon: 1, 274 + id: "app.two", 275 + defs: { main: { type: "record" } }, 276 + }), 277 + ); 278 + 279 + // Run with array of patterns 280 + await genInferred(outDir, [ 281 + `${schemasDir1}/*.json`, 282 + `${schemasDir2}/*.json`, 283 + ]); 284 + 285 + // Verify both were generated 286 + const oneContent = await readFile(join(outDir, "app/one.ts"), "utf-8"); 287 + const twoContent = await readFile(join(outDir, "app/two.ts"), "utf-8"); 288 + 289 + expect(oneContent).toContain("export type One"); 290 + expect(twoContent).toContain("export type Two"); 291 + }); 292 + 293 + test("generates code with all required components", async () => { 294 + // Create a comprehensive schema 295 + const schemaFile = join(schemasDir, "app.test.complete.json"); 296 + await writeFile( 297 + schemaFile, 298 + JSON.stringify({ 299 + lexicon: 1, 300 + id: "app.test.complete", 301 + defs: { 302 + main: { 303 + type: "record", 304 + key: "tid", 305 + record: { 306 + type: "object", 307 + required: ["text"], 308 + properties: { 309 + text: { type: "string", maxLength: 300 }, 310 + tags: { type: "array", items: { type: "string" } }, 311 + }, 312 + }, 313 + }, 314 + }, 315 + }), 316 + ); 317 + 318 + await genInferred(outDir, schemaFile); 319 + 320 + const outputFile = join(outDir, "app/test/complete.ts"); 321 + const content = await readFile(outputFile, "utf-8"); 322 + 323 + // Check all required exports 324 + expect(content).toContain('import type { Infer } from "prototypey"'); 325 + expect(content).toContain("export type Complete = Infer<typeof schema>"); 326 + expect(content).toContain("export const CompleteSchema = schema"); 327 + expect(content).toContain( 328 + "export function isComplete(v: unknown): v is Complete", 329 + ); 330 + 331 + // Check type guard implementation 332 + expect(content).toContain('typeof v === "object"'); 333 + expect(content).toContain("v !== null"); 334 + expect(content).toContain('"$type" in v'); 335 + expect(content).toContain('v.$type === "app.test.complete"'); 336 + 337 + // Check comments 338 + expect(content).toContain("// Generated by prototypey - DO NOT EDIT"); 339 + expect(content).toContain("// Source: app.test.complete"); 340 + expect(content).toContain( 341 + "* Type-inferred from lexicon schema: app.test.complete", 342 + ); 343 + expect(content).toContain("* The lexicon schema object"); 344 + expect(content).toContain("* Type guard to check if a value is a Complete"); 345 + }); 346 + 347 + test("handles kebab-case and mixed-case NSID parts", async () => { 348 + // Test NSID with different casing 349 + const schemaFile = join(schemasDir, "app.test.myCustomType.json"); 350 + await writeFile( 351 + schemaFile, 352 + JSON.stringify({ 353 + lexicon: 1, 354 + id: "app.test.myCustomType", 355 + defs: { main: { type: "record" } }, 356 + }), 357 + ); 358 + 359 + await genInferred(outDir, schemaFile); 360 + 361 + const outputFile = join(outDir, "app/test/myCustomType.ts"); 362 + const content = await readFile(outputFile, "utf-8"); 363 + 364 + // Should convert to PascalCase 365 + expect(content).toContain("export type MyCustomType"); 366 + expect(content).toContain("export const MyCustomTypeSchema"); 367 + expect(content).toContain("export function isMyCustomType"); 368 + }); 369 + });
+30
packages/cli/tests/fixtures/schemas/app.bsky.actor.profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "displayName": { 12 + "type": "string", 13 + "maxLength": 64, 14 + "maxGraphemes": 64 15 + }, 16 + "description": { 17 + "type": "string", 18 + "maxLength": 256, 19 + "maxGraphemes": 256 20 + }, 21 + "avatar": { 22 + "type": "blob", 23 + "accept": ["image/png", "image/jpeg"], 24 + "maxSize": 1000000 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+43
packages/cli/tests/fixtures/schemas/app.bsky.feed.post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["text", "createdAt"], 11 + "properties": { 12 + "text": { 13 + "type": "string", 14 + "maxLength": 300, 15 + "maxGraphemes": 300 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime" 20 + }, 21 + "reply": { 22 + "type": "ref", 23 + "ref": "app.bsky.feed.post#replyRef" 24 + } 25 + } 26 + } 27 + }, 28 + "replyRef": { 29 + "type": "object", 30 + "required": ["root", "parent"], 31 + "properties": { 32 + "root": { 33 + "type": "ref", 34 + "ref": "com.atproto.repo.strongRef" 35 + }, 36 + "parent": { 37 + "type": "ref", 38 + "ref": "com.atproto.repo.strongRef" 39 + } 40 + } 41 + } 42 + } 43 + }
+47
packages/cli/tests/fixtures/schemas/app.bsky.feed.searchPosts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.searchPosts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Find posts matching search criteria", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["q"], 11 + "properties": { 12 + "q": { 13 + "type": "string" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "minimum": 1, 18 + "maximum": 100, 19 + "default": 25 20 + }, 21 + "cursor": { 22 + "type": "string" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["posts"], 31 + "properties": { 32 + "cursor": { 33 + "type": "string" 34 + }, 35 + "posts": { 36 + "type": "array", 37 + "items": { 38 + "type": "ref", 39 + "ref": "app.bsky.feed.defs#postView" 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + } 47 + }
+11
packages/cli/tests/fixtures/simple-lexicon.ts
··· 1 + import { lx } from "prototypey"; 2 + 3 + export const profileNamespace = lx.namespace("app.bsky.actor.profile", { 4 + main: lx.record({ 5 + key: "self", 6 + record: lx.object({ 7 + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), 8 + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), 9 + }), 10 + }), 11 + });
+4
packages/cli/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["src", "tests"] 4 + }
+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 + });
+7
packages/cli/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + include: ["tests/**/*.test.ts"], 6 + }, 7 + });
+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 + });
+4
packages/prototypey/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["src", "tests"] 4 + }
+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 + });
+51 -9
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 devDependencies: 11 - '@ark/attest': 12 - specifier: ^0.49.0 13 - version: 0.49.0(typescript@5.8.3) 14 11 '@eslint/js': 15 12 specifier: 9.29.0 16 13 version: 9.29.0 17 - '@types/node': 18 - specifier: 24.0.4 19 - version: 24.0.4 20 14 eslint: 21 15 specifier: 9.29.0 22 16 version: 9.29.0(jiti@2.6.1) 23 17 prettier: 24 18 specifier: 3.6.1 25 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: 25 + dependencies: 26 + prototypey: 27 + specifier: workspace:* 28 + version: link:../prototypey 29 + sade: 30 + specifier: ^1.8.1 31 + version: 1.8.1 32 + tinyglobby: 33 + specifier: ^0.2.15 34 + version: 0.2.15 35 + devDependencies: 36 + '@types/node': 37 + specifier: 24.0.4 38 + version: 24.0.4 26 39 tsdown: 27 40 specifier: 0.12.7 28 41 version: 0.12.7(typescript@5.8.3) 29 42 typescript: 30 43 specifier: 5.8.3 31 44 version: 5.8.3 32 - typescript-eslint: 33 - specifier: 8.35.0 34 - version: 8.35.0(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3) 45 + vitest: 46 + specifier: ^3.2.4 47 + version: 3.2.4(@types/node@24.0.4)(jiti@2.6.1) 48 + 49 + packages/prototypey: 50 + devDependencies: 51 + '@ark/attest': 52 + specifier: ^0.49.0 53 + version: 0.49.0(typescript@5.8.3) 54 + '@types/node': 55 + specifier: 24.0.4 56 + version: 24.0.4 57 + tsdown: 58 + specifier: 0.12.7 59 + version: 0.12.7(typescript@5.8.3) 60 + typescript: 61 + specifier: 5.8.3 62 + version: 5.8.3 35 63 vitest: 36 64 specifier: ^3.2.4 37 65 version: 3.2.4(@types/node@24.0.4)(jiti@2.6.1) ··· 1020 1048 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 1021 1049 engines: {node: '>=16 || 14 >=14.17'} 1022 1050 1051 + mri@1.2.0: 1052 + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 1053 + engines: {node: '>=4'} 1054 + 1023 1055 ms@2.1.3: 1024 1056 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1025 1057 ··· 1152 1184 run-parallel@1.2.0: 1153 1185 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 1154 1186 1187 + sade@1.8.1: 1188 + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 1189 + engines: {node: '>=6'} 1190 + 1155 1191 safe-buffer@5.2.1: 1156 1192 resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 1157 1193 ··· 2306 2342 dependencies: 2307 2343 brace-expansion: 2.0.2 2308 2344 2345 + mri@1.2.0: {} 2346 + 2309 2347 ms@2.1.3: {} 2310 2348 2311 2349 nanoid@3.3.11: {} ··· 2449 2487 run-parallel@1.2.0: 2450 2488 dependencies: 2451 2489 queue-microtask: 1.2.3 2490 + 2491 + sade@1.8.1: 2492 + dependencies: 2493 + mri: 1.2.0 2452 2494 2453 2495 safe-buffer@5.2.1: {} 2454 2496
+2
pnpm-workspace.yaml
··· 1 + packages: 2 + - "packages/*"
src/index.ts packages/prototypey/src/index.ts
src/infer.ts packages/prototypey/src/infer.ts
src/lib.ts packages/prototypey/src/lib.ts
src/type-utils.ts packages/prototypey/src/type-utils.ts
tests/base-case.test.ts packages/prototypey/tests/base-case.test.ts
tests/bsky-actor.test.ts packages/prototypey/tests/bsky-actor.test.ts
tests/bsky-feed.test.ts packages/prototypey/tests/bsky-feed.test.ts
+1 -1
tests/infer.bench.ts packages/prototypey/tests/infer.bench.ts
··· 9 9 }), 10 10 }); 11 11 return schema.infer; 12 - }).types([899, "instantiations"]); 12 + }).types([741, "instantiations"]); 13 13 14 14 bench("infer with complex nested structure", () => { 15 15 const schema = lx.namespace("test.complex", {
tests/infer.test.ts packages/prototypey/tests/infer.test.ts
tests/primitives.test.ts packages/prototypey/tests/primitives.test.ts