cerulea/lexicon

define atproto schemas in TypeScript

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

Charlotte Somlexicon: emit definitions from a switch that returnsaceb06a

main
5.2 KiB139 linesraw
1import type { ParamsSchema, Rpc } from "./rpc.ts";
2import { definitionId, type Schema } from "./schema.ts";
3import { unwrap } from "./util/fields.ts";
4
5export type Json = null | boolean | number | string | readonly Json[] | {
6  readonly [key: string]: Json;
7};
8export type LexiconDefinition = { readonly type: string; readonly [key: string]: Json };
9export type LexiconDoc = {
10  readonly lexicon: 1;
11  readonly id: string;
12  readonly defs: Readonly<Record<string, LexiconDefinition>>;
13};
14
15type Context = "definition" | "property" | "item" | "body";
16type Emit = (schema: Schema | ParamsSchema | Rpc, context: Context) => LexiconDefinition;
17
18function byName([a]: readonly [string, unknown], [b]: readonly [string, unknown]): number {
19  return a < b ? -1 : a > b ? 1 : 0;
20}
21
22// Canonical field order makes collision checks and generated files stable.
23function canonical(definition: LexiconDefinition): LexiconDefinition {
24  return Object.fromEntries(Object.entries(definition).sort(byName)) as LexiconDefinition;
25}
26
27function schemaFields<S extends Schema>(schema: S): Omit<S, "validate"> {
28  const { validate: _validate, ...fields } = schema;
29  return fields;
30}
31
32const body = (schema: Schema, emit: Emit) => ({
33  encoding: "application/json",
34  schema: emit(schema, "body"),
35});
36
37function definition(
38  schema: Schema | ParamsSchema | Rpc,
39  context: Context,
40  emit: Emit,
41  register: (id: string, schema: Schema | Rpc) => void,
42): LexiconDefinition {
43  switch (schema.type) {
44    case "query":
45    case "procedure":
46      if (context !== "definition") throw new Error("RPCs cannot be used as field schemas");
47      return {
48        type: schema.type,
49        ...(schema.parameters && { parameters: emit(schema.parameters, "definition") }),
50        ...(schema.output && { output: body(schema.output, emit) }),
51        ...(schema.type === "procedure" && schema.input && { input: body(schema.input, emit) }),
52        ...(schema.errors && { errors: schema.errors.map((error) => ({ ...error })) }),
53      };
54    case "record":
55      if (context === "definition") {
56        return { type: "record", key: schema.key, record: emit(schema.record, "definition") };
57      }
58      register(schema.id, schema);
59      return { type: "ref", ref: schema.id };
60    case "ref":
61      register(schema.id, schema.target);
62      return { type: "ref", ref: schema.id };
63    case "union": {
64      const refs = Object.entries(schema.variants).sort(byName).map(([id, variant]) => {
65        register(id, variant);
66        return id;
67      });
68      return { type: "union", refs, closed: schema.closed };
69    }
70    case "params":
71    case "object": {
72      if (context !== "definition" && context !== "body") {
73        throw new Error("Nested objects must be named with named(id, object(...))");
74      }
75      const properties: Record<string, Json> = Object.create(null);
76      const required: string[] = [], nullable: string[] = [];
77      for (const [key, field] of Object.entries(schema.properties).sort(byName)) {
78        const inner = unwrap(field);
79        if (!inner.optional) required.push(key);
80        if (inner.nullable) nullable.push(key);
81        properties[key] = emit(inner.schema, "property");
82      }
83      return {
84        type: schema.type,
85        properties,
86        ...(required.length > 0 && { required }),
87        ...(nullable.length > 0 && { nullable }),
88      };
89    }
90    case "array":
91      if (context === "item") {
92        throw new Error("Nested arrays must be named with named(id, array(...))");
93      }
94      return { ...schemaFields(schema), items: emit(schema.items, "item") };
95    default:
96      return schemaFields(schema);
97  }
98}
99
100export function toLexicons(...roots: readonly (Schema | Rpc)[]): readonly LexiconDoc[] {
101  const registered = new Map<string, Set<Schema | Rpc>>();
102  const pending: [string, Schema | Rpc][] = [];
103  function register(id: string, schema: Schema | Rpc): void {
104    const schemas = registered.get(id) ?? new Set();
105    registered.set(id, schemas);
106    if (schemas.has(schema)) return;
107    schemas.add(schema);
108    pending.push([id, schema]);
109  }
110  const emit: Emit = (schema, context) => {
111    const result = definition(schema, context, emit, register);
112    const { description } = schema;
113    return canonical(description === undefined ? result : { ...result, description });
114  };
115
116  for (const root of roots) {
117    if (root.type === "record" || root.type === "query" || root.type === "procedure") {
118      register(root.id, root);
119    } else if (root.type === "ref" || root.type === "union") emit(root, "property");
120    else throw new Error("Lexicon roots must be records, named definitions, named unions, or RPCs");
121  }
122  const docs = new Map<string, Map<string, LexiconDefinition>>();
123  for (const [id, schema] of pending) {
124    const { nsid, name } = definitionId(id);
125    const def = emit(schema, "definition");
126    const defs = docs.get(nsid) ?? new Map<string, LexiconDefinition>();
127    docs.set(nsid, defs);
128    const existing = defs.get(name);
129    if (existing && JSON.stringify(existing) !== JSON.stringify(def)) {
130      throw new Error(`Conflicting definition: ${id}`);
131    }
132    defs.set(name, def);
133  }
134  return [...docs].sort(byName).map(([id, defs]) => ({
135    lexicon: 1,
136    id,
137    defs: Object.fromEntries([...defs].sort(byName)),
138  }));
139}