@vertekum/schema-builder 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @vertekum/schema-builder
2
2
 
3
+ ## 0.3.10
4
+
5
+ ### Patch Changes
6
+
7
+ - [`351366b`](https://github.com/moros-oxus/vertekum/commit/351366b8a92c60e9186cb0c74d774000c0d5366c) Thanks [@tschemmer](https://github.com/tschemmer)! - Emitted schemas share repeated subtrees: identical tails (optional-slot syntagms expand them once per branch) hoist into `$defs` as content-named `shared-*` entries. A consumer module that emitted 12 MB with 22k ref-sites — enough to overflow ajv's call stack at validation — now emits ~30 KB with identical validation behaviour.
8
+ - Updated dependencies []:
9
+ - @vertekum/core@0.3.10
10
+
11
+ ## 0.3.9
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [[`a35485a`](https://github.com/moros-oxus/vertekum/commit/a35485a227a9e94271b041bc020f8edf28e0137b)]:
16
+ - @vertekum/core@0.3.9
17
+
3
18
  ## 0.3.8
4
19
 
5
20
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertekum/schema-builder",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Declare token vocabularies as grammar; build them into JSON Schema files",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -37,13 +37,13 @@
37
37
  "./cli": "./src/cli.ts"
38
38
  },
39
39
  "peerDependencies": {
40
- "@vertekum/core": "0.3.8"
40
+ "@vertekum/core": "0.3.10"
41
41
  },
42
42
  "dependencies": {
43
43
  "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "@vertekum/core": "0.3.8",
46
+ "@vertekum/core": "0.3.10",
47
47
  "@vertekum/schema-vocabulary": "0.3.0"
48
48
  }
49
49
  }
package/src/dedupe.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Structural sharing for emitted schemas.
3
+ *
4
+ * A generative syntagm with optional slots (`color.<pattern>?.<property>.<role>?.<state>?`)
5
+ * repeats the TAIL of the expression under every enumerated sibling name — byte-identical
6
+ * subtrees, hundreds of kilobytes each, multiplied into megabytes (a consumer module reached
7
+ * 12 MB / 22k ref-sites, and ajv's compiled validator overflowed the call stack). This pass
8
+ * hoists every repeated schema subtree into `$defs` once and references it — validation
9
+ * behaviour is unchanged because 2020-12 `unevaluatedProperties` sees through `$ref`.
10
+ *
11
+ * Only nodes in SCHEMA POSITION are candidates: hoisting the object under `properties` itself
12
+ * would turn a keyword map into `{ "$ref": … }`, which is a property named `$ref`, not a
13
+ * reference. Names are content-derived (`shared-<hash>`), so rebuilds stay diff-stable.
14
+ */
15
+
16
+ type Json = Record<string, unknown>;
17
+
18
+ /** Keywords whose value is a map of name → schema. */
19
+ const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs']);
20
+ /** Keywords whose value is an array of schemas. */
21
+ const SCHEMA_LISTS = new Set(['allOf', 'anyOf', 'oneOf']);
22
+ /** Keywords whose value is a single schema (when it is an object). */
23
+ const SCHEMA_VALUES = new Set([
24
+ 'additionalProperties',
25
+ 'unevaluatedProperties',
26
+ 'items',
27
+ 'not',
28
+ 'if',
29
+ 'then',
30
+ 'else',
31
+ ]);
32
+
33
+ /**
34
+ * Only subtrees at least this large hoist. Small repetition is deliberate emission style (a
35
+ * shared `allOf` pair reads better inline, and tests pin those shapes); the pathology this pass
36
+ * exists for repeats subtrees of hundreds of kilobytes.
37
+ */
38
+ const MIN_BYTES = 512;
39
+
40
+ /** FNV-1a over a string, as fixed-width hex — composition-friendly and dependency-free. */
41
+ function fnv(input: string): string {
42
+ let hash = 0xcbf29ce484222325n;
43
+ const prime = 0x100000001b3n;
44
+ for (let i = 0; i < input.length; i++) {
45
+ hash ^= BigInt(input.charCodeAt(i));
46
+ hash = (hash * prime) & 0xffffffffffffffffn;
47
+ }
48
+ return hash.toString(16).padStart(16, '0');
49
+ }
50
+
51
+ /** Visit every schema-position object under `schema` (excluding `schema` itself). */
52
+ function visitChildren(
53
+ schema: Json,
54
+ visit: (child: Json, replace: (next: Json) => void) => void,
55
+ ): void {
56
+ for (const [key, value] of Object.entries(schema)) {
57
+ if (SCHEMA_MAPS.has(key) && value && typeof value === 'object') {
58
+ const map = value as Json;
59
+ for (const [name, child] of Object.entries(map)) {
60
+ if (child && typeof child === 'object' && !Array.isArray(child)) {
61
+ visit(child as Json, (next) => {
62
+ map[name] = next;
63
+ });
64
+ }
65
+ }
66
+ } else if (SCHEMA_LISTS.has(key) && Array.isArray(value)) {
67
+ value.forEach((child, index) => {
68
+ if (child && typeof child === 'object' && !Array.isArray(child)) {
69
+ visit(child as Json, (next) => {
70
+ (value as unknown[])[index] = next;
71
+ });
72
+ }
73
+ });
74
+ } else if (
75
+ SCHEMA_VALUES.has(key) &&
76
+ value &&
77
+ typeof value === 'object' &&
78
+ !Array.isArray(value)
79
+ ) {
80
+ visit(value as Json, (next) => {
81
+ schema[key] = next;
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Hoist repeated subtrees of `document` into its `$defs`, in place. Iterates to a fixpoint:
89
+ * hoisting inner duplicates makes outer subtrees identical, which the next round catches.
90
+ */
91
+ export function dedupeSubtrees(document: Json): void {
92
+ const defs = (): Json => {
93
+ if (!document.$defs || typeof document.$defs !== 'object') {
94
+ document.$defs = {};
95
+ }
96
+ return document.$defs as Json;
97
+ };
98
+ const reserved = new Set(Object.keys((document.$defs as Json) ?? {}));
99
+
100
+ for (let round = 0; round < 32; round++) {
101
+ // Pass 1: post-order signatures (composed from child hashes — compact and linear).
102
+ const signature = new Map<Json, string>();
103
+ const size = new Map<Json, number>();
104
+ const buckets = new Map<string, Json[]>();
105
+ const sign = (node: Json): string => {
106
+ const held = signature.get(node);
107
+ if (held) return held;
108
+ visitChildren(node, (child) => {
109
+ sign(child);
110
+ });
111
+ const parts: string[] = [];
112
+ let bytes = 2;
113
+ for (const key of Object.keys(node).sort()) {
114
+ const value = node[key];
115
+ parts.push(`${key}:${serialize(value)}`);
116
+ bytes += key.length + byteLength(value);
117
+ }
118
+ const hash = fnv(parts.join('|'));
119
+ signature.set(node, hash);
120
+ size.set(node, bytes);
121
+ const bucket = buckets.get(hash) ?? [];
122
+ bucket.push(node);
123
+ buckets.set(hash, bucket);
124
+ return hash;
125
+ };
126
+ const serialize = (value: unknown): string => {
127
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
128
+ return `#${sign(value as Json)}`;
129
+ }
130
+ return JSON.stringify(value) ?? 'null';
131
+ };
132
+ const byteLength = (value: unknown): number => {
133
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
134
+ return size.get(value as Json) ?? 16;
135
+ }
136
+ return (JSON.stringify(value) ?? 'null').length;
137
+ };
138
+
139
+ // The whole forest: the document body plus every existing def.
140
+ const roots: Json[] = [document];
141
+ signature.set(document, 'root'); // the document itself is never a candidate
142
+ visitChildren(document, (child) => {
143
+ sign(child);
144
+ });
145
+
146
+ // Pass 2: pick duplicated, big-enough subtrees; verify true equality (hash paranoia).
147
+ const hoisted = new Map<string, { name: string; canonical: string }>();
148
+ for (const [hash, bucket] of buckets) {
149
+ if (bucket.length < 2) continue;
150
+ const first = bucket[0] as Json;
151
+ if ((size.get(first) ?? 0) < MIN_BYTES) continue;
152
+ if (Object.keys(first).length === 1 && '$ref' in first) continue;
153
+ const canonical = JSON.stringify(first);
154
+ if (bucket.some((node) => JSON.stringify(node) !== canonical)) continue;
155
+ let name = `shared-${hash.slice(0, 8)}`;
156
+ while (reserved.has(name)) name = `shared-${fnv(name).slice(0, 8)}`;
157
+ hoisted.set(hash, { name, canonical });
158
+ }
159
+ if (hoisted.size === 0) return;
160
+
161
+ // Pass 3: replace every occurrence top-down (an outer hoist swallows its inner ones this
162
+ // round; the next round dedupes inside the new defs).
163
+ const defsMap = defs();
164
+ const replaceIn = (node: Json): void => {
165
+ visitChildren(node, (child, replace) => {
166
+ const hash = signature.get(child);
167
+ const hit = hash === undefined ? undefined : hoisted.get(hash);
168
+ if (hit) {
169
+ if (!(hit.name in defsMap)) {
170
+ defsMap[hit.name] = JSON.parse(hit.canonical);
171
+ reserved.add(hit.name);
172
+ }
173
+ replace({ $ref: `#/$defs/${hit.name}` });
174
+ return;
175
+ }
176
+ replaceIn(child);
177
+ });
178
+ };
179
+ for (const root of roots) replaceIn(root);
180
+ }
181
+ }
package/src/emit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PatternRef, TreeNode } from './build';
2
+ import { dedupeSubtrees } from './dedupe';
2
3
  import type { ResolvedModule } from './resolve';
3
4
 
4
5
  /**
@@ -230,6 +231,9 @@ export function emit(tree: TreeNode | undefined, options: EmitOptions): string {
230
231
  }
231
232
  }
232
233
 
234
+ // Structural sharing: repeated tails (optional-slot syntagms) hoist into $defs once.
235
+ dedupeSubtrees(document as Record<string, unknown>);
236
+
233
237
  return `${JSON.stringify(document, null, 2)}\n`;
234
238
  }
235
239