cerulea/lexicon

define atproto schemas in TypeScript

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

Charlotte Somschema: split union() into its three ways of naming variants62f5f17

main
16.0 KiB457 linesraw
1import { expand } from "./util/build.ts";
2import { compile, type ValidationResult } from "./validation.ts";
3import { isNsid } from "./util/syntax.ts";
4
5export declare const output: unique symbol;
6export declare const input: unique symbol;
7
8interface Typed<T, I = T> {
9  readonly [output]?: readonly [T];
10  readonly [input]?: readonly [I];
11}
12
13// deno-lint-ignore ban-types -- the intersection forces editor expansion
14type Simplify<T> = { [K in keyof T]: T[K] } & {};
15
16type Channel = typeof output | typeof input;
17type Read<S, C extends Channel> = S extends { readonly [K in C]?: readonly [infer T] } ? T : never;
18export type Infer<S> = Read<S, typeof output>;
19/** What builders accept for a schema, where union and record discriminators may be implied. */
20export type Input<S> = Read<S, typeof input>;
21type Validated<T, I = T> = Typed<T, I> & { validate(value: unknown): ValidationResult<T> };
22export type Schema<T = unknown, I = T> = Node & Validated<T, I>;
23
24export type StringFormat =
25  | "did"
26  | "handle"
27  | "at-identifier"
28  | "at-uri"
29  | "datetime"
30  | "uri"
31  | "language"
32  | "cid"
33  | "nsid"
34  | "tid";
35export type StringOptions = {
36  readonly description?: string;
37  readonly format?: StringFormat;
38  readonly minLength?: number;
39  readonly maxLength?: number;
40  readonly minGraphemes?: number;
41  readonly maxGraphemes?: number;
42};
43export type IntegerOptions = {
44  readonly description?: string;
45  readonly minimum?: number;
46  readonly maximum?: number;
47};
48export type LengthOptions = {
49  readonly description?: string;
50  readonly minLength?: number;
51  readonly maxLength?: number;
52};
53export type BlobOptions = {
54  readonly description?: string;
55  readonly accept?: readonly string[];
56  readonly maxSize?: number;
57};
58
59export type CidLink = { readonly $link: string };
60export type BytesValue = { readonly $bytes: string };
61export type BlobValue = {
62  readonly $type: "blob";
63  readonly ref: CidLink;
64  readonly mimeType: string;
65  readonly size: number;
66};
67
68export type OptionalField<T = unknown, I = T> = Typed<T, I> & {
69  readonly type: "optional";
70  readonly inner: Schema | NullableField;
71};
72export type NullableField<T = unknown, I = T> = Typed<T | null, I | null> & {
73  readonly type: "nullable";
74  readonly inner: Schema;
75};
76export type Field = Schema | OptionalField | NullableField;
77export type Shape = Readonly<Record<string, Field>>;
78
79type ObjectValue<S extends Shape, C extends Channel = typeof output> =
80  & { readonly [K in keyof S as S[K] extends OptionalField ? never : K]: Read<S[K], C> }
81  & {
82    readonly [K in keyof S as S[K] extends OptionalField ? K : never]?: Read<S[K], C> | undefined;
83  };
84
85type Node =
86  | (
87    & { readonly type: "string"; readonly const?: string; readonly enum?: readonly string[] }
88    & StringOptions
89  )
90  | (
91    & { readonly type: "integer"; readonly const?: number; readonly enum?: readonly number[] }
92    & IntegerOptions
93  )
94  | { readonly type: "boolean"; readonly const?: boolean; readonly description?: string }
95  | ({ readonly type: "bytes" } & LengthOptions)
96  | { readonly type: "cid-link"; readonly description?: string }
97  | ({ readonly type: "blob" } & BlobOptions)
98  | ({ readonly type: "array"; readonly items: Schema } & LengthOptions)
99  | { readonly type: "object"; readonly properties: Shape; readonly description?: string }
100  | {
101    readonly type: "ref";
102    readonly id: string;
103    readonly target: Schema;
104    readonly description?: string;
105  }
106  | {
107    readonly type: "union";
108    readonly variants: Variants;
109    readonly closed: boolean;
110    readonly namespace?: string;
111    readonly description?: string;
112  }
113  | {
114    readonly type: "record";
115    readonly id: string;
116    readonly key: RecordKey;
117    readonly record: ObjectSchema;
118    readonly description?: string;
119  };
120
121export type ObjectSchema<S extends Shape = Shape> =
122  & Schema<ObjectValue<S>, ObjectValue<S, typeof input>>
123  & {
124    readonly type: "object";
125    readonly properties: S;
126  };
127export type RecordKey = "tid" | "any" | `literal:${string}`;
128
129const validators = new WeakMap<object, (value: unknown) => ValidationResult<unknown>>();
130const schemaPrototype = {
131  validate<T>(this: Schema<T>, value: unknown): ValidationResult<T> {
132    const check = validators.get(this) ?? compile(this);
133    validators.set(this, check);
134    return check(value) as ValidationResult<T>;
135  },
136};
137const unionPrototype = Object.setPrototypeOf({
138  build(this: Schema, value: unknown) {
139    return expand(this, value);
140  },
141}, schemaPrototype);
142const recordPrototype = Object.setPrototypeOf({
143  build(this: Schema, fields: Record<string, unknown>) {
144    if (Object.hasOwn(fields, "$type")) throw new Error("Record fields must not include $type");
145    return expand(this, fields);
146  },
147}, schemaPrototype);
148
149function schema<S extends Node>(node: S): S & typeof schemaPrototype {
150  return Object.setPrototypeOf(node, schemaPrototype);
151}
152
153export function withDescription<S extends Schema>(source: S, description: string): S {
154  return Object.create(Object.getPrototypeOf(source), {
155    ...Object.getOwnPropertyDescriptors(source),
156    description: { value: description, enumerable: true, configurable: true, writable: true },
157  });
158}
159
160function bounds(min: number | undefined, max: number | undefined, nonnegative = true): void {
161  for (const n of [min, max]) {
162    if (n !== undefined && (!Number.isSafeInteger(n) || (nonnegative && n < 0))) {
163      throw new Error("Bounds must be safe integers" + (nonnegative ? " >= 0" : ""));
164    }
165  }
166  if (min !== undefined && max !== undefined && min > max) {
167    throw new Error("Minimum must not exceed maximum");
168  }
169}
170
171export type StringSchema<T extends string = string> = Schema<T> & { readonly type: "string" };
172export type IntegerSchema = Schema<number> & { readonly type: "integer" };
173
174export function stringWith(options: StringOptions = {}): StringSchema {
175  bounds(options.minLength, options.maxLength);
176  bounds(options.minGraphemes, options.maxGraphemes);
177  return schema({ ...options, type: "string" });
178}
179
180export function integerWith(options: IntegerOptions = {}): IntegerSchema {
181  bounds(options.minimum, options.maximum, false);
182  return schema({ ...options, type: "integer" });
183}
184
185// Exports carry explicit types: JSR's fast check can't follow `typeof` between them.
186export const string: StringSchema = stringWith();
187export const integer: IntegerSchema = integerWith();
188export const boolean: Schema<boolean> = schema({ type: "boolean" });
189export const cidLink: Schema<CidLink> = schema({ type: "cid-link" });
190export const did: StringSchema<`did:${string}`> = schema({ type: "string", format: "did" });
191export const handle: StringSchema = stringWith({ format: "handle" });
192export const atIdentifier: StringSchema = stringWith({ format: "at-identifier" });
193export const atUri: StringSchema<`at://${string}`> = schema({ type: "string", format: "at-uri" });
194export const datetime: StringSchema = stringWith({ format: "datetime" });
195export const uri: StringSchema = stringWith({ format: "uri" });
196export const language: StringSchema = stringWith({ format: "language" });
197export const cid: StringSchema = stringWith({ format: "cid" });
198export const nsid: StringSchema = stringWith({ format: "nsid" });
199export const tid: StringSchema = stringWith({ format: "tid", minLength: 13, maxLength: 13 });
200
201export function bytesWith(options: LengthOptions = {}): Schema<BytesValue> {
202  bounds(options.minLength, options.maxLength);
203  return schema({ ...options, type: "bytes" });
204}
205export const bytes: Schema<BytesValue> = bytesWith();
206
207export function literal<const T extends string | number | boolean>(value: T): Schema<T> {
208  if (typeof value === "string") return schema({ type: "string", const: value });
209  if (typeof value === "boolean") return schema({ type: "boolean", const: value });
210  if (!Number.isSafeInteger(value)) throw new Error("Numeric literals must be safe integers");
211  return schema({ type: "integer", const: value });
212}
213
214export function enumValues<const T extends readonly [string, ...string[]]>(
215  ...values: T
216): Schema<T[number]> {
217  return schema({ type: "string", enum: [...values] });
218}
219
220export function blob(options: BlobOptions = {}): Schema<BlobValue> {
221  bounds(undefined, options.maxSize);
222  if (
223    options.accept?.some((mime) => !/^(?:\*\/\*|[\w!#$&^.+-]+\/(?:[\w!#$&^.+-]+|\*))$/.test(mime))
224  ) {
225    throw new Error("Invalid blob MIME pattern");
226  }
227  return schema({
228    ...options,
229    ...(options.accept && { accept: [...options.accept] }),
230    type: "blob",
231  });
232}
233
234export function array<S extends Schema>(
235  items: S,
236  options: LengthOptions = {},
237): Schema<readonly Infer<S>[], readonly Input<S>[]> {
238  bounds(options.minLength, options.maxLength);
239  return schema({ ...options, type: "array", items });
240}
241
242export function optional<S extends Schema | NullableField>(
243  inner: S,
244): OptionalField<Infer<S>, Input<S>> {
245  return { type: "optional", inner };
246}
247
248export function nullable<S extends Schema>(inner: S): NullableField<Infer<S>, Input<S>> {
249  return { type: "nullable", inner };
250}
251
252export function object<const S extends Shape>(
253  properties: S,
254  options: { readonly description?: string } = {},
255): ObjectSchema<S> {
256  if (Object.keys(properties).some((key) => key.startsWith("$"))) {
257    throw new Error("$-prefixed fields are reserved; records and unions supply $type");
258  }
259  return schema({ ...options, type: "object", properties: { ...properties } });
260}
261
262export function definitionId(id: string): { nsid: string; name: string } {
263  const [nsid, name = "main", extra] = id.split("#");
264  if (!nsid || !isNsid(nsid) || extra !== undefined || !/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
265    throw new Error(`Invalid definition ID: ${id}`);
266  }
267  if (id.endsWith("#main")) throw new Error("Use the bare NSID instead of #main");
268  return { nsid, name };
269}
270
271export type NamedSchema<Id extends string = string, T = unknown, I = T> =
272  & Validated<T, I>
273  & {
274    readonly type: "ref";
275    readonly id: Id;
276    readonly target: Schema<T, I>;
277    readonly description?: string;
278  };
279
280export function named<S extends Schema, const Id extends string = string>(
281  id: Id,
282  target: S | (() => S),
283): NamedSchema<Id, Infer<S>, Input<S>> & { readonly target: S } {
284  definitionId(id);
285  let resolved: S | undefined;
286  let resolving = false;
287  const ref = schema({
288    type: "ref",
289    id,
290    get target(): S {
291      if (resolved) return resolved;
292      if (resolving) throw new Error(`Circular definition factory: ${id}`);
293      resolving = true;
294      try {
295        const value = typeof target === "function" ? target() : target;
296        if (value.type === "ref" || value.type === "union" || value.type === "record") {
297          throw new Error("Only concrete definitions can be named; records already have a name");
298        }
299        return resolved = value;
300      } finally {
301        resolving = false;
302      }
303    },
304  });
305  if (typeof target !== "function") void ref.target;
306  return ref as NamedSchema<Id, Infer<S>, Input<S>> & { readonly target: S };
307}
308
309type Variants = Readonly<Record<string, Schema>>;
310type ObjectVariants = Readonly<Record<string, ObjectSchema>>;
311type NamedVariants<V extends readonly NamedSchema[]> = {
312  readonly [S in V[number] as S["id"]]: S["target"];
313};
314type UnionValue<
315  V extends Variants,
316  C extends Channel = typeof output,
317  Base extends string = never,
318> = {
319  [K in keyof V & string]: Simplify<
320    & { readonly $type: K | (K extends `${Base}#${infer F}` ? `#${F}` : never) }
321    & Read<V[K], C>
322  >;
323}[keyof V & string];
324type UnionOptions<Closed extends boolean> = {
325  readonly closed?: Closed;
326  readonly description?: string;
327};
328export type UnknownVariant = { readonly $type: string; readonly [key: string]: unknown };
329type Open<Closed extends boolean> = Closed extends true ? never : UnknownVariant;
330export type UnionSchema<
331  V extends Variants,
332  Closed extends boolean = true,
333  Base extends string = never,
334> =
335  & Validated<UnionValue<V> | Open<Closed>, UnionValue<V, typeof input, Base> | Open<Closed>>
336  & {
337    readonly type: "union";
338    readonly variants: V;
339    readonly closed: Closed;
340    readonly description?: string;
341    build(value: UnionValue<V, typeof input, Base> | Open<Closed>): UnionValue<V> | Open<Closed>;
342  };
343
344// Variants of a namespaced union are declared as "#name" and stored under their full ID.
345function namespacedVariants(namespace: string, variants: ObjectVariants): Variants {
346  if (!isNsid(namespace)) throw new Error(`Invalid union namespace: ${namespace}`);
347  return Object.fromEntries(
348    Object.entries(variants).map(([key, variant]) => {
349      if (!key.startsWith("#")) throw new Error("Union variant names must start with #");
350      return [namespace + key, variant];
351    }),
352  );
353}
354
355// Resolving targets here would re-enter a definition that includes itself in a union, so
356// each variant resolves on first access.
357function namedVariants(refs: readonly NamedSchema[]): Variants {
358  const variants: Record<string, Schema> = {};
359  for (const ref of refs) {
360    if (ref.type !== "ref") throw new Error("Union arrays must contain named definitions");
361    if (Object.hasOwn(variants, ref.id)) throw new Error("Duplicate union variant");
362    Object.defineProperty(variants, ref.id, {
363      enumerable: true,
364      get() {
365        const target = ref.target;
366        if (target.type !== "object") throw new Error(`Union variant ${ref.id} must be an object`);
367        return target;
368      },
369    });
370  }
371  return variants;
372}
373
374const isNamedList = (value: unknown): value is readonly NamedSchema[] => Array.isArray(value);
375
376function unionOf(variants: Variants, options: UnionOptions<boolean>, namespace?: string): Schema {
377  const keys = Object.keys(variants);
378  if (keys.length === 0 && options.closed !== false) {
379    throw new Error("A closed union cannot be empty");
380  }
381  for (const key of keys) definitionId(key);
382  return Object.setPrototypeOf({
383    ...options,
384    type: "union",
385    variants,
386    closed: options.closed ?? true,
387    ...(namespace !== undefined && { namespace }),
388  }, unionPrototype);
389}
390
391export function union<
392  const V extends readonly NamedSchema[],
393  const Closed extends boolean = true,
394>(
395  variants: V,
396  options?: UnionOptions<Closed>,
397): UnionSchema<NamedVariants<V>, Closed>;
398export function union<const V extends ObjectVariants, const Closed extends boolean = true>(
399  variants: V,
400  options?: UnionOptions<Closed>,
401): UnionSchema<V, Closed>;
402export function union<
403  const Id extends string,
404  const V extends Readonly<Record<`#${string}`, ObjectSchema>>,
405  const Closed extends boolean = true,
406>(
407  id: Id,
408  variants: V,
409  options?: UnionOptions<Closed>,
410): UnionSchema<{ readonly [K in keyof V & `#${string}` as `${Id}${K}`]: V[K] }, Closed, Id>;
411export function union(
412  idOrVariants: string | ObjectVariants | readonly NamedSchema[],
413  variantsOrOptions: ObjectVariants | UnionOptions<boolean> = {},
414  options: UnionOptions<boolean> = {},
415): Schema {
416  if (typeof idOrVariants === "string") {
417    const variants = namespacedVariants(idOrVariants, variantsOrOptions as ObjectVariants);
418    return unionOf(variants, options, idOrVariants);
419  }
420  const variants = isNamedList(idOrVariants) ? namedVariants(idOrVariants) : { ...idOrVariants };
421  return unionOf(variants, variantsOrOptions as UnionOptions<boolean>);
422}
423
424export type RecordSchema<Id extends string, S extends Shape> =
425  & Schema<
426    Simplify<{ readonly $type: Id } & ObjectValue<S>>,
427    Simplify<{ readonly $type?: Id } & ObjectValue<S, typeof input>>
428  >
429  & {
430    readonly type: "record";
431    readonly id: Id;
432    readonly record: ObjectSchema<S>;
433    build(fields: ObjectValue<S, typeof input> & { readonly $type?: never }): Simplify<
434      { readonly $type: Id } & ObjectValue<S>
435    >;
436  };
437
438export function record<const Id extends string, const S extends Shape>(
439  id: Id,
440  options: { readonly key: RecordKey; readonly description?: string },
441  properties: S,
442): RecordSchema<Id, S> {
443  if (!isNsid(id)) throw new Error(`Invalid record NSID: ${id}`);
444  if (
445    options.key !== "tid" && options.key !== "any" &&
446    !/^literal:[A-Za-z0-9_~.:-]{1,512}$/.test(options.key)
447  ) {
448    throw new Error("Invalid record key policy");
449  }
450  if (options.key === "literal:." || options.key === "literal:..") {
451    throw new Error("Invalid literal record key");
452  }
453  return Object.setPrototypeOf(
454    { ...options, type: "record", id, record: object(properties) },
455    recordPrototype,
456  );
457}