import type { ParamsSchema, Rpc } from "./rpc.ts"; import { definitionId, type Schema } from "./schema.ts"; import { unwrap } from "./util/fields.ts"; export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json; }; export type LexiconDefinition = { readonly type: string; readonly [key: string]: Json }; export type LexiconDoc = { readonly lexicon: 1; readonly id: string; readonly defs: Readonly>; }; type Context = "definition" | "property" | "item" | "body"; type Emit = (schema: Schema | ParamsSchema | Rpc, context: Context) => LexiconDefinition; function byName([a]: readonly [string, unknown], [b]: readonly [string, unknown]): number { return a < b ? -1 : a > b ? 1 : 0; } // Canonical field order makes collision checks and generated files stable. function canonical(definition: LexiconDefinition): LexiconDefinition { return Object.fromEntries(Object.entries(definition).sort(byName)) as LexiconDefinition; } function schemaFields(schema: S): Omit { const { validate: _validate, ...fields } = schema; return fields; } const body = (schema: Schema, emit: Emit) => ({ encoding: "application/json", schema: emit(schema, "body"), }); function definition( schema: Schema | ParamsSchema | Rpc, context: Context, emit: Emit, register: (id: string, schema: Schema | Rpc) => void, ): LexiconDefinition { switch (schema.type) { case "query": case "procedure": if (context !== "definition") throw new Error("RPCs cannot be used as field schemas"); return { type: schema.type, ...(schema.parameters && { parameters: emit(schema.parameters, "definition") }), ...(schema.output && { output: body(schema.output, emit) }), ...(schema.type === "procedure" && schema.input && { input: body(schema.input, emit) }), ...(schema.errors && { errors: schema.errors.map((error) => ({ ...error })) }), }; case "record": if (context === "definition") { return { type: "record", key: schema.key, record: emit(schema.record, "definition") }; } register(schema.id, schema); return { type: "ref", ref: schema.id }; case "ref": register(schema.id, schema.target); return { type: "ref", ref: schema.id }; case "union": { const refs = Object.entries(schema.variants).sort(byName).map(([id, variant]) => { register(id, variant); return id; }); return { type: "union", refs, closed: schema.closed }; } case "params": case "object": { if (context !== "definition" && context !== "body") { throw new Error("Nested objects must be named with named(id, object(...))"); } const properties: Record = Object.create(null); const required: string[] = [], nullable: string[] = []; for (const [key, field] of Object.entries(schema.properties).sort(byName)) { const inner = unwrap(field); if (!inner.optional) required.push(key); if (inner.nullable) nullable.push(key); properties[key] = emit(inner.schema, "property"); } return { type: schema.type, properties, ...(required.length > 0 && { required }), ...(nullable.length > 0 && { nullable }), }; } case "array": if (context === "item") { throw new Error("Nested arrays must be named with named(id, array(...))"); } return { ...schemaFields(schema), items: emit(schema.items, "item") }; default: return schemaFields(schema); } } export function toLexicons(...roots: readonly (Schema | Rpc)[]): readonly LexiconDoc[] { const registered = new Map>(); const pending: [string, Schema | Rpc][] = []; function register(id: string, schema: Schema | Rpc): void { const schemas = registered.get(id) ?? new Set(); registered.set(id, schemas); if (schemas.has(schema)) return; schemas.add(schema); pending.push([id, schema]); } const emit: Emit = (schema, context) => { const result = definition(schema, context, emit, register); const { description } = schema; return canonical(description === undefined ? result : { ...result, description }); }; for (const root of roots) { if (root.type === "record" || root.type === "query" || root.type === "procedure") { register(root.id, root); } else if (root.type === "ref" || root.type === "union") emit(root, "property"); else throw new Error("Lexicon roots must be records, named definitions, named unions, or RPCs"); } const docs = new Map>(); for (const [id, schema] of pending) { const { nsid, name } = definitionId(id); const def = emit(schema, "definition"); const defs = docs.get(nsid) ?? new Map(); docs.set(nsid, defs); const existing = defs.get(name); if (existing && JSON.stringify(existing) !== JSON.stringify(def)) { throw new Error(`Conflicting definition: ${id}`); } defs.set(name, def); } return [...docs].sort(byName).map(([id, defs]) => ({ lexicon: 1, id, defs: Object.fromEntries([...defs].sort(byName)), })); }