1import * as Schema from "effect/Schema";
2import * as SchemaGetter from "effect/SchemaGetter";
3
4/**
5 * Opt-in wire form: on each record (top-level array element, top-level object, or `item`), values
6 * under `id`, `*Id`, and `*Ids` index the response's `ids` table. Both peers must apply this exact
7 * rule; changing it needs a new opt-in (`compactIds=2`, or a new `accept*` key over WS).
8 */
9type JsonObject = { [key: string]: unknown };
10
11const isObject = (value: unknown): value is JsonObject =>
12 typeof value === "object" && value !== null && !Array.isArray(value);
13const isIdKey = (key: string) => key === "id" || key.endsWith("Id");
14const isIdListKey = (key: string) => key.endsWith("Ids");
15
16function internRecordIds(container: JsonObject, ids: Array<string | number>): JsonObject {
17 const index = new Map<string | number, number>();
18 const ref = (value: unknown) => {
19 if (typeof value !== "string" && typeof value !== "number") return value;
20 let position = index.get(value);
21 if (position === undefined) {
22 position = ids.length;
23 ids.push(value);
24 index.set(value, position);
25 }
26 return position;
27 };
28 // Copies each record: unknown members can pass through encoding by reference.
29 const internRecord = (record: JsonObject): JsonObject => {
30 const out: JsonObject = {};
31 for (const key in record) {
32 const value = record[key];
33 out[key] = isIdKey(key)
34 ? ref(value)
35 : isIdListKey(key) && Array.isArray(value)
36 ? value.map(ref)
37 : key === "item" && isObject(value)
38 ? internRecord(value)
39 : value;
40 }
41 return out;
42 };
43 const out: JsonObject = {};
44 for (const key in container) {
45 const value = container[key];
46 out[key] = Array.isArray(value)
47 ? value.map((record) => (isObject(record) ? internRecord(record) : record))
48 : isObject(value)
49 ? internRecord(value)
50 : value;
51 }
52 return out;
53}
54
55// Mutates freshly parsed JSON in place; the client owns it and this avoids a copy.
56function expandRecordIds(container: JsonObject, ids: ReadonlyArray<unknown>): void {
57 const expandRecord = (record: JsonObject) => {
58 for (const key in record) {
59 const value = record[key];
60 if (typeof value === "number") {
61 if (isIdKey(key)) record[key] = ids[value];
62 } else if (Array.isArray(value)) {
63 if (isIdListKey(key)) {
64 for (let i = 0; i < value.length; i++) {
65 const entry = value[i];
66 if (typeof entry === "number") value[i] = ids[entry];
67 }
68 }
69 } else if (key === "item" && isObject(value)) {
70 expandRecord(value);
71 }
72 }
73 };
74 for (const key in container) {
75 const value = container[key];
76 if (Array.isArray(value)) {
77 for (const record of value) if (isObject(record)) expandRecord(record);
78 } else if (isObject(value)) {
79 expandRecord(value);
80 }
81 }
82}
83
84// `toCodecJson` turns `Schema.Unknown` into a recursive JSON check of the whole
85// tree. The input is parsed JSON and the inner codec validates it anyway.
86const ParsedJson = Schema.declare((_: unknown): _ is unknown => true, {
87 toCodecJson: () => undefined,
88});
89
90/**
91 * Adds the optional ID table to a snapshot-shaped schema. `container` returns the
92 * key whose value holds the records, `null` when records sit at the top level, or
93 * `undefined` when the value has no records (other stream item kinds).
94 */
95export const withRecordIdTable = <S extends Schema.Top>(
96 schema: S,
97 container: (value: JsonObject) => string | null | undefined,
98) => {
99 const codec = Schema.toCodecJson(schema);
100 type Encoded = typeof codec.Encoded;
101 return ParsedJson.pipe(
102 Schema.decodeTo(codec, {
103 decode: SchemaGetter.transform((raw: unknown): Encoded => {
104 if (!isObject(raw) || !Array.isArray(raw.ids)) return raw as Encoded;
105 // Consumed on the parsed object itself, so decoding it again is a no-op.
106 const ids: ReadonlyArray<unknown> = raw.ids;
107 delete raw.ids;
108 const key = container(raw);
109 const target = key === null ? raw : key === undefined ? undefined : raw[key];
110 if (isObject(target)) expandRecordIds(target, ids);
111 return raw as Encoded;
112 }),
113 encode: SchemaGetter.transform((encoded: Encoded): unknown => {
114 if (!isObject(encoded) || encoded.idTable !== true) return encoded;
115 const { idTable: _idTable, ...rest } = encoded;
116 const key = container(rest);
117 const ids: Array<string | number> = [];
118 if (key === null) return { ...internRecordIds(rest, ids), ids };
119 const target = key === undefined ? undefined : rest[key];
120 if (key === undefined || !isObject(target)) return rest;
121 return { ...rest, [key]: internRecordIds(target, ids), ids };
122 }),
123 }),
124 );
125};