@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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
23
|
-
"@pulse-compute/wasm-contracts": "1.0.0-beta.
|
|
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.
|
|
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.
|
|
14
|
-
const CANONICAL_SCHEMA_REGISTRY_VERSION = 'pulse.canonical-schema-registry.
|
|
15
|
-
const CANONICAL_SCHEMA_CODECS_VERSION = 'pulse.canonical-schema-codecs.
|
|
16
|
-
const JAVASCRIPT_SCHEMA_CODEC_VERSION = 'pulse.javascript-schema-codec.
|
|
17
|
-
const NATIVE_SCHEMA_CODEC_VERSION = 'pulse.native-json-as-schema-codec.
|
|
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('
|
|
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`;
|
package/src/compiler/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|