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
5.0 KiB137 linesraw
1import type { Schema } from "../schema.ts";
2import { isObject } from "./data.ts";
3import { unwrap } from "./fields.ts";
4
5type Path = readonly (string | number)[];
6type Expand = (value: unknown, path: Path) => unknown;
7
8const expanders = new WeakMap<Schema, Expand | undefined>();
9
10function fail(path: Path, message: string): never {
11  throw new Error(`${message} at ${[...path, "$type"].join(".")}`);
12}
13
14function children(schema: Schema): readonly Schema[] {
15  switch (schema.type) {
16    case "array":
17      return [schema.items];
18    case "object":
19      return Object.values(schema.properties).map((field) => unwrap(field).schema);
20    case "ref":
21      return [schema.target];
22    case "record":
23      return [schema.record];
24    case "union":
25      return Object.values(schema.variants);
26    default:
27      return [];
28  }
29}
30
31// Every schema reachable from the root, and those that rewrite values or contain one that
32// does. Anything else is shared with the input rather than copied.
33function survey(root: Schema): { visited: Set<Schema>; required: Set<Schema> } {
34  const parents = new Map<Schema, Set<Schema>>([[root, new Set()]]);
35  const required = new Set<Schema>();
36  for (const schema of parents.keys()) {
37    if (expanders.has(schema)) {
38      if (expanders.get(schema)) required.add(schema);
39      continue;
40    }
41    if (schema.type === "record" || schema.type === "union") required.add(schema);
42    for (const child of children(schema)) {
43      const known = parents.get(child);
44      if (known) known.add(schema);
45      else parents.set(child, new Set([schema]));
46    }
47  }
48  // Propagate through the whole cycle before deciding which branches can be shared.
49  for (const schema of required) {
50    for (const parent of parents.get(schema) ?? []) required.add(parent);
51  }
52  return { visited: new Set(parents.keys()), required };
53}
54
55function expanderFor(schema: Schema, build: (schema: Schema) => Expand | undefined): Expand {
56  const passthrough: Expand = (value) => value;
57  switch (schema.type) {
58    case "array": {
59      const item = build(schema.items) ?? passthrough;
60      return (value, path) =>
61        Array.isArray(value) ? value.map((element, i) => item(element, [...path, i])) : value;
62    }
63    case "object": {
64      const fields = new Map<string, Expand>();
65      for (const [key, field] of Object.entries(schema.properties)) {
66        const expand = build(unwrap(field).schema);
67        if (expand) fields.set(key, expand);
68      }
69      return (value, path) => {
70        if (!isObject(value)) return value;
71        return Object.fromEntries(
72          Object.entries(value).map(([key, item]) => {
73            const field = fields.get(key);
74            return [key, field ? field(item, [...path, key]) : item];
75          }),
76        );
77      };
78    }
79    case "ref":
80      return build(schema.target) ?? passthrough;
81    case "record": {
82      const body = build(schema.record) ?? passthrough;
83      return (value, path) => {
84        if (!isObject(value)) return value;
85        if (Object.hasOwn(value, "$type") && value.$type !== schema.id) {
86          fail(path, `Expected ${schema.id}`);
87        }
88        return { ...(body(value, path) as object), $type: schema.id };
89      };
90    }
91    case "union": {
92      const { namespace } = schema;
93      const variants = new Map(
94        Object.entries(schema.variants).map(([id, variant]) => [id, build(variant)]),
95      );
96      return (value, path) => {
97        if (!isObject(value) || typeof value.$type !== "string") return value;
98        let $type = value.$type;
99        if ($type.startsWith("#")) {
100          if (namespace === undefined) fail(path, "Only namespaced unions accept short variants");
101          $type = namespace + $type;
102          if (!variants.has($type)) fail(path, `Unknown union variant ${value.$type}`);
103        }
104        const resolved = $type === value.$type ? value : { ...value, $type };
105        const variant = variants.get($type);
106        return variant ? variant(resolved, path) : resolved;
107      };
108    }
109    default:
110      return passthrough;
111  }
112}
113
114// A value with its $type discriminators filled in and expanded, as far as the schema needs.
115export function expand(schema: Schema, value: unknown): unknown {
116  return expander(schema)?.(value, []) ?? value;
117}
118
119export function expander(root: Schema): Expand | undefined {
120  if (expanders.has(root)) return expanders.get(root);
121  const { visited, required } = survey(root);
122  const compiled = new Map<Schema, Expand | undefined>();
123  // Recursive schemas get a forwarder before their own expander exists.
124  function build(schema: Schema): Expand | undefined {
125    if (expanders.has(schema)) return expanders.get(schema);
126    if (compiled.has(schema)) return compiled.get(schema);
127    if (!required.has(schema)) return undefined;
128    let expand: Expand;
129    compiled.set(schema, (value, path) => expand(value, path));
130    return expand = expanderFor(schema, build);
131  }
132  const result = build(root);
133  for (const schema of visited) {
134    if (!expanders.has(schema)) expanders.set(schema, compiled.get(schema));
135  }
136  return result;
137}