@pulse-compute/wasm-contracts 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.
@@ -1,14 +1,49 @@
1
1
  'use strict';
2
2
 
3
3
  const { sha256Hex, stableStringify } = require('../stable-id.js');
4
+ const { JSON_LIMIT_FIELDS, normalizeJsonAdmissionLimits } = require('./admission.js');
4
5
 
5
- const SCHEMA_REGISTRY_IR_VERSION = 'pulse.schema-registry-ir.v1';
6
- const SCHEMA_CODEC_INPUTS_VERSION = 'pulse.schema-codec-inputs.v1';
7
- const SCHEMA_AUTHORING_VERSION = 'pulse.schema-authoring.v1';
6
+ const SCHEMA_REGISTRY_IR_VERSION = 'pulse.schema-registry-ir.v5';
7
+ const SCHEMA_CODEC_INPUTS_VERSION = 'pulse.schema-codec-inputs.v5';
8
+ const SCHEMA_AUTHORING_VERSION = 'pulse.schema-authoring.v3';
8
9
  const SCHEMA_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/;
9
10
  const RESPONSE_CASE_ID_PATTERN = SCHEMA_ID_PATTERN;
10
11
  const SCALAR_KINDS = Object.freeze(['string', 'boolean', 'i32', 'u32', 'f64']);
11
- const NODE_KINDS = Object.freeze([...SCALAR_KINDS, 'object', 'array', 'string-enum', 'nullable']);
12
+ const NODE_KINDS = Object.freeze([...SCALAR_KINDS, 'object', 'array', 'string-enum', 'nullable', 'scalar-record', 'json-value', 'json-object']);
13
+ const SCALAR_RECORD_LIMITS = Object.freeze({ maxKeys: 32, maxKeyLength: 64, maxStringLength: 1024, maxBytes: 8192, numberBytes: 24 });
14
+ const JSON_SCHEMA_DEFAULT_LIMITS = Object.freeze({ maxTextBytes: 65536, maxDepth: 32, maxNodes: 4096,
15
+ maxObjectMembers: 256, maxArrayItems: 1024, maxKeyLength: 256, maxStringLength: 16384, maxJsonBytes: 65536 });
16
+
17
+ function normalizeJsonSchemaLimits(input = {}) {
18
+ if (!plainObject(input)) throw schemaContractError('PULSE_SCHEMA_JSON_LIMITS_INVALID', 'Schema JSON limits must be a static object.');
19
+ const values = { ...JSON_SCHEMA_DEFAULT_LIMITS };
20
+ for (const key of Reflect.ownKeys(input)) {
21
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
22
+ if (!JSON_LIMIT_FIELDS.includes(key) || !descriptor || !Object.hasOwn(descriptor, 'value')) {
23
+ throw schemaContractError('PULSE_SCHEMA_JSON_LIMITS_INVALID', 'Unknown JSON limit or non-data property.');
24
+ }
25
+ values[key] = descriptor.value;
26
+ }
27
+ try { return normalizeJsonAdmissionLimits(values); }
28
+ catch { throw schemaContractError('PULSE_SCHEMA_JSON_LIMITS_INVALID', 'JSON limits must be positive i32 integers.'); }
29
+ }
30
+
31
+ // Shared conservative UTF-8 JSON string sizing. Native code generation reuses
32
+ // this pure function body; keep it independent of host APIs and mutable state.
33
+ function scalarRecordStringBytes(text) {
34
+ let bytes = 2;
35
+ for (let i = 0; i < text.length; i++) {
36
+ const code = text.charCodeAt(i);
37
+ if (code < 32) bytes += 6;
38
+ else if (code === 34 || code === 92) bytes += 2;
39
+ else if (code < 128) bytes += 1;
40
+ else if (code < 2048) bytes += 2;
41
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { bytes += 4; i++; }
42
+ else if (code >= 0xd800 && code <= 0xdfff) bytes += 6;
43
+ else bytes += 3;
44
+ }
45
+ return bytes;
46
+ }
12
47
 
