cerulea/lexicon

define atproto schemas in TypeScript

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

Charlotte Sominitial commit1945441

main
14.4 KiB349 linesraw
1import * as l from "../mod.ts";
2import { assert, equal, throws } from "./assert.ts";
3
4const range = { start: l.integerWith({ minimum: 0 }), end: l.integerWith({ minimum: 0 }) };
5const Facet = l.union("blue.cerulea.app.facet", {
6  "#mention": l.object({ ...range, did: l.did }),
7  "#link": l.object({ ...range, uri: l.uri }),
8});
9const Post = l.record("blue.cerulea.app.post", { key: "tid" }, {
10  text: l.string,
11  facets: l.array(Facet),
12  createdAt: l.optional(l.datetime),
13});
14const post = {
15  $type: "blue.cerulea.app.post",
16  text: "🙂 hi",
17  facets: [{ $type: "blue.cerulea.app.facet#mention", start: 3, end: 5, did: "did:plc:alice" }],
18};
19const cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
20
21Deno.test("records and discriminated variants validate without mutating data", () => {
22  const check = l.compile(Post);
23  const input = structuredClone(post);
24  const result = check(input);
25  assert(result.success);
26  assert(result.value === input);
27  equal(input, post);
28  assert(check({ ...post, extra: { future: true } }).success);
29  assert(!check({ ...post, $type: "blue.cerulea.app.other" }).success);
30  assert(!check({ text: "", facets: [] }).success);
31});
32
33Deno.test("record builder adds the discriminator without mutating fields", () => {
34  const fields = { text: "hello", facets: [] };
35  const built = Post.build(fields);
36  equal(built, { ...fields, $type: Post.id });
37  assert(!Object.hasOwn(fields, "$type"));
38  assert(Post.validate(built).success);
39  throws(() => Post.build({ ...fields, $type: Post.id } as never), "$type");
40  const described = l.withDescription(Post, "A post.");
41  assert(described.build(fields).$type === Post.id);
42  assert(described.validate(built).success);
43});
44
45Deno.test("builders expand short union variants and nested records throughout the value", () => {
46  const Embed = l.union("blue.cerulea.app.embed", {
47    "#facets": l.object({ facets: l.array(Facet) }),
48    "#post": l.object({ post: Post }),
49  });
50  const Holder = l.record("blue.cerulea.app.holder", { key: "tid" }, {
51    embeds: l.array(Embed),
52    pin: l.optional(l.nullable(l.named("blue.cerulea.app.defs#pin", l.object({ facet: Facet })))),
53  });
54  const stored = Post.validate(post);
55  assert(stored.success);
56  const link = { start: 0, end: 1, uri: "https://example.com" };
57  const fields = {
58    embeds: [
59      { $type: "#facets", facets: [{ $type: "#link", ...link }] },
60      { $type: "#post", post: { text: "", facets: [{ $type: "#link", ...link }] } },
61      { $type: "blue.cerulea.app.embed#post", post: stored.value },
62    ],
63    pin: { facet: { $type: "#link", ...link } },
64  } as const;
65  const original = structuredClone(fields);
66  const built = Holder.build(fields);
67  equal(fields, original);
68  const facet = { $type: "blue.cerulea.app.facet#link", ...link };
69  equal(built, {
70    $type: Holder.id,
71    embeds: [
72      { $type: "blue.cerulea.app.embed#facets", facets: [facet] },
73      { $type: "blue.cerulea.app.embed#post", post: { $type: Post.id, text: "", facets: [facet] } },
74      { $type: "blue.cerulea.app.embed#post", post },
75    ],
76    pin: { facet },
77  });
78  assert(Holder.validate(built).success);
79  equal(Holder.build({ embeds: [], pin: null }), { $type: Holder.id, embeds: [], pin: null });
80  equal(Facet.build({ $type: "#link", ...link }), facet);
81});
82
83Deno.test("builders reject discriminators they cannot expand", () => {
84  throws(() => Facet.build({ $type: "#other" } as never), "Unknown union variant #other at $type");
85  throws(() => Post.build({ text: "", facets: [{ $type: "#other" }] } as never), "facets.0.$type");
86  const anonymous = l.union({ "blue.cerulea.app.facet#link": l.object({}) });
87  throws(() => anonymous.build({ $type: "#link" } as never), "namespaced");
88  const Holder = l.record("blue.cerulea.app.holder", { key: "tid" }, { post: Post });
89  throws(
90    () => Holder.build({ post: { ...post, $type: "blue.cerulea.app.other" } } as never),
91    "Expected blue.cerulea.app.post at post.$type",
92  );
93  const open = l.union("blue.cerulea.app.open", {
94    "#known": l.object({ facets: l.array(Facet) }),
95  }, { closed: false });
96  const future = { $type: "other.example.defs#future", facets: [{ $type: "#link" }] };
97  equal(open.build(future), future);
98  throws(() => open.build({ $type: "#future" }), "Unknown union variant");
99});
100
101Deno.test("schema validation is bound to each schema, including derived descriptions", () => {
102  const text = l.stringWith({ maxLength: 3 });
103  const described = l.withDescription(text, "A short string.");
104  assert(described.validate("abc").success);
105  assert(!described.validate("abcd").success);
106  assert(!text.validate(42).success);
107  assert(described.description === "A short string.");
108  assert(text.description === undefined);
109  assert(l.params({ limit: l.integer }).validate({ limit: 3 }).success);
110});
111
112Deno.test("union validation selects only the declared discriminator", () => {
113  const check = l.compile(Facet);
114  assert(check(post.facets[0]).success);
115  for (
116    const value of [
117      { start: 0, end: 1, did: "did:plc:alice" },
118      { $type: "blue.cerulea.app.facet#unknown", start: 0, end: 1 },
119      { $type: "blue.cerulea.app.facet#mention", start: 0, end: 1, uri: "https://example.com" },
120      { $type: "blue.cerulea.app.facet#link", start: 0.5, end: 1, uri: "https://example.com" },
121    ]
122  ) assert(!check(value).success);
123  const bad = l.compile(Post)({ ...post, facets: [{ ...post.facets[0], did: 42 }] });
124  assert(!bad.success);
125  equal(bad.issues.map((i) => i.path), [["facets", 0, "did"]]);
126});
127
128Deno.test("Lexicon output declares variants and uses refs, not explicit $type fields", () => {
129  equal(l.toLexicons(Post), [
130    {
131      lexicon: 1,
132      id: "blue.cerulea.app.facet",
133      defs: {
134        link: {
135          type: "object",
136          properties: {
137            start: { type: "integer", minimum: 0 },
138            end: { type: "integer", minimum: 0 },
139            uri: { type: "string", format: "uri" },
140          },
141          required: ["end", "start", "uri"],
142        },
143        mention: {
144          type: "object",
145          properties: {
146            start: { type: "integer", minimum: 0 },
147            end: { type: "integer", minimum: 0 },
148            did: { type: "string", format: "did" },
149          },
150          required: ["did", "end", "start"],
151        },
152      },
153    },
154    {
155      lexicon: 1,
156      id: "blue.cerulea.app.post",
157      defs: {
158        main: {
159          type: "record",
160          key: "tid",
161          record: {
162            type: "object",
163            properties: {
164              text: { type: "string" },
165              facets: {
166                type: "array",
167                items: {
168                  type: "union",
169                  refs: ["blue.cerulea.app.facet#link", "blue.cerulea.app.facet#mention"],
170                  closed: true,
171                },
172              },
173              createdAt: { type: "string", format: "datetime" },
174            },
175            required: ["facets", "text"],
176          },
177        },
178      },
179    },
180  ]);
181});
182
183Deno.test("optional and nullable are independent and undefined means absent", () => {
184  const schema = l.named(
185    "blue.cerulea.app.defs#fields",
186    l.object({
187      required: l.string,
188      optional: l.optional(l.string),
189      nullable: l.nullable(l.string),
190      both: l.optional(l.nullable(l.string)),
191    }),
192  );
193  const check = l.compile(schema);
194  assert(check({ required: "", nullable: null }).success);
195  assert(check({ required: "", nullable: "", optional: "", both: null }).success);
196  assert(!check({ required: "" }).success);
197  assert(!check({ required: "", nullable: null, optional: null }).success);
198  assert(check({ required: "", nullable: null, optional: undefined, both: undefined }).success);
199  assert(!check({ required: "", nullable: undefined }).success);
200  const def = l.toLexicons(schema)[0]!.defs.fields!;
201  equal(def.required, ["nullable", "required"]);
202  equal(def.nullable, ["both", "nullable"]);
203});
204
205Deno.test("open unions permit well-formed unknown variants but still validate known ones", () => {
206  const schema = l.union("blue.cerulea.app.open", {
207    "#known": l.object({ text: l.string }),
208  }, { closed: false });
209  const check = l.compile(schema);
210  assert(check({ $type: "other.example.defs#future", value: [true, null, 3] }).success);
211  assert(!check({ $type: "blue.cerulea.app.open#known", text: 3 }).success);
212  assert(!check({ $type: "future", text: "" }).success);
213  assert(check({ $type: "other.example.defs#future", value: 0.5 }).success);
214  assert(
215    l.compile(l.union("blue.cerulea.app.empty", {}, { closed: false }))({
216      $type: "other.example.defs#future",
217    }).success,
218  );
219});
220
221Deno.test("named definitions are collected transitively, deduplicated, and ordered", () => {
222  const Ref = l.named("blue.cerulea.app.defs#strongRef", l.object({ uri: l.atUri, cid: l.cid }));
223  const Reply = l.named("blue.cerulea.app.defs#reply", l.object({ root: Ref, parent: Ref }));
224  const A = l.record("blue.cerulea.app.a", { key: "tid" }, { reply: Reply });
225  const B = l.record("blue.cerulea.app.b", { key: "literal:self" }, { pinned: Ref, record: A });
226  const docs = l.toLexicons(A, B, Ref, Ref);
227  equal(docs, l.toLexicons(B, A));
228  equal(docs.map((d) => d.id), [
229    "blue.cerulea.app.a",
230    "blue.cerulea.app.b",
231    "blue.cerulea.app.defs",
232  ]);
233  equal(Object.keys(docs[2]!.defs), ["reply", "strongRef"]);
234  equal(docs[2]!.defs.reply, {
235    type: "object",
236    properties: {
237      root: { type: "ref", ref: "blue.cerulea.app.defs#strongRef" },
238      parent: { type: "ref", ref: "blue.cerulea.app.defs#strongRef" },
239    },
240    required: ["parent", "root"],
241  });
242});
243
244Deno.test("conflicting definitions fail rather than overwrite each other", () => {
245  const a = l.named("blue.cerulea.app.defs#item", l.object({ a: l.string }));
246  const same = l.named("blue.cerulea.app.defs#item", l.object({ a: l.string }));
247  const b = l.named("blue.cerulea.app.defs#item", l.object({ b: l.integer }));
248  equal(l.toLexicons(a, same), l.toLexicons(a));
249  throws(() => l.toLexicons(a, b), "Conflicting definition");
250});
251
252Deno.test("unrepresentable and invalid definitions are rejected", () => {
253  throws(() => l.toLexicons(l.object({})), "roots");
254  throws(
255    () => l.toLexicons(l.record("blue.cerulea.app.post", { key: "tid" }, { nested: l.object({}) })),
256    "Nested objects",
257  );
258  throws(
259    () => l.toLexicons(l.named("blue.cerulea.app.defs#matrix", l.array(l.array(l.integer)))),
260    "Nested arrays",
261  );
262  throws(() => l.named("blue.cerulea.app.defs#union", Facet), "concrete definitions");
263  throws(() => l.object({ $type: l.string }), "$type");
264  throws(() => l.union("blue.cerulea.app.facet", {}), "empty");
265  throws(() => l.union("bad", { "#x": l.object({}) }), "namespace");
266  throws(() => l.union("blue.cerulea.app.facet", { "#main": l.object({}) }), "#main");
267  throws(() => l.union("blue.cerulea.app.facet", { "#bad-name": l.object({}) }), "definition ID");
268  throws(() => l.named("blue.cerulea.app.defs#main", l.string), "#main");
269  throws(() => l.record("blue.cerulea.app.post", { key: "literal:.." }, {}), "record key");
270  throws(() => l.stringWith({ minLength: 4, maxLength: 3 }), "Minimum");
271  throws(() => l.integerWith({ minimum: 0.5 }), "safe integers");
272  throws(() => l.array(l.string, { maxLength: -1 }), ">= 0");
273  throws(() => l.literal(NaN), "safe integers");
274  throws(() => l.blob({ accept: ["image/p*"] }), "MIME pattern");
275});
276
277Deno.test("string limits count UTF-8 bytes and graphemes, not UTF-16 indices", () => {
278  const check = l.compile(l.stringWith({ maxLength: 4, maxGraphemes: 1 }));
279  assert(check("🙂").success);
280  assert(check("e\u0301").success);
281  assert(!check("🙂a").success);
282  assert(!check("ab").success);
283  assert(!check("\ud800").success);
284});
285
286Deno.test("integer, literal, enum, array and byte constraints", () => {
287  const check = l.compile(l.integerWith({ minimum: -1, maximum: 1 }));
288  for (const v of [-1, 0, 1]) assert(check(v).success);
289  for (const v of [2, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0"]) {
290    assert(!check(v).success);
291  }
292  assert(l.compile(l.literal(false))(false).success);
293  assert(!l.compile(l.literal(false))(true).success);
294  assert(l.compile(l.enumValues("one", "two"))("two").success);
295  assert(!l.compile(l.enumValues("one", "two"))("three").success);
296  assert(l.compile(l.array(l.boolean, { minLength: 1, maxLength: 2 }))([false]).success);
297  assert(!l.compile(l.array(l.boolean, { minLength: 1 }))([]).success);
298  assert(!l.compile(l.array(l.boolean, { maxLength: 1 }))([true, false]).success);
299  assert(!l.compile(l.array(l.boolean))(new Array(1)).success);
300  assert(l.compile(l.bytesWith({ minLength: 1, maxLength: 2 }))({ $bytes: "AQI=" }).success);
301  assert(!l.compile(l.bytesWith({ maxLength: 1 }))({ $bytes: "AQI=" }).success);
302  assert(!l.compile(l.bytes)([1]).success);
303});
304
305Deno.test("blob refs enforce CID, size, and MIME constraints", () => {
306  const check = l.compile(l.blob({ accept: ["image/*"], maxSize: 100 }));
307  const value = { $type: "blob", ref: { $link: cid }, mimeType: "image/png", size: 100 };
308  assert(check(value).success);
309  for (
310    const change of [
311      { size: 101 },
312      { size: -1 },
313      { size: 0 },
314      { size: 1.5 },
315      { mimeType: "video/mp4" },
316      { ref: { $link: "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } },
317      { mimeType: "image/*" },
318      { ref: { $link: "not a CID" } },
319      { $type: "other" },
320    ]
321  ) assert(!check({ ...value, ...change }).success);
322  const def = l.toLexicons(
323    l.named("blue.cerulea.app.defs#image", l.blob({ accept: ["image/*"], maxSize: 100 })),
324  )[0]!.defs.image;
325  equal(def, { type: "blob", accept: ["image/*"], maxSize: 100 });
326});
327
328Deno.test("extra fields are accepted whatever their value, for forward compatibility", () => {
329  const check = l.compile(l.object({ text: l.string }));
330  const cyclic: Record<string, unknown> = { text: "" };
331  cyclic.extra = cyclic;
332  assert(check(cyclic).success);
333  for (
334    const extra of [undefined, 0.1, new Date(), { $bytes: false }, { $link: "x" }, {
335      $type: "blob",
336    }]
337  ) assert(check({ text: "", extra }).success);
338  assert(!check(Object.create({ text: "inherited" })).success);
339  assert(check(Object.assign(Object.create(null), { text: "" })).success);
340});
341
342Deno.test("prototype-looking property names are ordinary data", () => {
343  const properties = { ["__proto__"]: l.string, constructor: l.integer };
344  const schema = l.named("blue.cerulea.app.defs#safe", l.object(properties));
345  const value = JSON.parse('{"__proto__":"ok","constructor":1}');
346  assert(l.compile(schema)(value).success);
347  const doc = JSON.parse(JSON.stringify(l.toLexicons(schema)))[0];
348  equal(doc.defs.safe.properties.__proto__, { type: "string" });
349});