cerulea/lexicon

define atproto schemas in TypeScript

git clone https://git.t4t.associates/cerulea/lexicon

Charlotte Somadd NamedSchema so we can represent cyclic lexicons2d07e5f

main
12.6 KiB402 linesraw
1import * as l from "../mod.ts";
2import { assert, equal, throws } from "./assert.ts";
3
4Deno.test("named accepts eager or memoized lazy definitions without losing descriptions", () => {
5  let calls = 0;
6  const Lazy = l.named("blue.cerulea.app.defs#holder", () => {
7    calls++;
8    return l.object({ item: Item });
9  });
10  const Described = l.withDescription(Lazy, "A holder.");
11  const Item = l.named(
12    "blue.cerulea.app.defs#item",
13    l.object({ text: l.string }),
14  );
15  assert(calls === 0);
16  const Eager = l.named(
17    "blue.cerulea.app.defs#holder",
18    l.object({ item: Item }),
19  );
20  const value = { item: { text: "hello" } };
21  assert(Described.validate(value).success);
22  assert(Lazy.validate(value).success);
23  equal(l.toLexicons(Lazy), l.toLexicons(Eager));
24  equal(calls, 1);
25  equal(Described.description, "A holder.");
26  equal(Lazy.description, undefined);
27  const Container = l.named(
28    "blue.cerulea.app.defs#container",
29    l.object({ holder: Described }),
30  );
31  equal(l.toLexicons(Container)[0]!.defs.container!.properties, {
32    holder: { type: "ref", ref: Lazy.id, description: "A holder." },
33  });
34});
35
36Deno.test("recursive objects emit refs, validate nested paths, and share untouched builder values", () => {
37  type Tree = {
38    readonly text: string;
39    readonly children?: readonly Tree[] | null;
40  };
41  const Tree: l.NamedSchema<"blue.cerulea.app.defs#tree", Tree> = l.named(
42    "blue.cerulea.app.defs#tree",
43    () =>
44      l.object({
45        text: l.string,
46        children: l.optional(l.nullable(l.array(Tree))),
47      }),
48  );
49  const Holder = l.record("blue.cerulea.app.tree", { key: "tid" }, {
50    tree: Tree,
51  });
52  const leaf: Tree = { text: "leaf", children: null };
53  const tree = {
54    text: "root",
55    children: [leaf, leaf, { text: "branch", children: [leaf] }],
56  };
57  const original = structuredClone(tree);
58  assert(Tree.validate(tree).success);
59  assert(Holder.build({ tree }).tree === tree);
60  assert(Holder.validate(Holder.build({ tree })).success);
61  equal(tree, original);
62  const bad = Tree.validate({
63    text: "root",
64    children: [{ text: "branch", children: [{ text: 1 }] }],
65  });
66  assert(!bad.success);
67  equal(bad.issues.map((issue) => issue.path), [[
68    "children",
69    0,
70    "children",
71    0,
72    "text",
73  ]]);
74  equal(l.toLexicons(Tree), [{
75    lexicon: 1,
76    id: "blue.cerulea.app.defs",
77    defs: {
78      tree: {
79        type: "object",
80        properties: {
81          text: { type: "string" },
82          children: { type: "array", items: { type: "ref", ref: Tree.id } },
83        },
84        required: ["text"],
85        nullable: ["children"],
86      },
87    },
88  }]);
89  equal(l.toLexicons(Holder, Tree), l.toLexicons(Holder));
90});
91
92Deno.test("unions of named definitions support recursive variants and reuse ordinary refs", () => {
93  type Thread = { readonly text: string; readonly replies?: readonly Reply[] };
94  type Reply =
95    | ({ readonly $type: "app.bsky.feed.defs#threadViewPost" } & Thread)
96    | {
97      readonly $type: "app.bsky.feed.defs#notFoundPost";
98      readonly uri: `at://${string}`;
99    };
100  const Thread: l.NamedSchema<"app.bsky.feed.defs#threadViewPost", Thread> = l
101    .named(
102      "app.bsky.feed.defs#threadViewPost",
103      () =>
104        l.object({
105          text: l.string,
106          replies: l.optional(l.array(l.union([Thread, Missing]))),
107        }),
108    );
109  const Missing = l.named(
110    "app.bsky.feed.defs#notFoundPost",
111    l.object({ uri: l.atUri }),
112  );
113  const Reply = l.union([Thread, Missing]);
114  const value: l.Input<typeof Reply> = {
115    $type: Thread.id,
116    text: "root",
117    replies: [{
118      $type: Thread.id,
119      text: "child",
120      replies: [{
121        $type: Missing.id,
122        uri: "at://did:plc:alice/app.bsky.feed.post/abc",
123      }],
124    }],
125  };
126  const built = Reply.build(value);
127  equal(built, value);
128  assert(Reply.validate(built).success);
129  const bad = Reply.validate({
130    ...value,
131    replies: [{
132      $type: Thread.id,
133      text: "child",
134      replies: [{ $type: Missing.id, uri: "bad" }],
135    }],
136  });
137  assert(!bad.success);
138  equal(bad.issues.map((issue) => issue.path), [[
139    "replies",
140    0,
141    "replies",
142    0,
143    "uri",
144  ]]);
145  assert(
146    !Reply.validate({ ...value, $type: "app.bsky.feed.defs#other" }).success,
147  );
148  assert(
149    l.union([Thread, Missing], { closed: false }).validate({
150      $type: "app.bsky.feed.defs#future",
151    }).success,
152  );
153  const docs = l.toLexicons(Reply, Thread, Missing);
154  equal(docs, l.toLexicons(Missing, Thread));
155  equal(docs, [{
156    lexicon: 1,
157    id: "app.bsky.feed.defs",
158    defs: {
159      notFoundPost: {
160        type: "object",
161        properties: { uri: { type: "string", format: "at-uri" } },
162        required: ["uri"],
163      },
164      threadViewPost: {
165        type: "object",
166        properties: {
167          text: { type: "string" },
168          replies: {
169            type: "array",
170            items: {
171              type: "union",
172              refs: [Missing.id, Thread.id],
173              closed: true,
174            },
175          },
176        },
177        required: ["text"],
178      },
179    },
180  }]);
181});
182
183Deno.test("builders expand unions and records throughout mutually recursive definitions", () => {
184  const Payload = l.union("blue.cerulea.app.payload", {
185    "#text": l.object({ text: l.string }),
186  });
187  const Note = l.record("blue.cerulea.app.note", { key: "tid" }, {
188    text: l.string,
189  });
190  type A = {
191    readonly b?: B;
192    readonly payload: l.Infer<typeof Payload>;
193    readonly note: l.Infer<typeof Note>;
194  };
195  type B = { readonly a?: A };
196  type AInput = {
197    readonly b?: BInput;
198    readonly payload: l.Input<typeof Payload>;
199    readonly note: l.Input<typeof Note>;
200  };
201  type BInput = { readonly a?: AInput };
202  const A: l.NamedSchema<"blue.cerulea.app.a#view", A, AInput> = l.named(
203    "blue.cerulea.app.a#view",
204    () => l.object({ b: l.optional(B), payload: Payload, note: Note }),
205  );
206  const B: l.NamedSchema<"blue.cerulea.app.b#view", B, BInput> = l.named(
207    "blue.cerulea.app.b#view",
208    () => l.object({ a: l.optional(A) }),
209  );
210  const Holder = l.record("blue.cerulea.app.holder", { key: "tid" }, {
211    a: A,
212    b: B,
213  });
214  const payload = { $type: "#text", text: "hello" } as const;
215  const note = { text: "note" };
216  const leaf = { payload, note };
217  const input = {
218    a: { b: { a: leaf }, ...leaf },
219    b: { a: { b: { a: leaf }, ...leaf } },
220  };
221  const original = structuredClone(input);
222  const expanded = {
223    payload: { $type: "blue.cerulea.app.payload#text", text: "hello" },
224    note: { $type: "blue.cerulea.app.note", text: "note" },
225  };
226  equal(Payload.build(payload), expanded.payload);
227  equal(Note.build(note), expanded.note);
228  const expected = {
229    $type: Holder.id,
230    a: { b: { a: expanded }, ...expanded },
231    b: { a: { b: { a: expanded }, ...expanded } },
232  };
233  equal(Holder.build(input), expected);
234  equal(Holder.build(input), expected);
235  equal(Payload.build(payload), expanded.payload);
236  equal(Note.build(note), expanded.note);
237  assert(Holder.validate(expected).success);
238  equal(input, original);
239  equal(l.toLexicons(A, B, Holder), l.toLexicons(Holder));
240  const docs = l.toLexicons(A);
241  equal(
242    docs.find((doc) => doc.id === "blue.cerulea.app.b")!.defs.view!.properties,
243    {
244      a: { type: "ref", ref: A.id },
245    },
246  );
247});
248
249Deno.test("lazy definitions can lead back to an enclosing record", () => {
250  type Tree = {
251    readonly $type: "blue.cerulea.app.tree";
252    readonly text: string;
253    readonly branch?: Branch;
254  };
255  type Branch = { readonly tree?: Tree };
256  type TreeInput = {
257    readonly $type?: "blue.cerulea.app.tree";
258    readonly text: string;
259    readonly branch?: BranchInput;
260  };
261  type BranchInput = { readonly tree?: TreeInput };
262  const Branch: l.NamedSchema<"blue.cerulea.app.tree#branch", Branch, BranchInput> = l.named(
263    "blue.cerulea.app.tree#branch",
264    () => l.object({ tree: l.optional(Tree) }),
265  );
266  const Tree = l.record("blue.cerulea.app.tree", { key: "tid" }, {
267    text: l.string,
268    branch: l.optional(Branch),
269  });
270  const built = Tree.build({ text: "root", branch: { tree: { text: "leaf" } } });
271  equal(built, {
272    $type: Tree.id,
273    text: "root",
274    branch: { tree: { $type: Tree.id, text: "leaf" } },
275  });
276  assert(Tree.validate(built).success);
277  const [doc] = l.toLexicons(Tree);
278  equal(Object.keys(doc!.defs), ["branch", "main"]);
279  equal(doc!.defs.branch!.properties, { tree: { type: "ref", ref: Tree.id } });
280  equal(doc!.defs.main!.record, {
281    type: "object",
282    properties: { text: { type: "string" }, branch: { type: "ref", ref: Branch.id } },
283    required: ["text"],
284  });
285});
286
287Deno.test("recursive array definitions and lazy RPC bodies retain their schemas", () => {
288  type Nested = readonly Nested[];
289  const Nested: l.NamedSchema<"blue.cerulea.app.defs#nested", Nested> = l.named(
290    "blue.cerulea.app.defs#nested",
291    () => l.array(Nested),
292  );
293  assert(Nested.validate([[], [[[]]]]).success);
294  const bad = Nested.validate([[1]]);
295  assert(!bad.success);
296  equal(bad.issues.map((issue) => issue.path), [[0, 0]]);
297  equal(l.toLexicons(Nested)[0]!.defs.nested, {
298    type: "array",
299    items: { type: "ref", ref: Nested.id },
300  });
301  const Body = l.named(
302    "blue.cerulea.app.defs#body",
303    () => l.object({ nested: Nested }),
304  );
305  const endpoint = l.procedure("blue.cerulea.app.echo", {
306    input: Body,
307    output: Body,
308  });
309  assert(endpoint.input.validate({ nested: [[]] }).success);
310  equal(
311    l.toLexicons(endpoint).find((doc) => doc.id === endpoint.id)!.defs.main!
312      .output,
313    {
314      encoding: "application/json",
315      schema: { type: "ref", ref: Body.id },
316    },
317  );
318  throws(
319    () => l.query("blue.cerulea.app.read", { output: Nested }),
320    "RPC bodies",
321  );
322});
323
324Deno.test("recursive definition deduplication does not hide conflicting IDs", () => {
325  type Link<T> = { readonly value: T; readonly next?: Link<T> };
326  const id = "blue.cerulea.app.defs#link";
327  const A: l.NamedSchema<typeof id, Link<string>> = l.named(
328    id,
329    () => l.object({ value: l.string, next: l.optional(A) }),
330  );
331  const Same: l.NamedSchema<typeof id, Link<string>> = l.named(
332    id,
333    () => l.object({ value: l.string, next: l.optional(Same) }),
334  );
335  const Different: l.NamedSchema<typeof id, Link<number>> = l.named(
336    id,
337    () => l.object({ value: l.integer, next: l.optional(Different) }),
338  );
339  equal(l.toLexicons(A, Same), l.toLexicons(A));
340  throws(() => l.toLexicons(A, Different), `Conflicting definition: ${id}`);
341  throws(() => l.toLexicons(Different, A), `Conflicting definition: ${id}`);
342  const NestedConflict: l.NamedSchema = l.named(
343    id,
344    () => l.object({ next: Different }),
345  );
346  throws(() => l.toLexicons(NestedConflict), `Conflicting definition: ${id}`);
347});
348
349Deno.test("lazy targets enforce concrete definitions and failed resolution can be retried", () => {
350  let available = false;
351  const Retry = l.named("blue.cerulea.app.defs#retry", () => {
352    if (!available) throw new Error("Not ready");
353    return l.object({ text: l.string });
354  });
355  const Holder = l.record("blue.cerulea.app.retry", { key: "tid" }, {
356    item: Retry,
357  });
358  throws(() => Holder.build({ item: { text: "hi" } }), "Not ready");
359  throws(() => Retry.validate({}), "Not ready");
360  throws(() => l.toLexicons(Retry), "Not ready");
361  available = true;
362  assert(Holder.validate(Holder.build({ item: { text: "hi" } })).success);
363  assert(l.toLexicons(Retry).length === 1);
364  for (
365    const target of [
366      Retry,
367      l.union({ "blue.cerulea.app.defs#empty": l.object({}) }),
368      Holder,
369    ]
370  ) {
371    throws(
372      () => l.named("blue.cerulea.app.defs#invalid", target),
373      "concrete definitions",
374    );
375    const Lazy = l.named("blue.cerulea.app.defs#invalid", () => target);
376    throws(() => l.compile(Lazy), "concrete definitions");
377    throws(() => l.toLexicons(Lazy), "concrete definitions");
378  }
379  const Reentrant: l.NamedSchema = l.named(
380    "blue.cerulea.app.defs#reentrant",
381    () => Reentrant.target,
382  );
383  throws(() => l.toLexicons(Reentrant), "Circular definition factory");
384  throws(() => l.compile(Reentrant), "Circular definition factory");
385  throws(() => l.named("invalid", () => l.string), "definition ID");
386});
387
388Deno.test("named union variants must resolve to objects and have unique IDs", () => {
389  const Text = l.named("blue.cerulea.app.defs#text", () => l.string);
390  const Union = l.union([Text]);
391  throws(() => l.compile(Union), "must be an object");
392  throws(() => l.toLexicons(Union), "must be an object");
393  throws(() => Union.build({ $type: Text.id } as never), "must be an object");
394  const Object = l.named("blue.cerulea.app.defs#object", l.object({}));
395  throws(() => l.union([Object, Object]), "Duplicate union variant");
396  throws(() => l.union([]), "empty");
397  assert(
398    l.union([], { closed: false }).validate({
399      $type: "blue.cerulea.app.defs#future",
400    }).success,
401  );
402});