@pulse-compute/wasm-schema-json 1.0.0-beta.4 → 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.4/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.4` 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.4/guides/json-schemas/), [Project configuration](https://pulsecompute.io/v1.0.0-beta.4/reference/project-config/#json-schema-policy), and [Implementation packages](https://pulsecompute.io/v1.0.0-beta.4/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.4",
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.4",
23
- "@pulse-compute/wasm-contracts": "1.0.0-beta.4",
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.4/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.v1';
17
- const NATIVE_SCHEMA_CODEC_VERSION = 'pulse.native-json-as-schema-codec.v1';
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);');
@@ -225,10 +252,15 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
225
252
  lines.push(" if (!value || typeof value !== 'object' || Array.isArray(value)) throw __pulse_schema_failure(mode, undefined, schemaId, path, 'object', __pulse_schema_kind(value), source);");
226
253
  lines.push(' const output = {};');
227
254
  lines.push(' for (const field of node.fields) {');
255
+ lines.push(" if (!field.required && !Object.prototype.hasOwnProperty.call(value, field.name)) continue;");
228
256
  lines.push(' const fieldPath = __pulse_schema_pointer(path, field.name);');
229
257
  lines.push(' const fieldValue = __pulse_schema_data_value(value, field.name, mode, schemaId, fieldPath, source);');
230
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) });');
231
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(' }');
232
264
  lines.push(' return Object.freeze(output);');
233
265
  lines.push(' }');
234
266
  lines.push(" throw __pulse_schema_failure(mode, undefined, schemaId, path, 'supported-schema-node', String(kind), source);");
@@ -237,6 +269,10 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
237
269
  registry.schemas.forEach((schema, index) => {
238
270
  const fn = schemaFunctionName(registry, index);
239
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
+ }
240
276
  lines.push(` return __pulse_schema_apply_node(${registryName}.schemas[${index}].root, value, mode, ${JSON.stringify(schema.id)}, '', source);`);
241
277
  lines.push('}');
242
278
  });
@@ -263,15 +299,41 @@ function renderCanonicalSchemaCodecDeclaration(registryInput, options = {}) {
263
299
  lines.push(" decode(schemaId, value, source = 'json') { return __pulse_schema_apply(schemaId, value, 'decode', source); },");
264
300
  lines.push(" encode(schemaId, value, source = 'response') { return __pulse_schema_apply(schemaId, value, 'encode', source); },");
265
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
+ }
266
315
  lines.push(' let value;');
267
316
  lines.push(' try { value = JSON.parse(String(text)); }');
268
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
+ }
269
321
  lines.push(" return __pulse_schema_apply(schemaId, value, 'decode', source);");
270
322
  lines.push(' },');
271
323
  lines.push(" encodeJsonText(schemaId, value, source = 'response') {");
272
324
  lines.push(" const normalized = __pulse_schema_apply(schemaId, value, 'encode', source);");
273
- lines.push(' try { return JSON.stringify(normalized); }');
325
+ lines.push(' let text;');
326
+ lines.push(' try { text = JSON.stringify(normalized); }');
274
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;');
275
337
  lines.push(' }');
276
338
  lines.push('});');
277
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));
@@ -330,7 +340,7 @@ function classifyType(graph, record, typeNode, state) {
330
340
  if (typeNode.kind === ts.SyntaxKind.BooleanKeyword) return Object.freeze({ kind: 'boolean' });
331
341
  if (typeNode.kind === ts.SyntaxKind.NumberKeyword) return Object.freeze({ kind: 'f64' });
332
342
  if (typeNode.kind === ts.SyntaxKind.UndefinedKeyword || typeNode.kind === ts.SyntaxKind.VoidKeyword) {
333
- fail('PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED', 'undefined and optional schema fields are reserved beyond IR v1.', typeNode, record);
343
+ fail('PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED', 'Explicit undefined schema types are reserved; use a question-mark property for absence.', typeNode, record);
334
344
  }
335
345
  if (ts.isArrayTypeNode(typeNode)) {
336
346
  return Object.freeze({ kind: 'array', element: classifyType(graph, record, typeNode.elementType, state) });
@@ -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
  }
@@ -420,15 +440,7 @@ function classifyMembers(graph, record, members, state) {
420
440
  if (!ts.isPropertySignature(member)) {
421
441
  fail(
422
442
  'PULSE_SCHEMA_OBJECT_MEMBER_UNSUPPORTED',
423
- 'Schema object types allow required property signatures only.',
424
- member,
425
- record
426
- );
427
- }
428
- if (member.questionToken) {
429
- fail(
430
- 'PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED',
431
- 'Optional schema fields are reserved until absence-preserving encode semantics are defined.',
443
+ 'Schema object types allow property signatures only.',
432
444
  member,
433
445
  record
434
446
  );
@@ -444,7 +456,7 @@ function classifyMembers(graph, record, members, state) {
444
456
  names.add(name);
445
457
  fields.push(Object.freeze({
446
458
  name,
447
- required: true,
459
+ required: !member.questionToken,
448
460
  value: classifyType(graph, record, member.type, { ...state, node: member }),
449
461
  source: sourceLocation(record, member.name)
450
462
  }));
@@ -517,10 +529,10 @@ function extractSchemas(graph, record, property) {
517
529
  if (ids.has(id)) fail('PULSE_SCHEMA_ID_DUPLICATE', `Schema ID ${id} is declared more than once.`, declaration.name, record, { id });
518
530
  ids.add(id);
519
531
  const call = helperCall(declaration.initializer, record, 'schema');
520
- if (call.arguments.length !== 0 || !call.typeArguments || call.typeArguments.length !== 1) {
532
+ if (call.arguments.length > 1 || !call.typeArguments || call.typeArguments.length !== 1) {
521
533
  fail(
522
534
  'PULSE_SCHEMA_DECLARATION_SIGNATURE_INVALID',
523
- `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.`,
524
536
  call,
525
537
  record,
526
538
  { id }
@@ -530,7 +542,8 @@ function extractSchemas(graph, record, property) {
530
542
  const typeReferenceName = ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)
531
543
  ? typeNode.typeName.text
532
544
  : null;
533
- 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;
534
547
  const typeName = resolvedRoot
535
548
  ? resolvedRoot.declaration.name.text
536
549
  : `Inline_${id.replace(/[^A-Za-z0-9_$]+/g, '_')}`;
@@ -538,10 +551,27 @@ function extractSchemas(graph, record, property) {
538
551
  stack: new Set(),
539
552
  node: typeNode
540
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
+ }
541
570
  schemas.push(Object.freeze({
542
571
  id,
543
572
  typeName,
544
573
  root,
574
+ ...(jsonLimits ? { jsonLimits } : {}),
545
575
  source: resolvedRoot
546
576
  ? sourceLocation(resolvedRoot.record, resolvedRoot.declaration.name)
547
577
  : sourceLocation(record, declaration.name)
@@ -621,7 +651,7 @@ function schemaRegistryLegacyBridge(registry) {
621
651
  const deferred = [];
622
652
  for (const schema of registry.schemas) {
623
653
  const fieldPairs = schema.root.fields.map((field) => [field.name, legacyFieldType(field.value)]);
624
- if (schema.typeName.startsWith('Inline_') || fieldPairs.some(([, type]) => type === null)) {
654
+ if (schema.typeName.startsWith('Inline_') || schema.root.fields.some(field => !field.required) || fieldPairs.some(([, type]) => type === null)) {
625
655
  deferred.push(schema.id);
626
656
  continue;
627
657
  }