@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.
|
|
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);');
|
|
@@ -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('
|
|
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`;
|
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));
|
|
@@ -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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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)
|