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.

adding a .validate() api to lexicons (#14)

* readme todo for infer from json

* implemented and tested .validate()

* validate def support

authored by

tyler and committed by
GitHub
9d0dc75f ad16699b

+1184 -127
+1 -1
packages/cli/package.json
··· 1 1 { 2 2 "name": "@prototypey/cli", 3 - "version": "0.1.1", 3 + "version": "0.2.0", 4 4 "description": "CLI tool for generating types from ATProto lexicon schemas", 5 5 "repository": { 6 6 "type": "git",
-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 - }
+3 -2
packages/prototypey/README.md
··· 108 108 - CLI generates ts from json definitions 109 109 - Inferrance of valid type from full lexicon definition 110 110 - the really cool part of this is that it fills in the refs from the defs all at the type level 111 + - `lx.lexicon(...).validate(data)` for validating data using `@atproto/lexicon` and your lexicon definitions 111 112 112 113 **TODO/In Progress:** 113 114 114 115 - Library art! Please reach out if you'd be willing to contribute some drawings or anything! 115 - - Runtime validation using [@atproto/lexicon](https://www.npmjs.com/package/@atproto/lexicon) 116 - - this will be hard to get correct, I'm weary of loading all of the json in a project's lexicons into js memory and would like to run benchmarks and find the best way to get this right. 116 + - Runtime validation `assert*` api's with `@atproto/lexicon` 117 + - Add CLI support for inferring and validating from json as the starting point 117 118 - The CLI needs more real world use and mileage. I expect bugs and weird behavior in this initial release (sorry). 118 119 119 120 ## Disclaimer:
+3 -1
packages/prototypey/package.json
··· 1 1 { 2 2 "name": "prototypey", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "description": "atproto lexicon typescript toolkit", 5 5 "repository": { 6 6 "type": "git", ··· 31 31 "lint": "eslint .", 32 32 "test": "vitest run", 33 33 "test:bench": "node tests/infer.bench.ts", 34 + "test:bench:validation": "vitest bench --run tests/validation-baseline.bench.ts", 34 35 "test:update-snapshots": "vitest run -u", 35 36 "tsc": "tsc" 36 37 }, 37 38 "dependencies": { 39 + "@atproto/lexicon": "^0.5.1", 38 40 "@prototypey/cli": "workspace:*" 39 41 }, 40 42 "devDependencies": {
+37 -3
packages/prototypey/src/lib.ts
··· 1 1 /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 2 import type { Infer } from "./infer.ts"; 3 3 import type { UnionToTuple } from "./type-utils.ts"; 4 + import type { LexiconDoc, ValidationResult } from "@atproto/lexicon"; 5 + import { Lexicons } from "@atproto/lexicon"; 4 6 5 7 /** @see https://atproto.com/specs/lexicon#overview-of-types */ 6 8 type LexiconType = ··· 327 329 errors?: ErrorDef[]; 328 330 } 329 331 330 - class Namespace<T extends LexiconNamespace> { 332 + /** 333 + * Public interface for Lexicon to avoid exposing private implementation details 334 + */ 335 + export interface LexiconSchema<T extends LexiconNamespace> { 336 + json: T; 337 + infer: Infer<{ json: T }>; 338 + validate( 339 + data: unknown, 340 + def?: keyof T["defs"], 341 + ): ValidationResult<Infer<{ json: T }>>; 342 + } 343 + 344 + class Lexicon<T extends LexiconNamespace> implements LexiconSchema<T> { 331 345 public json: T; 332 346 public infer: Infer<{ json: T }> = null as unknown as Infer<{ json: T }>; 347 + private _validator: Lexicons; 333 348 334 349 constructor(json: T) { 335 350 this.json = json; 351 + // Clone before passing to Lexicons to prevent mutation of this.json 352 + this._validator = new Lexicons([ 353 + structuredClone(json) as unknown as LexiconDoc, 354 + ]); 355 + } 356 + 357 + /** 358 + * Validate data against this lexicon's main definition. 359 + * @param data - The data to validate 360 + * @returns ValidationResult with success status and value or error 361 + */ 362 + validate( 363 + data: unknown, 364 + def: keyof T["defs"] = "main", 365 + ): ValidationResult<Infer<{ json: T }>> { 366 + return this._validator.validate( 367 + `${this.json.id}#${def as string}`, 368 + data, 369 + ) as ValidationResult<Infer<{ json: T }>>; 336 370 } 337 371 } 338 372 ··· 569 603 lexicon<ID extends string, D extends LexiconNamespace["defs"]>( 570 604 id: ID, 571 605 defs: D, 572 - ): Namespace<{ lexicon: 1; id: ID; defs: D }> { 573 - return new Namespace({ 606 + ): LexiconSchema<{ lexicon: 1; id: ID; defs: D }> { 607 + return new Lexicon({ 574 608 lexicon: 1, 575 609 id, 576 610 defs,
+185
packages/prototypey/tests/validation-baseline.bench.ts
··· 1 + import { bench, describe } from "vitest"; 2 + import { Lexicons } from "@atproto/lexicon"; 3 + import { lx } from "../src/lib.ts"; 4 + 5 + // Phase 1 Benchmarks: Baseline measurements before implementation 6 + 7 + describe("baseline: lexicon instantiation", () => { 8 + bench("simple lexicon instantiation", () => { 9 + lx.lexicon("test.simple", { 10 + main: lx.object({ 11 + id: lx.string({ required: true }), 12 + name: lx.string({ required: true }), 13 + }), 14 + }); 15 + }); 16 + }); 17 + 18 + describe("baseline: Lexicons class", () => { 19 + bench("create empty Lexicons instance", () => { 20 + new Lexicons(); 21 + }); 22 + 23 + bench("load 1 lexicon into Lexicons", () => { 24 + const lexicons = new Lexicons(); 25 + lexicons.add({ 26 + lexicon: 1, 27 + id: "test.simple", 28 + defs: { 29 + main: { 30 + type: "object", 31 + properties: { 32 + id: { type: "string" }, 33 + name: { type: "string" }, 34 + }, 35 + required: ["id", "name"], 36 + }, 37 + }, 38 + }); 39 + }); 40 + 41 + bench("load 10 lexicons into Lexicons", () => { 42 + const lexicons = new Lexicons(); 43 + for (let i = 0; i < 10; i++) { 44 + lexicons.add({ 45 + lexicon: 1, 46 + id: `test.schema${i}`, 47 + defs: { 48 + main: { 49 + type: "object", 50 + properties: { 51 + id: { type: "string" }, 52 + name: { type: "string" }, 53 + }, 54 + required: ["id", "name"], 55 + }, 56 + }, 57 + }); 58 + } 59 + }); 60 + 61 + bench("load 100 lexicons into Lexicons", () => { 62 + const lexicons = new Lexicons(); 63 + for (let i = 0; i < 100; i++) { 64 + lexicons.add({ 65 + lexicon: 1, 66 + id: `test.schema${i}`, 67 + defs: { 68 + main: { 69 + type: "object", 70 + properties: { 71 + id: { type: "string" }, 72 + name: { type: "string" }, 73 + }, 74 + required: ["id", "name"], 75 + }, 76 + }, 77 + }); 78 + } 79 + }); 80 + }); 81 + 82 + describe("baseline: validation", () => { 83 + bench("validate simple object", () => { 84 + const lexicons = new Lexicons([ 85 + { 86 + lexicon: 1, 87 + id: "test.simple", 88 + defs: { 89 + main: { 90 + type: "object", 91 + properties: { 92 + id: { type: "string" }, 93 + name: { type: "string" }, 94 + }, 95 + required: ["id", "name"], 96 + }, 97 + }, 98 + }, 99 + ]); 100 + lexicons.validate("test.simple#main", { 101 + id: "123", 102 + name: "test", 103 + }); 104 + }); 105 + 106 + bench("validate complex nested object", () => { 107 + const lexicons = new Lexicons([ 108 + { 109 + lexicon: 1, 110 + id: "test.complex", 111 + defs: { 112 + user: { 113 + type: "object", 114 + properties: { 115 + handle: { type: "string" }, 116 + displayName: { type: "string" }, 117 + }, 118 + required: ["handle"], 119 + }, 120 + reply: { 121 + type: "object", 122 + properties: { 123 + text: { type: "string" }, 124 + author: { type: "ref", ref: "#user" }, 125 + }, 126 + required: ["text", "author"], 127 + }, 128 + main: { 129 + type: "record", 130 + key: "tid", 131 + record: { 132 + type: "object", 133 + properties: { 134 + author: { type: "ref", ref: "#user" }, 135 + replies: { 136 + type: "array", 137 + items: { type: "ref", ref: "#reply" }, 138 + }, 139 + content: { type: "string" }, 140 + createdAt: { type: "string", format: "datetime" }, 141 + }, 142 + required: ["author", "content", "createdAt"], 143 + }, 144 + }, 145 + }, 146 + }, 147 + ]); 148 + lexicons.validate("test.complex#main", { 149 + author: { handle: "alice.bsky.social", displayName: "Alice" }, 150 + replies: [ 151 + { 152 + text: "Great post!", 153 + author: { handle: "bob.bsky.social", displayName: "Bob" }, 154 + }, 155 + ], 156 + content: "Hello world", 157 + createdAt: "2025-01-01T00:00:00Z", 158 + }); 159 + }); 160 + 161 + bench("validate 1000 simple objects", () => { 162 + const lexicons = new Lexicons([ 163 + { 164 + lexicon: 1, 165 + id: "test.simple", 166 + defs: { 167 + main: { 168 + type: "object", 169 + properties: { 170 + id: { type: "string" }, 171 + name: { type: "string" }, 172 + }, 173 + required: ["id", "name"], 174 + }, 175 + }, 176 + }, 177 + ]); 178 + for (let i = 0; i < 1000; i++) { 179 + lexicons.validate("test.simple#main", { 180 + id: `${i}`, 181 + name: `test${i}`, 182 + }); 183 + } 184 + }); 185 + });
+113
packages/prototypey/tests/validation-eager.bench.ts
··· 1 + import { bench, describe } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + // Phase 2 Benchmarks: Eager loading strategy 5 + 6 + describe("eager: lexicon instantiation with validator", () => { 7 + bench("simple lexicon with eager validator", () => { 8 + lx.lexicon("test.simple", { 9 + main: lx.object({ 10 + id: lx.string({ required: true }), 11 + name: lx.string({ required: true }), 12 + }), 13 + }); 14 + }); 15 + 16 + bench("complex lexicon with eager validator", () => { 17 + lx.lexicon("test.complex", { 18 + user: lx.object({ 19 + handle: lx.string({ required: true }), 20 + displayName: lx.string(), 21 + }), 22 + reply: lx.object({ 23 + text: lx.string({ required: true }), 24 + author: lx.ref("#user", { required: true }), 25 + }), 26 + main: lx.record({ 27 + key: "tid", 28 + record: lx.object({ 29 + author: lx.ref("#user", { required: true }), 30 + replies: lx.array(lx.ref("#reply")), 31 + content: lx.string({ required: true }), 32 + createdAt: lx.string({ required: true, format: "datetime" }), 33 + }), 34 + }), 35 + }); 36 + }); 37 + 38 + bench("100 lexicons with eager validators", () => { 39 + for (let i = 0; i < 100; i++) { 40 + lx.lexicon(`test.schema${i}`, { 41 + main: lx.object({ 42 + id: lx.string({ required: true }), 43 + name: lx.string({ required: true }), 44 + }), 45 + }); 46 + } 47 + }); 48 + }); 49 + 50 + describe("eager: validation (validator already loaded)", () => { 51 + const simpleSchema = lx.lexicon("test.simple", { 52 + main: lx.object({ 53 + id: lx.string({ required: true }), 54 + name: lx.string({ required: true }), 55 + }), 56 + }); 57 + const complexSchema = lx.lexicon("test.complex", { 58 + user: lx.object({ 59 + handle: lx.string({ required: true }), 60 + displayName: lx.string(), 61 + }), 62 + reply: lx.object({ 63 + text: lx.string({ required: true }), 64 + author: lx.ref("#user", { required: true }), 65 + }), 66 + main: lx.record({ 67 + key: "tid", 68 + record: lx.object({ 69 + author: lx.ref("#user", { required: true }), 70 + replies: lx.array(lx.ref("#reply")), 71 + content: lx.string({ required: true }), 72 + createdAt: lx.string({ required: true, format: "datetime" }), 73 + }), 74 + }), 75 + }); 76 + 77 + bench("first validation call (already loaded)", () => { 78 + simpleSchema.validate({ 79 + id: "123", 80 + name: "test", 81 + }); 82 + }); 83 + 84 + bench("validate simple object", () => { 85 + simpleSchema.validate({ 86 + id: "123", 87 + name: "test", 88 + }); 89 + }); 90 + 91 + bench("validate complex object", () => { 92 + complexSchema.validate({ 93 + author: { handle: "alice.bsky.social", displayName: "Alice" }, 94 + replies: [ 95 + { 96 + text: "Great post!", 97 + author: { handle: "bob.bsky.social", displayName: "Bob" }, 98 + }, 99 + ], 100 + content: "Hello world", 101 + createdAt: "2025-01-01T00:00:00Z", 102 + }); 103 + }); 104 + 105 + bench("1000 sequential validations", () => { 106 + for (let i = 0; i < 1000; i++) { 107 + simpleSchema.validate({ 108 + id: `${i}`, 109 + name: `test${i}`, 110 + }); 111 + } 112 + }); 113 + });
+796
packages/prototypey/tests/validation.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { lx } from "../src/lib.ts"; 3 + 4 + describe("basic validation", () => { 5 + const schema = lx.lexicon("test.simple", { 6 + main: lx.object({ 7 + id: lx.string({ required: true }), 8 + name: lx.string({ required: true }), 9 + }), 10 + }); 11 + 12 + it("should validate valid data", () => { 13 + const result = schema.validate({ id: "123", name: "test" }); 14 + expect(result.success).toBe(true); 15 + if (result.success) { 16 + expect(result.value).toEqual({ id: "123", name: "test" }); 17 + } 18 + }); 19 + 20 + it("should reject missing required fields", () => { 21 + const result = schema.validate({ id: "123" }); 22 + expect(result.success).toBe(false); 23 + }); 24 + 25 + it("should reject invalid types", () => { 26 + const result = schema.validate({ id: 123, name: "test" }); 27 + expect(result.success).toBe(false); 28 + }); 29 + }); 30 + 31 + describe("complex types", () => { 32 + const schema = lx.lexicon("test.complex", { 33 + user: lx.object({ 34 + handle: lx.string({ required: true }), 35 + displayName: lx.string(), 36 + }), 37 + reply: lx.object({ 38 + text: lx.string({ required: true }), 39 + author: lx.ref("#user", { required: true }), 40 + }), 41 + main: lx.record({ 42 + key: "tid", 43 + record: lx.object({ 44 + author: lx.ref("#user", { required: true }), 45 + replies: lx.array(lx.ref("#reply")), 46 + content: lx.string({ required: true }), 47 + createdAt: lx.string({ required: true, format: "datetime" }), 48 + }), 49 + }), 50 + }); 51 + 52 + it("should validate complex nested objects", () => { 53 + const result = schema.validate({ 54 + author: { handle: "alice.bsky.social", displayName: "Alice" }, 55 + replies: [ 56 + { 57 + text: "Great post!", 58 + author: { handle: "bob.bsky.social", displayName: "Bob" }, 59 + }, 60 + ], 61 + content: "Hello world", 62 + createdAt: "2025-01-01T00:00:00Z", 63 + }); 64 + expect(result.success).toBe(true); 65 + }); 66 + 67 + it("should reject invalid nested objects", () => { 68 + const result = schema.validate({ 69 + author: { displayName: "Alice" }, // missing required handle 70 + content: "Hello world", 71 + createdAt: "2025-01-01T00:00:00Z", 72 + }); 73 + expect(result.success).toBe(false); 74 + }); 75 + }); 76 + 77 + describe("string formats", () => { 78 + const schema = lx.lexicon("test.formats", { 79 + main: lx.object({ 80 + timestamp: lx.string({ format: "datetime", required: true }), 81 + url: lx.string({ format: "uri", required: true }), 82 + atUri: lx.string({ format: "at-uri", required: true }), 83 + did: lx.string({ format: "did", required: true }), 84 + handle: lx.string({ format: "handle", required: true }), 85 + atIdentifier: lx.string({ format: "at-identifier", required: true }), 86 + nsid: lx.string({ format: "nsid", required: true }), 87 + cid: lx.string({ format: "cid", required: true }), 88 + language: lx.string({ format: "language", required: true }), 89 + }), 90 + }); 91 + 92 + it("should accept valid datetime format", () => { 93 + const result = schema.validate({ 94 + timestamp: "2025-01-01T00:00:00Z", 95 + url: "https://example.com", 96 + atUri: "at://did:plc:abc123/app.bsky.feed.post/123", 97 + did: "did:plc:abc123", 98 + handle: "alice.bsky.social", 99 + atIdentifier: "alice.bsky.social", 100 + nsid: "app.bsky.feed.post", 101 + cid: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 102 + language: "en", 103 + }); 104 + expect(result.success).toBe(true); 105 + }); 106 + 107 + it("should reject invalid datetime format", () => { 108 + const result = schema.validate({ 109 + timestamp: "not-a-date", 110 + url: "https://example.com", 111 + atUri: "at://did:plc:abc123/app.bsky.feed.post/123", 112 + did: "did:plc:abc123", 113 + handle: "alice.bsky.social", 114 + atIdentifier: "alice.bsky.social", 115 + nsid: "app.bsky.feed.post", 116 + cid: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 117 + language: "en", 118 + }); 119 + expect(result.success).toBe(false); 120 + }); 121 + 122 + it("should reject invalid uri format", () => { 123 + const result = schema.validate({ 124 + timestamp: "2025-01-01T00:00:00Z", 125 + url: "not a uri", 126 + atUri: "at://did:plc:abc123/app.bsky.feed.post/123", 127 + did: "did:plc:abc123", 128 + handle: "alice.bsky.social", 129 + atIdentifier: "alice.bsky.social", 130 + nsid: "app.bsky.feed.post", 131 + cid: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 132 + language: "en", 133 + }); 134 + expect(result.success).toBe(false); 135 + }); 136 + 137 + it("should reject invalid did format", () => { 138 + const result = schema.validate({ 139 + timestamp: "2025-01-01T00:00:00Z", 140 + url: "https://example.com", 141 + atUri: "at://did:plc:abc123/app.bsky.feed.post/123", 142 + did: "not-a-did", 143 + handle: "alice.bsky.social", 144 + atIdentifier: "alice.bsky.social", 145 + nsid: "app.bsky.feed.post", 146 + cid: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 147 + language: "en", 148 + }); 149 + expect(result.success).toBe(false); 150 + }); 151 + }); 152 + 153 + describe("array validation", () => { 154 + const schema = lx.lexicon("test.arrays", { 155 + main: lx.object({ 156 + tags: lx.array(lx.string(), { required: true }), 157 + limitedTags: lx.array(lx.string(), { 158 + required: true, 159 + minLength: 1, 160 + maxLength: 5, 161 + }), 162 + optionalTags: lx.array(lx.string()), 163 + }), 164 + }); 165 + 166 + it("should accept valid arrays", () => { 167 + const result = schema.validate({ 168 + tags: ["a", "b", "c"], 169 + limitedTags: ["x", "y"], 170 + }); 171 + expect(result.success).toBe(true); 172 + }); 173 + 174 + it("should accept empty arrays when no minLength", () => { 175 + const result = schema.validate({ 176 + tags: [], 177 + limitedTags: ["x"], 178 + }); 179 + expect(result.success).toBe(true); 180 + }); 181 + 182 + it("should reject arrays below minLength", () => { 183 + const result = schema.validate({ 184 + tags: [], 185 + limitedTags: [], 186 + }); 187 + expect(result.success).toBe(false); 188 + }); 189 + 190 + it("should reject arrays above maxLength", () => { 191 + const result = schema.validate({ 192 + tags: ["a"], 193 + limitedTags: ["a", "b", "c", "d", "e", "f"], 194 + }); 195 + expect(result.success).toBe(false); 196 + }); 197 + 198 + it("should reject arrays with invalid item types", () => { 199 + const result = schema.validate({ 200 + tags: ["a", 123, "c"], 201 + limitedTags: ["x"], 202 + }); 203 + expect(result.success).toBe(false); 204 + }); 205 + 206 + it("should allow omitting optional arrays", () => { 207 + const result = schema.validate({ 208 + tags: ["a"], 209 + limitedTags: ["x"], 210 + }); 211 + expect(result.success).toBe(true); 212 + }); 213 + }); 214 + 215 + describe("optional vs required fields", () => { 216 + const schema = lx.lexicon("test.optional", { 217 + main: lx.object({ 218 + requiredString: lx.string({ required: true }), 219 + optionalString: lx.string(), 220 + requiredNumber: lx.integer({ required: true }), 221 + optionalNumber: lx.integer(), 222 + requiredBool: lx.boolean({ required: true }), 223 + optionalBool: lx.boolean(), 224 + }), 225 + }); 226 + 227 + it("should accept data with all required fields", () => { 228 + const result = schema.validate({ 229 + requiredString: "test", 230 + requiredNumber: 42, 231 + requiredBool: true, 232 + }); 233 + expect(result.success).toBe(true); 234 + }); 235 + 236 + it("should accept data with optional fields included", () => { 237 + const result = schema.validate({ 238 + requiredString: "test", 239 + optionalString: "optional", 240 + requiredNumber: 42, 241 + optionalNumber: 100, 242 + requiredBool: true, 243 + optionalBool: false, 244 + }); 245 + expect(result.success).toBe(true); 246 + }); 247 + 248 + it("should reject data missing required string", () => { 249 + const result = schema.validate({ 250 + requiredNumber: 42, 251 + requiredBool: true, 252 + }); 253 + expect(result.success).toBe(false); 254 + }); 255 + 256 + it("should reject data missing required number", () => { 257 + const result = schema.validate({ 258 + requiredString: "test", 259 + requiredBool: true, 260 + }); 261 + expect(result.success).toBe(false); 262 + }); 263 + 264 + it("should reject data missing required boolean", () => { 265 + const result = schema.validate({ 266 + requiredString: "test", 267 + requiredNumber: 42, 268 + }); 269 + expect(result.success).toBe(false); 270 + }); 271 + 272 + it("should allow undefined for optional fields", () => { 273 + const result = schema.validate({ 274 + requiredString: "test", 275 + requiredNumber: 42, 276 + requiredBool: true, 277 + optionalString: undefined, 278 + }); 279 + expect(result.success).toBe(true); 280 + }); 281 + }); 282 + 283 + describe("error messages", () => { 284 + const schema = lx.lexicon("test.errors", { 285 + main: lx.object({ 286 + name: lx.string({ required: true }), 287 + age: lx.integer({ required: true }), 288 + }), 289 + }); 290 + 291 + it("should provide error details on validation failure", () => { 292 + const result = schema.validate({ 293 + name: 123, // wrong type 294 + }); 295 + expect(result.success).toBe(false); 296 + if (!result.success) { 297 + expect(result.error).toBeDefined(); 298 + expect(typeof result.error).toBe("object"); 299 + } 300 + }); 301 + 302 + it("should include error information for missing required fields", () => { 303 + const result = schema.validate({ 304 + name: "Alice", 305 + // missing age 306 + }); 307 + expect(result.success).toBe(false); 308 + if (!result.success) { 309 + expect(result.error).toBeDefined(); 310 + } 311 + }); 312 + 313 + it("should include error information for type mismatches", () => { 314 + const result = schema.validate({ 315 + name: "Alice", 316 + age: "not a number", 317 + }); 318 + expect(result.success).toBe(false); 319 + if (!result.success) { 320 + expect(result.error).toBeDefined(); 321 + } 322 + }); 323 + }); 324 + 325 + describe("edge cases", () => { 326 + const schema = lx.lexicon("test.edge", { 327 + main: lx.object({ 328 + name: lx.string({ required: true }), 329 + count: lx.integer({ required: true }), 330 + }), 331 + }); 332 + 333 + it("should reject null values for required fields", () => { 334 + const result = schema.validate({ 335 + name: null, 336 + count: 42, 337 + }); 338 + expect(result.success).toBe(false); 339 + }); 340 + 341 + it("should reject undefined values for required fields", () => { 342 + const result = schema.validate({ 343 + name: undefined, 344 + count: 42, 345 + }); 346 + expect(result.success).toBe(false); 347 + }); 348 + 349 + it("should handle empty strings", () => { 350 + const result = schema.validate({ 351 + name: "", 352 + count: 42, 353 + }); 354 + // Empty strings should be valid strings 355 + expect(result.success).toBe(true); 356 + }); 357 + 358 + it("should reject completely empty object", () => { 359 + const result = schema.validate({}); 360 + expect(result.success).toBe(false); 361 + }); 362 + 363 + it("should handle objects with extra properties", () => { 364 + const result = schema.validate({ 365 + name: "test", 366 + count: 42, 367 + extraProp: "should this be allowed?", 368 + }); 369 + // This test will reveal the current behavior 370 + // Lexicon spec typically allows additional properties 371 + expect(result.success).toBe(true); 372 + }); 373 + 374 + it("should reject wrong type primitives", () => { 375 + // The validator throws an error for completely wrong types 376 + try { 377 + const result = schema.validate("not an object"); 378 + expect(result.success).toBe(false); 379 + } catch (error) { 380 + // This is also acceptable - validator can throw for completely invalid types 381 + expect(error).toBeDefined(); 382 + } 383 + }); 384 + 385 + it("should reject arrays when expecting objects", () => { 386 + const result = schema.validate([]); 387 + expect(result.success).toBe(false); 388 + }); 389 + 390 + it("should handle deeply nested nulls", () => { 391 + const nestedSchema = lx.lexicon("test.nested", { 392 + main: lx.object({ 393 + user: lx.object({ 394 + name: lx.string({ required: true }), 395 + }), 396 + }), 397 + }); 398 + const result = nestedSchema.validate({ 399 + user: null, 400 + }); 401 + expect(result.success).toBe(false); 402 + }); 403 + }); 404 + 405 + describe("union types", () => { 406 + const schema = lx.lexicon("test.unions", { 407 + textPost: lx.object({ 408 + text: lx.string({ required: true }), 409 + }), 410 + imagePost: lx.object({ 411 + imageUrl: lx.string({ required: true }), 412 + }), 413 + main: lx.object({ 414 + post: lx.union(["#textPost", "#imagePost"], { required: true }), 415 + }), 416 + }); 417 + 418 + it("should accept valid first union variant", () => { 419 + const result = schema.validate({ 420 + post: { 421 + $type: "test.unions#textPost", 422 + text: "Hello world", 423 + }, 424 + }); 425 + expect(result.success).toBe(true); 426 + }); 427 + 428 + it("should accept valid second union variant", () => { 429 + const result = schema.validate({ 430 + post: { 431 + $type: "test.unions#imagePost", 432 + imageUrl: "https://example.com/image.png", 433 + }, 434 + }); 435 + expect(result.success).toBe(true); 436 + }); 437 + 438 + it("should reject data matching no union variant", () => { 439 + const result = schema.validate({ 440 + post: { 441 + $type: "test.unions#videoPost", 442 + videoUrl: "https://example.com/video.mp4", 443 + }, 444 + }); 445 + // AT Protocol unions are "open" - unknown types may be accepted 446 + // This test documents the actual behavior 447 + if (result.success) { 448 + // Open union behavior - accepts unknown types 449 + expect(result.success).toBe(true); 450 + } else { 451 + // Closed union behavior - rejects unknown types 452 + expect(result.success).toBe(false); 453 + } 454 + }); 455 + 456 + it("should reject incomplete union variant", () => { 457 + const result = schema.validate({ 458 + post: { 459 + $type: "test.unions#textPost", 460 + // missing text field 461 + }, 462 + }); 463 + expect(result.success).toBe(false); 464 + }); 465 + }); 466 + 467 + describe("token types", () => { 468 + const schema = lx.lexicon("test.tokens", { 469 + main: lx.object({ 470 + action: lx.string({ 471 + knownValues: ["like", "repost", "follow"], 472 + required: true, 473 + }), 474 + }), 475 + }); 476 + 477 + it("should accept known string values", () => { 478 + const result = schema.validate({ 479 + action: "like", 480 + }); 481 + expect(result.success).toBe(true); 482 + }); 483 + 484 + it("should accept other known string values", () => { 485 + const result = schema.validate({ 486 + action: "repost", 487 + }); 488 + expect(result.success).toBe(true); 489 + }); 490 + 491 + it("should handle unknown string values", () => { 492 + // String types with knownValues typically allow other values too 493 + const result = schema.validate({ 494 + action: "unknown-action", 495 + }); 496 + // This reveals current behavior - lexicon strings with knownValues are typically open 497 + expect(result.success).toBe(true); 498 + }); 499 + }); 500 + 501 + describe("record validation", () => { 502 + const schema = lx.lexicon("test.record", { 503 + main: lx.record({ 504 + key: "tid", 505 + record: lx.object({ 506 + title: lx.string({ required: true }), 507 + content: lx.string({ required: true }), 508 + createdAt: lx.string({ format: "datetime", required: true }), 509 + }), 510 + }), 511 + }); 512 + 513 + it("should accept valid record data", () => { 514 + const result = schema.validate({ 515 + title: "My Post", 516 + content: "This is the content", 517 + createdAt: "2025-01-01T00:00:00Z", 518 + }); 519 + expect(result.success).toBe(true); 520 + }); 521 + 522 + it("should reject record missing required fields", () => { 523 + const result = schema.validate({ 524 + title: "My Post", 525 + // missing content and createdAt 526 + }); 527 + expect(result.success).toBe(false); 528 + }); 529 + 530 + it("should reject record with invalid field types", () => { 531 + const result = schema.validate({ 532 + title: "My Post", 533 + content: 123, // wrong type 534 + createdAt: "2025-01-01T00:00:00Z", 535 + }); 536 + expect(result.success).toBe(false); 537 + }); 538 + 539 + it("should reject record with invalid datetime", () => { 540 + const result = schema.validate({ 541 + title: "My Post", 542 + content: "Content", 543 + createdAt: "not a datetime", 544 + }); 545 + expect(result.success).toBe(false); 546 + }); 547 + }); 548 + 549 + describe("bytes, CID, and unknown primitives", () => { 550 + const schema = lx.lexicon("test.primitives", { 551 + main: lx.object({ 552 + data: lx.bytes({ required: true }), 553 + hash: lx.string({ format: "cid", required: true }), 554 + metadata: lx.unknown(), 555 + }), 556 + }); 557 + 558 + it("should accept valid bytes data", () => { 559 + const result = schema.validate({ 560 + data: new Uint8Array([1, 2, 3, 4]), 561 + hash: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 562 + metadata: { custom: "data" }, 563 + }); 564 + expect(result.success).toBe(true); 565 + }); 566 + 567 + it("should accept valid CID string", () => { 568 + const result = schema.validate({ 569 + data: new Uint8Array([1, 2, 3]), 570 + hash: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 571 + }); 572 + expect(result.success).toBe(true); 573 + }); 574 + 575 + it("should reject invalid CID format", () => { 576 + const result = schema.validate({ 577 + data: new Uint8Array([1, 2, 3]), 578 + hash: "not-a-cid", 579 + }); 580 + expect(result.success).toBe(false); 581 + }); 582 + 583 + it("should accept any type for unknown field", () => { 584 + // Unknown fields accept object/array values but may have restrictions on primitives 585 + const objectResult = schema.validate({ 586 + data: new Uint8Array([1]), 587 + hash: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 588 + metadata: { any: "object" }, 589 + }); 590 + expect(objectResult.success).toBe(true); 591 + 592 + const arrayResult = schema.validate({ 593 + data: new Uint8Array([1]), 594 + hash: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 595 + metadata: [1, 2, 3], 596 + }); 597 + expect(arrayResult.success).toBe(true); 598 + }); 599 + 600 + it("should allow omitting optional unknown field", () => { 601 + const result = schema.validate({ 602 + data: new Uint8Array([1, 2, 3]), 603 + hash: "bafyreigvpnl2njkqy7qbqthw3r3emgbz2v6w5xrr4yhwj5jzymlwnvscam", 604 + }); 605 + expect(result.success).toBe(true); 606 + }); 607 + }); 608 + 609 + describe("validate with custom def parameter", () => { 610 + const schema = lx.lexicon("test.multidefs", { 611 + user: lx.object({ 612 + handle: lx.string({ required: true }), 613 + displayName: lx.string(), 614 + }), 615 + post: lx.object({ 616 + text: lx.string({ required: true }), 617 + author: lx.ref("#user", { required: true }), 618 + }), 619 + main: lx.object({ 620 + id: lx.string({ required: true }), 621 + name: lx.string({ required: true }), 622 + }), 623 + }); 624 + 625 + it("should validate against main def by default", () => { 626 + const result = schema.validate({ id: "123", name: "test" }); 627 + expect(result.success).toBe(true); 628 + }); 629 + 630 + it("should validate against main def when explicitly specified", () => { 631 + const result = schema.validate({ id: "123", name: "test" }, "main"); 632 + expect(result.success).toBe(true); 633 + }); 634 + 635 + it("should validate against user def when specified", () => { 636 + const result = schema.validate({ handle: "alice.bsky.social" }, "user"); 637 + expect(result.success).toBe(true); 638 + }); 639 + 640 + it("should validate against post def when specified", () => { 641 + const result = schema.validate( 642 + { 643 + text: "Hello world", 644 + author: { handle: "bob.bsky.social", displayName: "Bob" }, 645 + }, 646 + "post", 647 + ); 648 + expect(result.success).toBe(true); 649 + }); 650 + 651 + it("should reject invalid data for user def", () => { 652 + const result = schema.validate({ displayName: "Alice" }, "user"); 653 + expect(result.success).toBe(false); 654 + }); 655 + 656 + it("should reject invalid data for post def", () => { 657 + const result = schema.validate( 658 + { 659 + text: "Hello world", 660 + // missing author 661 + }, 662 + "post", 663 + ); 664 + expect(result.success).toBe(false); 665 + }); 666 + 667 + it("should reject main def data when validating against user def", () => { 668 + const result = schema.validate({ id: "123", name: "test" }, "user"); 669 + expect(result.success).toBe(false); 670 + }); 671 + 672 + it("should reject user def data when validating against main def", () => { 673 + const result = schema.validate({ handle: "alice.bsky.social" }); 674 + expect(result.success).toBe(false); 675 + }); 676 + 677 + it("should accept valid data with optional fields for user def", () => { 678 + const result = schema.validate( 679 + { handle: "alice.bsky.social", displayName: "Alice" }, 680 + "user", 681 + ); 682 + expect(result.success).toBe(true); 683 + }); 684 + }); 685 + 686 + describe("deep nesting", () => { 687 + const schema = lx.lexicon("test.deep", { 688 + level3: lx.object({ 689 + value: lx.string({ required: true }), 690 + }), 691 + level2: lx.object({ 692 + nested: lx.ref("#level3", { required: true }), 693 + id: lx.string({ required: true }), 694 + }), 695 + level1: lx.object({ 696 + nested: lx.ref("#level2", { required: true }), 697 + name: lx.string({ required: true }), 698 + }), 699 + main: lx.object({ 700 + nested: lx.ref("#level1", { required: true }), 701 + title: lx.string({ required: true }), 702 + }), 703 + }); 704 + 705 + it("should validate deeply nested valid data", () => { 706 + const result = schema.validate({ 707 + title: "Top level", 708 + nested: { 709 + name: "Level 1", 710 + nested: { 711 + id: "level2-id", 712 + nested: { 713 + value: "deep value", 714 + }, 715 + }, 716 + }, 717 + }); 718 + expect(result.success).toBe(true); 719 + }); 720 + 721 + it("should reject invalid data at deepest level", () => { 722 + const result = schema.validate({ 723 + title: "Top level", 724 + nested: { 725 + name: "Level 1", 726 + nested: { 727 + id: "level2-id", 728 + nested: { 729 + // missing value 730 + }, 731 + }, 732 + }, 733 + }); 734 + expect(result.success).toBe(false); 735 + }); 736 + 737 + it("should reject invalid data at middle level", () => { 738 + const result = schema.validate({ 739 + title: "Top level", 740 + nested: { 741 + name: "Level 1", 742 + nested: { 743 + // missing id 744 + nested: { 745 + value: "deep value", 746 + }, 747 + }, 748 + }, 749 + }); 750 + expect(result.success).toBe(false); 751 + }); 752 + 753 + it("should handle arrays of deeply nested objects", () => { 754 + const arraySchema = lx.lexicon("test.array-deep", { 755 + item: lx.object({ 756 + data: lx.object({ 757 + value: lx.string({ required: true }), 758 + }), 759 + }), 760 + main: lx.object({ 761 + items: lx.array(lx.ref("#item"), { required: true }), 762 + }), 763 + }); 764 + 765 + const result = arraySchema.validate({ 766 + items: [ 767 + { data: { value: "first" } }, 768 + { data: { value: "second" } }, 769 + { data: { value: "third" } }, 770 + ], 771 + }); 772 + expect(result.success).toBe(true); 773 + }); 774 + 775 + it("should reject invalid item in array of nested objects", () => { 776 + const arraySchema = lx.lexicon("test.array-deep-invalid", { 777 + item: lx.object({ 778 + data: lx.object({ 779 + value: lx.string({ required: true }), 780 + }), 781 + }), 782 + main: lx.object({ 783 + items: lx.array(lx.ref("#item"), { required: true }), 784 + }), 785 + }); 786 + 787 + const result = arraySchema.validate({ 788 + items: [ 789 + { data: { value: "first" } }, 790 + { data: {} }, // missing value in second item 791 + { data: { value: "third" } }, 792 + ], 793 + }); 794 + expect(result.success).toBe(false); 795 + }); 796 + });
+46
pnpm-lock.yaml
··· 51 51 52 52 packages/prototypey: 53 53 dependencies: 54 + '@atproto/lexicon': 55 + specifier: ^0.5.1 56 + version: 0.5.1 54 57 '@prototypey/cli': 55 58 specifier: workspace:* 56 59 version: link:../cli ··· 154 157 155 158 '@asamuzakjp/css-color@3.2.0': 156 159 resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} 160 + 161 + '@atproto/common-web@0.4.3': 162 + resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==} 163 + 164 + '@atproto/lexicon@0.5.1': 165 + resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==} 166 + 167 + '@atproto/syntax@0.4.1': 168 + resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} 157 169 158 170 '@babel/code-frame@7.27.1': 159 171 resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} ··· 1392 1404 isexe@2.0.0: 1393 1405 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1394 1406 1407 + iso-datestring-validator@2.2.2: 1408 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 1409 + 1395 1410 jiti@2.6.1: 1396 1411 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 1397 1412 hasBin: true ··· 1586 1601 1587 1602 ms@2.1.3: 1588 1603 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1604 + 1605 + multiformats@9.9.0: 1606 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1589 1607 1590 1608 nanoid@3.3.11: 1591 1609 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} ··· 1995 2013 engines: {node: '>=14.17'} 1996 2014 hasBin: true 1997 2015 2016 + uint8arrays@3.0.0: 2017 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 2018 + 1998 2019 unconfig@7.3.3: 1999 2020 resolution: {integrity: sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==} 2000 2021 ··· 2164 2185 '@csstools/css-tokenizer': 3.0.4 2165 2186 lru-cache: 10.4.3 2166 2187 2188 + '@atproto/common-web@0.4.3': 2189 + dependencies: 2190 + graphemer: 1.4.0 2191 + multiformats: 9.9.0 2192 + uint8arrays: 3.0.0 2193 + zod: 3.25.76 2194 + 2195 + '@atproto/lexicon@0.5.1': 2196 + dependencies: 2197 + '@atproto/common-web': 0.4.3 2198 + '@atproto/syntax': 0.4.1 2199 + iso-datestring-validator: 2.2.2 2200 + multiformats: 9.9.0 2201 + zod: 3.25.76 2202 + 2203 + '@atproto/syntax@0.4.1': {} 2204 + 2167 2205 '@babel/code-frame@7.27.1': 2168 2206 dependencies: 2169 2207 '@babel/helper-validator-identifier': 7.27.1 ··· 3395 3433 3396 3434 isexe@2.0.0: {} 3397 3435 3436 + iso-datestring-validator@2.2.2: {} 3437 + 3398 3438 jiti@2.6.1: {} 3399 3439 3400 3440 js-tokens@4.0.0: {} ··· 3560 3600 mri@1.2.0: {} 3561 3601 3562 3602 ms@2.1.3: {} 3603 + 3604 + multiformats@9.9.0: {} 3563 3605 3564 3606 nanoid@3.3.11: {} 3565 3607 ··· 3903 3945 - supports-color 3904 3946 3905 3947 typescript@5.8.3: {} 3948 + 3949 + uint8arrays@3.0.0: 3950 + dependencies: 3951 + multiformats: 9.9.0 3906 3952 3907 3953 unconfig@7.3.3: 3908 3954 dependencies: