@pulse-compute/wasm-schema-json 1.0.0-beta.5 → 1.0.0-beta.6

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/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
  > **Stability:** Internal schema compiler interface synchronized with this release set.<br>
9
9
  > **npm:** [`@pulse-compute/wasm-schema-json`](https://www.npmjs.com/package/@pulse-compute/wasm-schema-json)<br>
10
10
  > **Canonical replacement:** `schemas in .pulse/config.ts through the Pulse project workflow`<br>
11
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/)
11
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/)
12
12
  >
13
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
13
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` package policy.
14
14
  <!-- pulse-package-status:end -->
15
15
 
16
16
  JSON schema compiler, canonical codec, registry, sidecar, and body-policy implementation for Pulse.
@@ -35,4 +35,4 @@ Application projects declare schemas in `pulse.config.*`; they do not install or
35
35
 
36
36
  The exported compiler modules are synchronized implementation interfaces without an application-author compatibility promise.
37
37
 
38
- See [Explicit JSON schemas](https://pulsecompute.io/v1.0.0-beta.5/guides/json-schemas/), [Project configuration](https://pulsecompute.io/v1.0.0-beta.5/reference/project-config/#json-schema-policy), and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/).
38
+ See [Explicit JSON schemas](https://pulsecompute.io/v1.0.0-beta.6/guides/json-schemas/), [Project configuration](https://pulsecompute.io/v1.0.0-beta.6/reference/project-config/#json-schema-policy), and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/wasm-schema-json",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": "^22.14.0 || ^24.0.0"
@@ -19,8 +19,8 @@
19
19
  "./compiler/schema-registry": "./src/compiler/schema-registry.js"
20
20
  },
21
21
  "dependencies": {
22
- "@pulse-compute/wasm-build-support": "1.0.0-beta.5",
23
- "@pulse-compute/wasm-contracts": "1.0.0-beta.5",
22
+ "@pulse-compute/wasm-build-support": "1.0.0-beta.6",
23
+ "@pulse-compute/wasm-contracts": "1.0.0-beta.6",
24
24
  "typescript": "5.9.3"
25
25
  },
26
26
  "publishConfig": {
@@ -37,7 +37,7 @@
37
37
  "url": "git+https://github.com/pulse-compute/pulse.git",
38
38
  "directory": "wasm/packages/schema-json"
39
39
  },
40
- "homepage": "https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/",
40
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/",
41
41
  "bugs": {
42
42
  "url": "https://github.com/pulse-compute/pulse/issues"
43
43
  }
@@ -3,18 +3,23 @@
3
3
  const crypto = require('node:crypto');
4
4
  const {
5
5
  SCHEMA_REGISTRY_IR_VERSION,
6
- normalizeSchemaRegistry
6
+ normalizeSchemaRegistry,
7
+ scalarRecordStringBytes,
8
+ schemaHasScalarRecord,
9
+ schemaHasNestedJson
7
10
  } = require('@pulse-compute/wasm-contracts/schema-json/registry');
11
+ const { projectScalarRecord, validateScalarRecordText } = require('./scalar-record-codec.js');
12
+ const { javascriptJsonAdmissionSource } = require('./json-admission.js');
8
13
  const {
9
14
  normalizeJsonTraceEvent,
10
15
  semanticValueDigest
11
16
  } = require('@pulse-compute/wasm-contracts/schema-json/semantic-trace');
12
17
 
13
- const CANONICAL_SCHEMA_BUNDLE_VERSION = 'pulse.canonical-schema-bundle.v2';
14
- const CANONICAL_SCHEMA_REGISTRY_VERSION = 'pulse.canonical-schema-registry.v2';
15
- const CANONICAL_SCHEMA_CODECS_VERSION = 'pulse.canonical-schema-codecs.v2';
16
- const JAVASCRIPT_SCHEMA_CODEC_VERSION = 'pulse.javascript-schema-codec.v2';
17
- const NATIVE_SCHEMA_CODEC_VERSION = 'pulse.native-json-as-schema-codec.v2';
18
+ const CANONICAL_SCHEMA_BUNDLE_VERSION = 'pulse.canonical-schema-bundle.v4';
19
+ const CANONICAL_SCHEMA_REGISTRY_VERSION = 'pulse.canonical-schema-registry.v4';
20
+ const CANONICAL_SCHEMA_CODECS_VERSION = 'pulse.canonical-schema-codecs.v4';
21
+ const JAVASCRIPT_SCHEMA_CODEC_VERSION = 'pulse.javascript-schema-codec.v4';
22
+ const NATIVE_SCHEMA_CODEC_VERSION = 'pulse.native-json-as-schema-codec.v4';
18
23
 
19
24
  function stableObject(value) {
20
25
  if (Array.isArray(value)) return value.map(stableObject);
@@ -105,6 +110,7 @@ function canonicalRegistry(input, options = {}) {
105
110
  registryHash: registryIr.registryHash,
106
111
  id: schema.id,
107
112
  root: schema.root,
113
+ ...(schema.jsonLimits ? { jsonLimits: schema.jsonLimits, boundaryMaxBytes: maxBytes } : {}),
108
114
  policies: registryIr.policies
109
115
  };
110
116
  const semanticHash = stableHash(semantic);
@@ -155,6 +161,16 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
155
161
  const codecsName = options.codecsName || '__pulse_schema_codecs';
156
162
  const lines = [];
157
163
  lines.push(`const ${registryName} = Object.freeze(${JSON.stringify(registry, null, 2)});`);
164
+ const scalarRecords = registry.schemas.some(schema => schemaHasScalarRecord(schema.root));
165
+ const nestedJson = registry.schemas.some(schema => schemaHasNestedJson(schema.root));
166
+ const admission = registry.schemas.some(schema => schema.jsonLimits);
167
+ if (admission) lines.push(javascriptJsonAdmissionSource());
168
+ if (scalarRecords || nestedJson) {
169
+ // Keep generated declarations in the existing reserved schema namespace.
170
+ for (const helper of [scalarRecordStringBytes, projectScalarRecord, validateScalarRecordText]) {
171
+ lines.push(helper.toString().replace(/\b(scalarRecordStringBytes|projectScalarRecord|validateScalarRecordText)\b/g, name => '__pulse_schema_' + name));
172
+ }
173
+ }
158
174
  lines.push('function __pulse_schema_kind(value) {');
159
175
  lines.push(" if (value === null) return 'null';");
160
176
  lines.push(" if (Array.isArray(value)) return 'array';");
@@ -189,6 +205,17 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
189
205
  lines.push('}');
190
206
  lines.push('function __pulse_schema_apply_node(node, value, mode, schemaId, path, source) {');
191
207
  lines.push(' const kind = node.kind;');
208
+ if (nestedJson) {
209
+ lines.push(" if (kind === 'json-value') return value;");
210
+ lines.push(" if (kind === 'json-object') {");
211
+ lines.push(" if (!value || typeof value !== 'object' || Array.isArray(value)) throw __pulse_schema_failure(mode, undefined, schemaId, path, 'json-object', __pulse_schema_kind(value), source);");
212
+ lines.push(' return value;', ' }');
213
+ }
214
+ if (scalarRecords) {
215
+ lines.push(" if (kind === 'scalar-record') return __pulse_schema_projectScalarRecord(value, node.limits, (key, expected, actual) => {");
216
+ lines.push(' throw __pulse_schema_failure(mode, undefined, schemaId, key === null ? path : __pulse_schema_pointer(path, key), expected, actual, source);');
217
+ lines.push(' });');
218
+ }
192
219
  lines.push(" if (kind === 'nullable') {");
193
220
  lines.push(" if (value === null) return null;");
194
221
  lines.push(' return __pulse_schema_apply_node(node.value, value, mode, schemaId, path, source);');
@@ -230,6 +257,10 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
230
257
  lines.push(' const fieldValue = __pulse_schema_data_value(value, field.name, mode, schemaId, fieldPath, source);');
231
258
  lines.push(' Object.defineProperty(output, field.name, { enumerable: true, configurable: false, writable: false, value: __pulse_schema_apply_node(field.value, fieldValue, mode, schemaId, fieldPath, source) });');
232
259
  lines.push(' }');
260
+ lines.push(' if (node.additionalProperties) for (const key of Object.keys(value)) {');
261
+ lines.push(' if (node.fields.some(field => field.name === key)) continue;');
262
+ lines.push(' Object.defineProperty(output, key, { enumerable: true, value: value[key] });');
263
+ lines.push(' }');
233
264
  lines.push(' return Object.freeze(output);');
234
265
  lines.push(' }');
235
266
  lines.push(" throw __pulse_schema_failure(mode, undefined, schemaId, path, 'supported-schema-node', String(kind), source);");
@@ -238,6 +269,10 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
238
269
  registry.schemas.forEach((schema, index) => {
239
270
  const fn = schemaFunctionName(registry, index);
240
271
  lines.push(`function ${fn}(value, mode, source) {`);
272
+ if (schema.jsonLimits) {
273
+ lines.push(` try { value = __pulse_schema_inspectJsonValue(value, ${registryName}.schemas[${index}].jsonLimits, true).value; }`);
274
+ lines.push(` catch (cause) { throw __pulse_schema_failure(mode, undefined, ${JSON.stringify(schema.id)}, '', 'bounded-json', cause.detail?.reason || 'unsupported-value', source, cause); }`);
275
+ }
241
276
  lines.push(` return __pulse_schema_apply_node(${registryName}.schemas[${index}].root, value, mode, ${JSON.stringify(schema.id)}, '', source);`);
242
277
  lines.push('}');
243
278
  });
@@ -264,15 +299,41 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
264
299
  lines.push(" decode(schemaId, value, source = 'json') { return __pulse_schema_apply(schemaId, value, 'decode', source); },");
265
300
  lines.push(" encode(schemaId, value, source = 'response') { return __pulse_schema_apply(schemaId, value, 'encode', source); },");
266
301
  lines.push(" decodeJsonText(schemaId, text, source = 'json') {");
302
+ lines.push(' const schema = this.schema(schemaId);');
303
+ lines.push(' if (!schema) throw __pulse_schema_reference_error(schemaId, source);');
304
+ if (admission) {
305
+ lines.push(' let duplicates = [];');
306
+ lines.push(' if (schema.jsonLimits) {');
307
+ lines.push(' const limits = { ...schema.jsonLimits, maxTextBytes: Math.min(schema.jsonLimits.maxTextBytes, this.registry.maxBytes) };');
308
+ lines.push(' const scan = new __pulse_schema_JsonTextAdmission(String(text), limits, 2);');
309
+ lines.push(" if (!scan.scan()) throw __pulse_schema_failure('decode', scan.failure === 1 ? 'PULSE_SCHEMA_JSON_MALFORMED' : scan.failure === 2 ? 'PULSE_BODY_TOO_LARGE' : undefined, String(schemaId), '', 'bounded-json', __pulse_schema_JSON_ADMISSION_FAILURES[scan.failure], source);");
310
+ lines.push(' duplicates = scan.duplicateObjects;', ' }');
311
+ }
312
+ if ((scalarRecords || nestedJson) && admission) {
313
+ lines.push(" if (schema.jsonLimits) __pulse_schema_validateScalarRecordText(String(text), schema.root, (path, expected, actual) => { throw __pulse_schema_failure('decode', undefined, String(schemaId), path, expected, actual, source); }, duplicates);");
314
+ }
267
315
  lines.push(' let value;');
268
316
  lines.push(' try { value = JSON.parse(String(text)); }');
269
317
  lines.push(" catch (cause) { throw __pulse_schema_failure('decode', 'PULSE_SCHEMA_JSON_MALFORMED', String(schemaId), '', 'valid-json', 'malformed-json', source, cause); }");
318
+ if (scalarRecords) {
319
+ lines.push(" if (!schema.jsonLimits) __pulse_schema_validateScalarRecordText(String(text), schema.root, (path, expected, actual) => { throw __pulse_schema_failure('decode', undefined, String(schemaId), path, expected, actual, source); });");
320
+ }
270
321
  lines.push(" return __pulse_schema_apply(schemaId, value, 'decode', source);");
271
322
  lines.push(' },');
272
323
  lines.push(" encodeJsonText(schemaId, value, source = 'response') {");
273
324
  lines.push(" const normalized = __pulse_schema_apply(schemaId, value, 'encode', source);");
274
- lines.push(' try { return JSON.stringify(normalized); }');
325
+ lines.push(' let text;');
326
+ lines.push(' try { text = JSON.stringify(normalized); }');
275
327
  lines.push(" catch (cause) { throw __pulse_schema_failure('encode', 'PULSE_SCHEMA_ENCODE', String(schemaId), '', 'serializable-json', __pulse_schema_kind(normalized), source, cause); }");
328
+ if (admission) {
329
+ lines.push(' const schema = this.schema(schemaId);');
330
+ lines.push(' if (schema.jsonLimits) {');
331
+ lines.push(' const limits = { ...schema.jsonLimits, maxTextBytes: Math.min(schema.jsonLimits.maxTextBytes, this.registry.maxBytes) };');
332
+ lines.push(' const scan = new __pulse_schema_JsonTextAdmission(text, limits, 0);');
333
+ lines.push(" if (!scan.scan()) throw __pulse_schema_failure('encode', scan.failure === 2 ? 'PULSE_BODY_TOO_LARGE' : undefined, String(schemaId), '', 'bounded-json', __pulse_schema_JSON_ADMISSION_FAILURES[scan.failure], source);");
334
+ lines.push(' }');
335
+ }
336
+ lines.push(' return text;');
276
337
  lines.push(' }');
277
338
  lines.push('});');
278
339
  return `${lines.join('\n')}\n`;
@@ -6,8 +6,10 @@ const schemaJsonGenericParser = require('./schema-json-generic-parser.js');
6
6
  const schemaJsonSidecarV2 = require('./schema-json-sidecar-v2.js');
7
7
  const canonicalSchemaCodecs = require('./canonical-schema-codecs.js');
8
8
  const schemaRegistry = require('./schema-registry.js');
9
+ const jsonAdmission = require('./json-admission.js');
9
10
 
10
11
  module.exports = {
12
+ jsonAdmission,
11
13
  schemaJsonCompile,
12
14
  schemaJsonSidecar,
13
15
  schemaJsonGenericParser,
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { admission } = require('@pulse-compute/wasm-contracts/schema-json/contracts');
4
+ const { normalizeJsonAdmissionLimits, JsonAdmissionBudget, JsonTextAdmission, JSON_ADMISSION_FAILURES } = admission;
5
+
6
+ function jsonAdmissionFailure(budget, offset = -1) {
7
+ const error = new TypeError(`JSON admission failed: ${JSON_ADMISSION_FAILURES[budget.failure]}.`);
8
+ error.code = 'PULSEWASM_JSON_ADMISSION_FAILED';
9
+ error.detail = Object.freeze({ reason: JSON_ADMISSION_FAILURES[budget.failure], offset });
10
+ throw error;
11
+ }
12
+
13
+ function jsonAdmissionSummary(budget) {
14
+ return { nodes: budget.nodes, depth: budget.depth, jsonBytes: budget.jsonBytes };
15
+ }
16
+
17
+ function admitJsonText(text, input, duplicatePolicy = 'allow') {
18
+ const limits = normalizeJsonAdmissionLimits(input);
19
+ if (typeof text !== 'string') throw new TypeError('JSON admission text must be a string.');
20
+ const mode = ['allow', 'reject', 'report'].indexOf(duplicatePolicy);
21
+ if (mode < 0) throw new TypeError('Unknown JSON duplicate policy.');
22
+ const scanner = new JsonTextAdmission(text, limits, mode);
23
+ if (!scanner.scan()) jsonAdmissionFailure(scanner, scanner.offset);
24
+ const duplicates = scanner.duplicateObjects.map((objectStart, index) => Object.freeze({
25
+ objectStart, keyStart: scanner.duplicateKeys[index], firstKeyStart: scanner.duplicateFirstKeys[index]
26
+ }));
27
+ return Object.freeze({ ...jsonAdmissionSummary(scanner), textBytes: scanner.textBytes, duplicates: Object.freeze(duplicates) });
28
+ }
29
+
30
+ // The continuation runs only after admission. Existing schemas do not select
31
+ // this internal seam; it establishes ordering for subsequent codec integration.
32
+ function withAdmittedJsonText(text, limits, materialize, duplicatePolicy = 'allow') {
33
+ const result = admitJsonText(text, limits, duplicatePolicy);
34
+ return materialize(text, result);
35
+ }
36
+
37
+ function admitJsonValue(root, input) {
38
+ const limits = normalizeJsonAdmissionLimits(input);
39
+ return inspectJsonValue(root, limits);
40
+ }
41
+
42
+ // A generated schema codec uses normalized limits and requests one detached,
43
+ // immutable snapshot. Admission and copying share a traversal, so no second
44
+ // read of an application property can replace an already-admitted child.
45
+ function inspectJsonValue(root, limits, project = false) {
46
+ const budget = new JsonAdmissionBudget(limits);
47
+ const active = new Set();
48
+ const stack = [];
49
+ const fail = reason => { budget.fail(reason); jsonAdmissionFailure(budget); };
50
+ const bytes = size => { if (!budget.addBytes(size)) jsonAdmissionFailure(budget); };
51
+ function string(text, key) {
52
+ if (!budget.stringBytes(text, key)) jsonAdmissionFailure(budget);
53
+ }
54
+ function value(item) {
55
+ const container = item !== null && typeof item === 'object';
56
+ if (!budget.node(stack.length + (container ? 1 : 0))) jsonAdmissionFailure(budget);
57
+ if (!container) {
58
+ if (item === null) bytes(4);
59
+ else if (typeof item === 'boolean') bytes(item ? 4 : 5);
60
+ else if (typeof item === 'string') string(item, false);
61
+ else if (typeof item === 'number') { if (!Number.isFinite(item)) fail(11); bytes(24); }
62
+ else fail(12);
63
+ return item;
64
+ }
65
+ if (active.has(item)) fail(13);
66
+ const array = Array.isArray(item);
67
+ const proto = Object.getPrototypeOf(item);
68
+ if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) fail(12);
69
+ if (array && item.length > limits.maxArrayItems) fail(6);
70
+ // JS has no incremental own-key reflection primitive. This enumerates the
71
+ // already-created input, then bounds our traversal stack before retaining
72
+ // frames or reading descriptors. It does not construct a projected object.
73
+ const keys = Reflect.ownKeys(item);
74
+ if (array) {
75
+ if (keys.length !== item.length + 1) fail(12); // dense, no extra properties
76
+ } else if (keys.length > limits.maxObjectMembers) fail(5);
77
+ if (keys.some(key => typeof key !== 'string')) fail(12);
78
+ bytes(2); // opening and closing delimiters
79
+ active.add(item);
80
+ const output = project ? (array ? [] : {}) : null;
81
+ stack.push({ item, output, array, keys, index: 0, count: array ? item.length : keys.length });
82
+ return output;
83
+ }
84
+ const output = value(root);
85
+ while (stack.length > 0) {
86
+ const frame = stack[stack.length - 1];
87
+ if (frame.index === frame.count) {
88
+ if (project) Object.freeze(frame.output);
89
+ active.delete(frame.item); stack.pop(); continue;
90
+ }
91
+ const index = frame.index++;
92
+ const key = frame.array ? String(index) : frame.keys[index];
93
+ if (index > 0) bytes(1);
94
+ if (!frame.array) { string(key, true); bytes(1); }
95
+ const descriptor = Object.getOwnPropertyDescriptor(frame.item, key);
96
+ if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) fail(12);
97
+ const child = value(descriptor.value);
98
+ if (project) Object.defineProperty(frame.output, key, { value: child, enumerable: true });
99
+ }
100
+ return Object.freeze({ ...jsonAdmissionSummary(budget), ...(project ? { value: output } : {}) });
101
+ }
102
+
103
+ function javascriptJsonAdmissionSource() {
104
+ const classes = [admission.JsonAdmissionBudget, admission.JsonAdmissionFrame, admission.JsonTextAdmission];
105
+ return (`const JSON_ADMISSION_FAILURES = ${JSON.stringify(JSON_ADMISSION_FAILURES)};\n`
106
+ + [...classes, jsonAdmissionFailure, jsonAdmissionSummary, inspectJsonValue].map(value => value.toString()).join('\n'))
107
+ .replace(/\b(JSON_ADMISSION_FAILURES|JsonAdmissionBudget|JsonAdmissionFrame|JsonTextAdmission|jsonAdmissionFailure|jsonAdmissionSummary|inspectJsonValue)\b/g,
108
+ name => '__pulse_schema_' + name);
109
+ }
110
+
111
+ module.exports = { admitJsonText, withAdmittedJsonText, admitJsonValue, javascriptJsonAdmissionSource };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const { scalarRecordStringBytes } = require('@pulse-compute/wasm-contracts/schema-json/registry');
4
+
5
+ // These functions are embedded into emitted JavaScript codecs. They use only
6
+ // their arguments, standard JavaScript, and the shared string-sizing function.
7
+ function projectScalarRecord(value, limits, fail) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)
9
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
10
+ fail(null, 'scalar-record', value === null ? 'null' : typeof value);
11
+ }
12
+ const keys = Object.getOwnPropertyNames(value).sort();
13
+ if (keys.length > limits.maxKeys) fail(null, 'at-most-' + limits.maxKeys + '-keys', 'too-many-keys');
14
+ if (Object.getOwnPropertySymbols(value).length) fail(null, 'string-keys', 'symbol');
15
+ const output = {};
16
+ let bytes = 2;
17
+ for (let index = 0; index < keys.length; index++) {
18
+ const key = keys[index];
19
+ if (key.length > limits.maxKeyLength) fail(key, 'bounded-key', 'key-too-long');
20
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
21
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) fail(key, 'data-property', 'accessor');
22
+ const item = descriptor.value;
23
+ let size;
24
+ if (item === null) size = 4;
25
+ else if (typeof item === 'string') {
26
+ if (item.length > limits.maxStringLength) fail(key, 'bounded-string', 'string-too-long');
27
+ size = scalarRecordStringBytes(item);
28
+ } else if (typeof item === 'boolean') size = item ? 4 : 5;
29
+ else if (typeof item === 'number' && Number.isFinite(item)) size = limits.numberBytes;
30
+ else fail(key, 'string|finite-number|boolean|null', Array.isArray(item) ? 'array' : typeof item);
31
+ bytes += (index === 0 ? 0 : 1) + scalarRecordStringBytes(key) + 1 + size;
32
+ if (bytes > limits.maxBytes) fail(null, 'bounded-scalar-record', 'byte-budget-exceeded');
33
+ Object.defineProperty(output, key, { enumerable: true, value: item });
34
+ }
35
+ return Object.freeze(output);
36
+ }
37
+
38
+ // JSON.parse still owns syntax. Scan only schema-selected records in valid
39
+ // JSON before exposing values; ordinary object fields keep last-member-wins.
40
+ function validateScalarRecordText(text, root, fail, duplicateObjects = []) {
41
+ function space(i) { while (i < text.length && text.charCodeAt(i) <= 32) i++; return i; }
42
+ function stringEnd(i) {
43
+ for (i++; i < text.length; i++) {
44
+ const c = text.charCodeAt(i);
45
+ if (c === 92) i++;
46
+ else if (c === 34) return i + 1;
47
+ }
48
+ return text.length;
49
+ }
50
+ function end(i) {
51
+ i = space(i);
52
+ if (text[i] === '"') return stringEnd(i);
53
+ if (text[i] !== '{' && text[i] !== '[') {
54
+ while (i < text.length && !',]} \t\r\n'.includes(text[i])) i++;
55
+ return i;
56
+ }
57
+ let depth = 0;
58
+ for (; i < text.length; i++) {
59
+ const c = text[i];
60
+ if (c === '"') i = stringEnd(i) - 1;
61
+ else if (c === '{' || c === '[') depth++;
62
+ else if ((c === '}' || c === ']') && --depth === 0) return i + 1;
63
+ }
64
+ return text.length;
65
+ }
66
+ function visit(node, start, path) {
67
+ start = space(start);
68
+ if (node.kind === 'json-value' || node.kind === 'json-object') {
69
+ const stop = end(start);
70
+ if (duplicateObjects.some(offset => offset >= start && offset < stop)) fail(path, 'unique-keys', 'duplicate-key');
71
+ return;
72
+ }
73
+ if (node.kind === 'nullable') { visit(node.value, start, path); return; }
74
+ if (node.kind === 'array' && text[start] === '[') {
75
+ let i = space(start + 1), index = 0;
76
+ while (i < text.length && text[i] !== ']') {
77
+ visit(node.element, i, path + '/' + index++);
78
+ i = space(end(i));
79
+ if (text[i] !== ',') break;
80
+ i = space(i + 1);
81
+ }
82
+ } else if ((node.kind === 'object' || node.kind === 'scalar-record') && text[start] === '{') {
83
+ if (node.additionalProperties && duplicateObjects.includes(start)) fail(path, 'unique-keys', 'duplicate-key');
84
+ const names = new Set(), fields = new Map();
85
+ let i = space(start + 1);
86
+ while (i < text.length && text[i] !== '}') {
87
+ const keyEnd = stringEnd(i), key = JSON.parse(text.slice(i, keyEnd));
88
+ i = space(space(keyEnd) + 1);
89
+ if (node.kind === 'scalar-record') {
90
+ if (names.has(key)) fail(path, 'unique-keys', 'duplicate-key');
91
+ names.add(key);
92
+ if (names.size > node.limits.maxKeys) fail(path, 'bounded-scalar-record', 'too-many-keys');
93
+ } else {
94
+ const field = node.fields.find(field => field.name === key);
95
+ if (field) fields.set(key, { node: field.value, start: i });
96
+ else if (node.additionalProperties) fields.set(key, { node: node.additionalProperties, start: i });
97
+ }
98
+ i = space(end(i));
99
+ if (text[i] !== ',') break;
100
+ i = space(i + 1);
101
+ }
102
+ for (const [key, field] of fields) visit(field.node, field.start, path + '/' + key.replace(/~/g, '~0').replace(/\//g, '~1'));
103
+ }
104
+ }
105
+ visit(root, 0, '');
106
+ }
107
+
108
+ module.exports = { projectScalarRecord, validateScalarRecordText };
@@ -5,13 +5,14 @@ const path = require('node:path');
5
5
  const ts = require('typescript');
6
6
  const {
7
7
  normalizeSchemaRegistry,
8
- codecInputsForRegistry
8
+ codecInputsForRegistry,
9
+ normalizeJsonSchemaLimits
9
10
  } = require('@pulse-compute/wasm-contracts/schema-json/registry');
10
11
 
11
- const SCHEMA_REGISTRY_EXTRACTOR_VERSION = 'pulse.schema-registry-extractor.v1';
12
+ const SCHEMA_REGISTRY_EXTRACTOR_VERSION = 'pulse.schema-registry-extractor.v3';
12
13
  const AUTHORING_MODULE = '@pulse-compute/pulse/schema';
13
14
  const HELPER_EXPORTS = new Set(['defineSchemaRegistry', 'schema', 'response']);
14
- const MARKER_EXPORTS = new Map([['Int32', 'i32'], ['Uint32', 'u32']]);
15
+ const MARKER_EXPORTS = new Map([['Int32', 'i32'], ['Uint32', 'u32'], ['ScalarRecord', 'scalar-record'], ['JsonValue', 'json-value'], ['JsonObject', 'json-object'], ['OpenObject', 'open-object']]);
15
16
 
16
17
  class SchemaRegistryExtractionError extends Error {
17
18
  constructor(code, message, detail = {}) {
@@ -232,13 +233,13 @@ class TypeGraph {
232
233
  fail('PULSE_SCHEMA_HELPER_VALUE_IMPORT_REQUIRED', `${imported} must be imported as a value.`, element, record, { imported });
233
234
  }
234
235
  record.helpers.set(local, imported);
235
- } else if (MARKER_EXPORTS.has(imported)) {
236
+ } else if (MARKER_EXPORTS.has(imported) || imported === 'JsonLimits' || imported === 'SchemaOptions') {
236
237
  if (!clause.isTypeOnly && !element.isTypeOnly) {
237
238
  fail('PULSE_SCHEMA_MARKER_TYPE_IMPORT_REQUIRED', `${imported} must be imported with import type.`, element, record, {
238
239
  imported
239
240
  });
240
241
  }
241
- record.markers.set(local, MARKER_EXPORTS.get(imported));
242
+ if (MARKER_EXPORTS.has(imported)) record.markers.set(local, MARKER_EXPORTS.get(imported));
242
243
  } else {
243
244
  fail('PULSE_SCHEMA_AUTHORING_IMPORT_UNKNOWN', `Unsupported schema authoring import ${imported}.`, element, record, {
244
245
  imported
@@ -285,6 +286,14 @@ class TypeGraph {
285
286
  record
286
287
  );
287
288
  }
289
+ if (statement.moduleSpecifier.text === AUTHORING_MODULE && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
290
+ for (const element of statement.exportClause.elements) {
291
+ const imported = element.propertyName ? element.propertyName.text : element.name.text;
292
+ if (!MARKER_EXPORTS.has(imported)) fail('PULSE_SCHEMA_AUTHORING_IMPORT_UNKNOWN', `Unsupported schema marker ${imported}.`, element, record);
293
+ record.reExports.set(element.name.text, { marker: MARKER_EXPORTS.get(imported) });
294
+ }
295
+ return;
296
+ }
288
297
  const resolved = this.resolveRelative(record.file, statement.moduleSpecifier.text, statement.moduleSpecifier, record);
289
298
  this.load(resolved);
290
299
  if (!statement.exportClause) {
@@ -314,6 +323,7 @@ class TypeGraph {
314
323
  const imported = record.imports.get(name);
315
324
  if (imported) return this.resolve(this.load(imported.file), imported.name, true, seen);
316
325
  const reExport = record.reExports.get(name);
326
+ if (reExport && reExport.marker) return { record, marker: reExport.marker };
317
327
  if (reExport) return this.resolve(this.load(reExport.file), reExport.name, true, seen);
318
328
  for (const file of record.exportStars) {
319
329
  const resolved = this.resolve(this.load(file), name, true, new Set(seen));
@@ -371,7 +381,18 @@ function classifyType(graph, record, typeNode, state) {
371
381
  }
372
382
  return Object.freeze({ kind: 'array', element: classifyType(graph, record, typeNode.typeArguments[0], state) });
373
383
  }
374
- const marker = record.markers.get(name);
384
+ const resolved = record.markers.has(name) ? undefined : graph.resolve(record, name);
385
+ const marker = record.markers.get(name) || (resolved && resolved.marker);
386
+ if (marker === 'open-object') {
387
+ if (!typeNode.typeArguments || typeNode.typeArguments.length !== 1) {
388
+ fail('PULSE_SCHEMA_MARKER_GENERIC_UNSUPPORTED', `${name} requires exactly one declared object type.`, typeNode, record);
389
+ }
390
+ const object = classifyType(graph, record, typeNode.typeArguments[0], state);
391
+ if (object.kind !== 'object') {
392
+ fail('PULSE_SCHEMA_TYPE_UNSUPPORTED', `${name} requires a declared object type.`, typeNode, record);
393
+ }
394
+ return Object.freeze({ ...object, additionalProperties: Object.freeze({ kind: 'json-value' }) });
395
+ }
375
396
  if (marker) {
376
397
  if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
377
398
  fail('PULSE_SCHEMA_MARKER_GENERIC_UNSUPPORTED', `${name} does not accept type arguments.`, typeNode, record);
@@ -383,7 +404,6 @@ function classifyType(graph, record, typeNode, state) {
383
404
  typeName: name
384
405
  });
385
406
  }
386
- const resolved = graph.resolve(record, name);
387
407
  if (!resolved) {
388
408
  fail('PULSE_SCHEMA_TYPE_NOT_FOUND', `Could not resolve schema type ${name}.`, typeNode, record, { typeName: name });
389
409
  }
@@ -509,10 +529,10 @@ function extractSchemas(graph, record, property) {
509
529
  if (ids.has(id)) fail('PULSE_SCHEMA_ID_DUPLICATE', `Schema ID ${id} is declared more than once.`, declaration.name, record, { id });
510
530
  ids.add(id);
511
531
  const call = helperCall(declaration.initializer, record, 'schema');
512
- if (call.arguments.length !== 0 || !call.typeArguments || call.typeArguments.length !== 1) {
532
+ if (call.arguments.length > 1 || !call.typeArguments || call.typeArguments.length !== 1) {
513
533
  fail(
514
534
  'PULSE_SCHEMA_DECLARATION_SIGNATURE_INVALID',
515
- `Schema ${id} must use schema<Type>() with one type argument and no runtime arguments.`,
535
+ `Schema ${id} must use schema<Type>() with one type argument and at most one static options object.`,
516
536
  call,
517
537
  record,
518
538
  { id }
@@ -522,7 +542,8 @@ function extractSchemas(graph, record, property) {
522
542
  const typeReferenceName = ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)
523
543
  ? typeNode.typeName.text
524
544
  : null;
525
- const resolvedRoot = typeReferenceName ? graph.resolve(record, typeReferenceName) : null;
545
+ const resolution = typeReferenceName ? graph.resolve(record, typeReferenceName) : null;
546
+ const resolvedRoot = resolution && resolution.declaration ? resolution : null;
526
547
  const typeName = resolvedRoot
527
548
  ? resolvedRoot.declaration.name.text
528
549
  : `Inline_${id.replace(/[^A-Za-z0-9_$]+/g, '_')}`;
@@ -530,10 +551,27 @@ function extractSchemas(graph, record, property) {
530
551
  stack: new Set(),
531
552
  node: typeNode
532
553
  });
554
+ let jsonLimits;
555
+ if (call.arguments.length) {
556
+ const options = objectProperties(call.arguments[0], record, new Set(['json']), 'schema options');
557
+ const json = options.properties.get('json');
558
+ const limits = {};
559
+ if (json) {
560
+ const fields = objectProperties(json.initializer, record, new Set(Object.keys(normalizeJsonSchemaLimits())), 'schema JSON limits');
561
+ for (const [key, property] of fields.properties) {
562
+ const literal = unwrapExpression(property.initializer);
563
+ if (!ts.isNumericLiteral(literal)) fail('PULSE_SCHEMA_JSON_LIMIT_LITERAL_REQUIRED', 'Schema JSON limits require positive integer literals.', literal, record);
564
+ limits[key] = Number(literal.text);
565
+ }
566
+ }
567
+ try { jsonLimits = normalizeJsonSchemaLimits(limits); }
568
+ catch (error) { fail(error.code, error.message, call.arguments[0], record); }
569
+ }
533
570
  schemas.push(Object.freeze({
534
571
  id,
535
572
  typeName,
536
573
  root,
574
+ ...(jsonLimits ? { jsonLimits } : {}),
537
575
  source: resolvedRoot
538
576
  ? sourceLocation(resolvedRoot.record, resolvedRoot.declaration.name)
539
577
  : sourceLocation(record, declaration.name)