13
48
  function schemaContractError(code, message, details = {}) {
14
49
  const error = new TypeError(message);
@@ -73,6 +108,14 @@ function normalizeSchemaNode(input, field = 'schema.root', stack = []) {
73
108
  throw schemaContractError('PULSE_SCHEMA_IR_NODE_KIND_UNSUPPORTED', `${field}.kind is unsupported: ${kind}.`, { field, kind });
74
109
  }
75
110
  if (SCALAR_KINDS.includes(kind)) return Object.freeze({ kind });
111
+ if (kind === 'json-value' || kind === 'json-object') return Object.freeze({ kind });
112
+ if (kind === 'scalar-record') {
113
+ if (input.limits !== undefined && (!plainObject(input.limits)
114
+ || stableStringify(input.limits) !== stableStringify(SCALAR_RECORD_LIMITS))) {
115
+ throw schemaContractError('PULSE_SCHEMA_IR_NODE_KIND_UNSUPPORTED', `${field} must use the fixed ScalarRecord limits.`, { field, kind });
116
+ }
117
+ return Object.freeze({ kind, limits: SCALAR_RECORD_LIMITS });
118
+ }
76
119
  if (kind === 'array') {
77
120
  return Object.freeze({ kind, element: normalizeSchemaNode(input.element, `${field}.element`, stack) });
78
121
  }
@@ -106,20 +149,26 @@ function normalizeSchemaNode(input, field = 'schema.root', stack = []) {
106
149
  throw schemaContractError('PULSE_SCHEMA_IR_FIELD_DUPLICATE', `${field} declares field ${name} more than once.`, { field, name });
107
150
  }
108
151
  names.add(name);
109
- if (entry.required !== true) {
110
- throw schemaContractError('PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED', `${field}.${name} must be required in schema IR v1.`, {
152
+ if (typeof entry.required !== 'boolean') {
153
+ throw schemaContractError('PULSE_SCHEMA_IR_FIELD_REQUIRED', `${field}.${name}.required must be a boolean.`, {
111
154
  field,
112
155
  name
113
156
  });
114
157
  }
115
158
  return Object.freeze({
116
159
  name,
117
- required: true,
160
+ required: entry.required,
118
161
  value: normalizeSchemaNode(entry.value, `${field}.fields[${index}].value`, [...stack, name]),
119
162
  source: entry.source ? normalizeSource(entry.source, `${field}.fields[${index}].source`) : undefined
120
163
  });
121
164
  });
122
- return Object.freeze({ kind, fields: Object.freeze(fields) });
165
+ if (input.additionalProperties !== undefined
166
+ && (!plainObject(input.additionalProperties) || input.additionalProperties.kind !== 'json-value'
167
+ || Object.keys(input.additionalProperties).length !== 1)) {
168
+ throw schemaContractError('PULSE_SCHEMA_IR_NODE_KIND_UNSUPPORTED', `${field}.additionalProperties must be a JSON value policy.`, { field, kind });
169
+ }
170
+ return Object.freeze({ kind, fields: Object.freeze(fields),
171
+ ...(input.additionalProperties === undefined ? {} : { additionalProperties: Object.freeze({ kind: 'json-value' }) }) });
123
172
  }
124
173
 
125
174
  function normalizeSchema(input, index) {
@@ -135,6 +184,8 @@ function normalizeSchema(input, index) {
135
184
  id,
136
185
  typeName: requiredString(input.typeName, `schemas[${index}].typeName`),
137
186
  root,
187
+ ...(input.jsonLimits !== undefined || schemaHasNestedJson(root)
188
+ ? { jsonLimits: normalizeJsonSchemaLimits(input.jsonLimits) } : {}),
138
189
  source: normalizeSource(input.source, `schemas[${index}].source`)
139
190
  });
140
191
  }
@@ -193,10 +244,19 @@ function normalizeSchemaRegistry(input) {
193
244
  responses,
194
245
  policies: deepFreeze({
195
246
  objectRoots: true,
196
- requiredFieldsOnly: true,
197
- unknownInputFields: 'drop',
198
- outputFields: 'declared-only',
247
+ requiredFieldsOnly: false,
248
+ optionalFields: 'omit-absent-own-properties',
249
+ presentUndefined: 'reject',
250
+ unknownInputFields: 'drop-unless-open',
251
+ outputFields: 'declared-and-admitted-open-properties',
199
252
  outputFieldOrder: 'declaration',
253
+ scalarRecords: 'bounded-own-scalar-properties',
254
+ scalarRecordDuplicateKeys: 'reject-after-unescaping',
255
+ nestedJson: 'bounded-recursive-json-values',
256
+ nestedJsonDuplicateKeys: 'reject-after-unescaping',
257
+ openObjects: 'known-fields-first-then-bounded-json-extras',
258
+ openObjectDuplicateKeys: 'reject-all-names-after-unescaping',
259
+ jsonAdmission: 'whole-document-before-materialization',
200
260
  numericPolicy: 'finite-rfc-json',
201
261
  parity: 'semantic',
202
262
  dynamicIds: false,
@@ -248,7 +308,7 @@ function collectNativeClasses(schema, node, symbols, classes, pathParts = []) {
248
308
  fields: Object.freeze(node.fields.map((field) => Object.freeze({
249
309
  name: field.name,
250
310
  type: assemblyScriptType(field.value, symbols, [...pathParts, field.name]),
251
- required: true,
311
+ required: field.required,
252
312
  constraints: field.value.kind === 'string-enum'
253
313
  ? Object.freeze({ enum: field.value.values })
254
314
  : Object.freeze({})
@@ -256,18 +316,45 @@ function collectNativeClasses(schema, node, symbols, classes, pathParts = []) {
256
316
  }));
257
317
  }
258
318
 
319
+ function schemaHasOptionalProperties(node) {
320
+ if (node.kind === 'nullable') return schemaHasOptionalProperties(node.value);
321
+ if (node.kind === 'array') return schemaHasOptionalProperties(node.element);
322
+ return node.kind === 'object' && node.fields.some(field => !field.required || schemaHasOptionalProperties(field.value));
323
+ }
324
+
325
+ function schemaHasScalarRecord(node) {
326
+ if (node.kind === 'scalar-record') return true;
327
+ if (node.kind === 'nullable') return schemaHasScalarRecord(node.value);
328
+ if (node.kind === 'array') return schemaHasScalarRecord(node.element);
329
+ return node.kind === 'object' && node.fields.some(field => schemaHasScalarRecord(field.value));
330
+ }
331
+
332
+ function schemaNeedsValueProjection(node) {
333
+ return schemaHasOptionalProperties(node) || schemaHasScalarRecord(node) || schemaHasNestedJson(node);
334
+ }
335
+
336
+ function schemaHasNestedJson(node) {
337
+ if (node.kind === 'json-value' || node.kind === 'json-object') return true;
338
+ if (node.kind === 'nullable') return schemaHasNestedJson(node.value);
339
+ if (node.kind === 'array') return schemaHasNestedJson(node.element);
340
+ return node.kind === 'object' && (Boolean(node.additionalProperties) || node.fields.some(field => schemaHasNestedJson(field.value)));
341
+ }
342
+
259
343
  function codecInputsForRegistry(registryInput) {
260
344
  const registry = normalizeSchemaRegistry(registryInput);
261
345
  const nativeSchemas = registry.schemas.map((schema) => {
262
346
  const schemaClasses = [];
263
347
  const symbols = new Map();
264
- collectNativeClasses(schema, schema.root, symbols, schemaClasses);
348
+ const presence = schemaNeedsValueProjection(schema.root) || Boolean(schema.jsonLimits);
349
+ if (!presence) collectNativeClasses(schema, schema.root, symbols, schemaClasses);
350
+ const rootClass = presence ? 'JSON.Value' : symbols.get('');
265
351
  return Object.freeze({
266
352
  id: schema.id,
267
- rootClass: symbols.get(''),
353
+ rootClass,
354
+ representation: presence ? 'schema-projected-json-value' : 'required-struct',
268
355
  classes: Object.freeze(schemaClasses),
269
- parse: `JSON.parse<${symbols.get('')}>`,
270
- stringify: `JSON.stringify<${symbols.get('')}>`
356
+ parse: `JSON.parse<${rootClass}>`,
357
+ stringify: `JSON.stringify<${rootClass}>`
271
358
  });
272
359
  });
273
360
  return deepFreeze({
@@ -325,7 +412,9 @@ function defaultSchemaRegistryContract() {
325
412
  scalarKinds: SCALAR_KINDS,
326
413
  nodeKinds: NODE_KINDS,
327
414
  helpers: ['defineSchemaRegistry', 'schema', 'response'],
328
- markerTypes: ['Int32', 'Uint32'],
415
+ markerTypes: ['Int32', 'Uint32', 'ScalarRecord', 'JsonValue', 'JsonObject', 'OpenObject'],
416
+ scalarRecordLimits: SCALAR_RECORD_LIMITS,
417
+ jsonDefaultLimits: JSON_SCHEMA_DEFAULT_LIMITS,
329
418
  policies: {
330
419
  canonicalEntrypoint: 'pulse.schema',
331
420
  defaultExportRequired: true,
@@ -333,7 +422,11 @@ function defaultSchemaRegistryContract() {
333
422
  stableStringIds: true,
334
423
  exactLiteralIdsAtBoundaries: true,
335
424
  oldSchemasJsonSupported: false,
336
- optionalPropertiesSupported: false,
425
+ optionalPropertiesSupported: true,
426
+ scalarRecordsSupported: true,
427
+ nestedJsonSupported: true,
428
+ typedOpenObjectsSupported: true,
429
+ staticJsonLimitsSupported: true,
337
430
  recursiveSchemasSupported: false,
338
431
  publicJsonAsImportsSupported: false,
339
432
  automaticFallback: false
@@ -350,10 +443,18 @@ module.exports = Object.freeze({
350
443
  RESPONSE_CASE_ID_PATTERN,
351
444
  SCALAR_KINDS,
352
445
  NODE_KINDS,
446
+ SCALAR_RECORD_LIMITS,
447
+ JSON_SCHEMA_DEFAULT_LIMITS,
448
+ normalizeJsonSchemaLimits,
449
+ scalarRecordStringBytes,
353
450
  schemaContractError,
354
451
  normalizeSchemaNode,
355
452
  normalizeSchemaRegistry,
356
453
  codecInputsForRegistry,
454
+ schemaHasOptionalProperties,
455
+ schemaHasScalarRecord,
456
+ schemaHasNestedJson,
457
+ schemaNeedsValueProjection,
357
458
  defaultSchemaBoundaryPolicy,
358
459
  defaultSchemaRegistryContract
359
460
  });
@@ -7,13 +7,14 @@ const JSON_SEMANTIC_TRACE_VERSION = 'pulse.json-semantic-trace.v1';
7
7
  const JSON_TRACE_EVENT_KINDS = Object.freeze([
8
8
  'json.decode.request',
9
9
  'json.decode.fetch',
10
+ 'json.decode.text',
10
11
  'json.encode.response',
11
12
  'json.encode.fetch',
12
13
  'json.encode.value',
13
14
  'json.decode.error',
14
15
  'json.encode.error'
15
16
  ]);
16
- const JSON_BOUNDARIES = Object.freeze(['request', 'fetch-response', 'application-response', 'fetch-request', 'application-value']);
17
+ const JSON_BOUNDARIES = Object.freeze(['request', 'fetch-response', 'application-response', 'fetch-request', 'application-value', 'application-text']);
17
18
  const JSON_BODY_OWNERSHIP = Object.freeze([
18
19
  'request-snapshot',
19
20
  'fetched-response-snapshot',
@@ -23,10 +24,11 @@ const JSON_BODY_OWNERSHIP = Object.freeze([
23
24
  const JSON_EVENT_BOUNDARIES = Object.freeze({
24
25
  'json.decode.request': Object.freeze(['request']),
25
26
  'json.decode.fetch': Object.freeze(['fetch-response']),
27
+ 'json.decode.text': Object.freeze(['application-text']),
26
28
  'json.encode.response': Object.freeze(['application-response']),
27
29
  'json.encode.fetch': Object.freeze(['fetch-request']),
28
30
  'json.encode.value': Object.freeze(['application-value']),
29
- 'json.decode.error': Object.freeze(['request', 'fetch-response']),
31
+ 'json.decode.error': Object.freeze(['request', 'fetch-response', 'application-text']),
30
32
  'json.encode.error': Object.freeze(['application-response', 'fetch-request', 'application-value'])
31
33
  });
32
34