circuitjson-toolkit 1.2.0 → 1.2.1

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
@@ -55,6 +55,12 @@ metadata ownership traversals now support deeply nested, valid ECAD documents
55
55
  up to 256 levels. See the
56
56
  [1.2.0 release notes](docs/release-notes-v1.2.0.md).
57
57
 
58
+ Version 1.2.1 keeps large canonical documents and multi-document projects
59
+ inside explicit worker budgets. Selected extensions may contain up to four
60
+ million structured items, while exact project envelopes receive independent
61
+ per-document, project-metadata, aggregate-byte, and repeated-work limits. See
62
+ the [1.2.1 release notes](docs/release-notes-v1.2.1.md).
63
+
58
64
  Before 1.1.0:
59
65
 
60
66
  ```js
@@ -100,7 +106,7 @@ const model = document.model
100
106
  - Strict `ecad-toolkit.worker.v1` parsing/project protocol with progress,
101
107
  cancellation, and controlled buffer transfer
102
108
  - One-pass ownership for selected source extensions, with a separate 128 MiB /
103
- 2,000,000-item bound and exact direct/worker result parity
109
+ 4,000,000-item bound and exact direct/worker result parity
104
110
  - Machine-readable capability inventory and packed downstream conformance
105
111
  harness
106
112
  - Explicit `/extensions` surface retaining every previous specialized API
@@ -201,11 +207,14 @@ falls back to direct execution only when worker construction is unavailable;
201
207
  explicit worker and runtime failures remain visible.
202
208
 
203
209
  Selected source-native extensions are captured once into an immutable owned
204
- snapshot. Their separate 128 MiB payload and 2,000,000-item ceilings permit
205
- realistic renderer/model graphs without weakening the worker protocol's 250 MB
206
- total-result ceiling; an over-limit extension fails visibly instead of being
207
- silently truncated. Binary extension values remain byte-backed and return
208
- defensive copies instead of expanding into JavaScript number arrays.
210
+ snapshot. Their separate 128 MiB payload and 4,000,000-item ceilings permit
211
+ realistic renderer/model graphs. Canonical standalone documents remain within
212
+ a 250 MB result ceiling, while exact multi-document project envelopes use a
213
+ 256 MiB aggregate ceiling plus independent 250 MB per-document and metadata
214
+ ceilings. Item and repeated-alias work budgets remain separately bounded; an
215
+ over-limit extension or result fails visibly instead of being silently
216
+ truncated. Binary extension values remain byte-backed and return defensive
217
+ copies instead of expanding into JavaScript number arrays.
209
218
  When extensions are disabled, native documents and projects return the exact
210
219
  empty map `{}`. Bounded ZIP consumers can preflight local/central filenames and
211
220
  CRC32/size metadata with
@@ -319,6 +328,7 @@ copy while keeping sync, direct async, and worker results mutation-isolated.
319
328
  - [1.1.1 release notes](docs/release-notes-v1.1.1.md)
320
329
  - [1.1.2 release notes](docs/release-notes-v1.1.2.md)
321
330
  - [1.2.0 release notes](docs/release-notes-v1.2.0.md)
331
+ - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
322
332
  - [Library scope](spec/library-scope.md)
323
333
 
324
334
  ## Package scope
package/docs/api.md CHANGED
@@ -53,12 +53,13 @@ make the common shape smaller.
53
53
 
54
54
  Validated source documents own selected extension values as one deeply
55
55
  immutable, descriptor-safe snapshot. This uses a distinct 128 MiB payload and
56
- 2,000,000-item ceiling so large native renderer graphs do not inherit the
56
+ 4,000,000-item ceiling so large native renderer graphs do not inherit the
57
57
  compact metadata limit. The shared worker protocol applies the same ownership
58
- boundary after structured cloning and retains its 250 MB whole-result ceiling.
59
- Binary values preserve their `ArrayBuffer`/typed-view shape behind defensive
60
- copy access, so payload bytes do not inflate into one JavaScript number per
61
- byte.
58
+ boundary after structured cloning. Canonical standalone documents retain a
59
+ 250 MB byte ceiling; exact multi-document projects use a 256 MiB aggregate
60
+ ceiling with independent 250 MB document and project-metadata limits. Binary
61
+ values preserve their `ArrayBuffer`/typed-view shape behind defensive copy
62
+ access, so payload bytes do not inflate into one JavaScript number per byte.
62
63
 
63
64
  ### Errors
64
65
 
@@ -102,15 +102,19 @@ must not duplicate the CircuitJSON model or keep a renamed renderer model.
102
102
  Default parsing excludes raw/base64/full-payload graphs.
103
103
 
104
104
  When a caller explicitly selects a native extension, the toolkit captures and
105
- freezes that graph once under a distinct ceiling of 2,000,000 structured items
105
+ freezes that graph once under a distinct ceiling of 4,000,000 structured items
106
106
  and 128 MiB of string or binary content. The ceiling is shared across the
107
- selected namespace, applies equally after worker transfer, and remains below
108
- the worker's 250 MB whole-result limit. General document metadata keeps its
109
- smaller budget, so a large native projection cannot consume the canonical
110
- metadata allowance. Oversized graphs are rejected; they are never truncated or
111
- partially retained. Binary extension values remain byte-backed with their
112
- common buffer/view type and are exposed as defensive copies, preserving
113
- mutation isolation without expanding bytes into plain numeric arrays.
107
+ selected namespace and applies equally after worker transfer. Canonical
108
+ standalone documents allow the extension plus envelope overhead within five
109
+ million result values and 250 MB. Exact project envelopes allow eight million
110
+ aggregate values and 256 MiB, while each document stays within five million
111
+ values and 250 MB and non-document project metadata stays within two million
112
+ values and 250 MB. Reused graphs are charged independently to each local scope,
113
+ and repeated accounting work is itself bounded by the aggregate project
114
+ budget. Oversized graphs are rejected; they are never truncated or partially
115
+ retained. Binary extension values remain byte-backed with their common
116
+ buffer/view type and are exposed as defensive copies, preserving mutation
117
+ isolation without expanding bytes into plain numeric arrays.
114
118
 
115
119
  Supported pre-union CircuitJSON aliases may be projected through
116
120
  `CircuitJsonDocument.normalizeModel()`. The copy-on-write form preserves exact
@@ -0,0 +1,26 @@
1
+ # circuitjson-toolkit 1.2.1
2
+
3
+ ## Large canonical worker results
4
+
5
+ This patch release keeps valid high-fidelity documents and multi-document
6
+ projects inside explicit, format-neutral worker limits:
7
+
8
+ - Selected native extensions may contain up to 4,000,000 structured items and
9
+ 128 MiB of string or binary content.
10
+ - A canonical standalone document may contain up to 5,000,000 result values
11
+ within the existing 250 MB byte ceiling.
12
+ - An exact canonical project may contain up to 8,000,000 aggregate values and
13
+ 256 MiB, while every document retains its own 5,000,000-value and 250 MB
14
+ limits.
15
+ - Non-document project metadata retains the generic 2,000,000-value and 250 MB
16
+ limits.
17
+
18
+ Elevated budgets require the complete canonical document or project envelope;
19
+ a schema string alone does not change generic result limits. Reused object
20
+ graphs are charged independently to document and project-metadata scopes in a
21
+ property-order-independent way. Repeated alias-accounting work is also capped
22
+ by the project aggregate budget, keeping transport time bounded.
23
+
24
+ No public class, method, package subpath, parameter, or result field is removed
25
+ or renamed. Oversized results continue to fail visibly rather than being
26
+ truncated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuitjson-toolkit",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Canonical CircuitJSON parsing, project, rendering, query, manufacturing, simulation, and scene contracts",
5
5
  "keywords": [
6
6
  "circuitjson",
@@ -54,6 +54,7 @@
54
54
  "docs/release-notes-v1.1.1.md",
55
55
  "docs/release-notes-v1.1.2.md",
56
56
  "docs/release-notes-v1.2.0.md",
57
+ "docs/release-notes-v1.2.1.md",
57
58
  "docs/testing.md",
58
59
  "spec",
59
60
  "LICENSE",
@@ -13,7 +13,7 @@ const METADATA_BUDGETS = new WeakMap()
13
13
  const EXTENSION_METADATA_LIMITS = Object.freeze({
14
14
  label: 'Canonical extension data',
15
15
  maxBytes: 128 * 1024 * 1024,
16
- maxItems: 2_000_000,
16
+ maxItems: 4_000_000,
17
17
  preserveBinary: true
18
18
  })
19
19
  const ASSET_SCALAR_FIELDS = new Set([
@@ -2,6 +2,7 @@ import { CircuitJsonValidationProof } from '../context/CircuitJsonValidationProo
2
2
  import { CircuitJsonReadOnlyDocument } from '../context/CircuitJsonReadOnlyDocument.mjs'
3
3
  import { ProtectedExtensionBinaryBoundary } from '../context/ProtectedExtensionBinaryBoundary.mjs'
4
4
  import { RuntimeProxyBoundary } from '../contracts/RuntimeProxyBoundary.mjs'
5
+ import { WorkerResultShape } from './WorkerResultShape.mjs'
5
6
 
6
7
  const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(
7
8
  ArrayBuffer.prototype,
@@ -56,6 +57,11 @@ const MAX_REQUEST_DEPTH = 256
56
57
  const MAX_REQUEST_VALUES = 100_000
57
58
  const MAX_RESULT_BYTES = 250_000_000
58
59
  const MAX_RESULT_VALUES = 2_000_000
60
+ const MAX_DOCUMENT_RESULT_VALUES = 5_000_000
61
+ // Project documents retain the single-result ceiling independently while this
62
+ // aggregate ceiling bounds total worker traversal regardless of document count.
63
+ const MAX_PROJECT_RESULT_VALUES = 8_000_000
64
+ const MAX_PROJECT_RESULT_BYTES = 256 * 1024 * 1024
59
65
  const TYPED_ARRAYS = new Map(
60
66
  [
61
67
  Int8Array,
@@ -166,14 +172,28 @@ export class WorkerRequestData {
166
172
  const state = {
167
173
  bytes: 0,
168
174
  copyBinary: limits.copyBinary,
175
+ documentAccounted: null,
176
+ documentBytes: null,
177
+ documentValues: null,
169
178
  maxBytes: limits.bytes,
179
+ maxDocumentBytes: limits.bytes,
180
+ maxDocumentValues: MAX_DOCUMENT_RESULT_VALUES,
181
+ maxUnscopedBytes: limits.bytes,
182
+ maxUnscopedValues: limits.values,
170
183
  maxValues: limits.values,
171
184
  output: limits.output,
172
185
  prepared: new WeakMap(),
186
+ projectDocuments: null,
187
+ projectResult: false,
188
+ reaccountValues: 0,
173
189
  strictDescriptors: limits.strictDescriptors,
174
190
  transfer: { items: [], seen: new Set() },
175
191
  transferInput: limits.transferInput,
176
192
  trustProof: limits.trustProof,
193
+ unscopedAccounted: null,
194
+ unscopedBytes: 0,
195
+ unscopedIncomplete: new WeakSet(),
196
+ unscopedValues: 0,
177
197
  values: 0,
178
198
  visiting: new WeakSet()
179
199
  }
@@ -223,8 +243,26 @@ export class WorkerRequestData {
223
243
  if (state.visiting.has(value)) {
224
244
  throw new TypeError('Worker request data must not be cyclic.')
225
245
  }
226
- if (state.prepared.has(value)) return state.prepared.get(value)
246
+ if (state.prepared.has(value)) {
247
+ const accounted = WorkerRequestData.#accountedScope(state)
248
+ if (accounted && !accounted.has(value)) {
249
+ WorkerRequestData.#accountPreparedValue(value, state, depth)
250
+ } else if (
251
+ accounted === state.unscopedAccounted &&
252
+ state.unscopedIncomplete.delete(value)
253
+ ) {
254
+ // The documents array itself was already charged unscoped.
255
+ WorkerRequestData.#accountPreparedContainer(
256
+ value,
257
+ state,
258
+ depth,
259
+ false
260
+ )
261
+ }
262
+ return state.prepared.get(value)
263
+ }
227
264
  WorkerRequestData.#reserveValues(state, 1)
265
+ WorkerRequestData.#accountedScope(state)?.add(value)
228
266
  state.visiting.add(value)
229
267
  try {
230
268
  const bufferLength = WorkerRequestData.#bufferLength(value)
@@ -305,18 +343,32 @@ export class WorkerRequestData {
305
343
  'Worker requests may contain only plain data objects.'
306
344
  )
307
345
  }
308
- const keys =
309
- state.output && !state.strictDescriptors
310
- ? Reflect.ownKeys(descriptors).filter(
311
- (key) =>
312
- typeof key === 'string' && descriptors[key].enumerable
313
- )
314
- : Reflect.ownKeys(descriptors)
315
- const documentResult =
346
+ const keys = WorkerResultShape.keys(
347
+ descriptors,
348
+ state.output,
349
+ state.strictDescriptors
350
+ )
351
+ const projectResult =
352
+ depth === 0 &&
353
+ state.output &&
354
+ WorkerResultShape.project(descriptors, keys)
355
+ if (projectResult) {
356
+ state.maxBytes = MAX_PROJECT_RESULT_BYTES
357
+ state.maxValues = MAX_PROJECT_RESULT_VALUES
358
+ state.projectResult = true
359
+ state.unscopedAccounted = new WeakSet([value])
360
+ state.unscopedBytes = state.bytes
361
+ state.unscopedValues = state.values
362
+ }
363
+ const canonicalDocumentResult =
364
+ state.output && WorkerResultShape.document(descriptors, keys)
365
+ if (depth === 0 && canonicalDocumentResult) {
366
+ state.maxValues = MAX_DOCUMENT_RESULT_VALUES
367
+ }
368
+ const provenDocumentResult =
316
369
  state.output &&
317
370
  state.trustProof &&
318
- WorkerRequestData.#dataValue(descriptors.schema) ===
319
- 'ecad-toolkit.document.v1' &&
371
+ canonicalDocumentResult &&
320
372
  CircuitJsonValidationProof.has(value)
321
373
  WorkerRequestData.#reserveValues(state, keys.length)
322
374
  const prepared = prototype === null ? Object.create(null) : {}
@@ -330,13 +382,19 @@ export class WorkerRequestData {
330
382
  configurable: true,
331
383
  enumerable: true,
332
384
  value:
333
- documentResult && key === 'model'
334
- ? WorkerRequestData.#documentModel(descriptors[key])
335
- : WorkerRequestData.#descriptorValue(
385
+ projectResult && key === 'documents'
386
+ ? WorkerRequestData.#projectDocuments(
336
387
  descriptors[key],
337
388
  state,
338
389
  depth
339
- ),
390
+ )
391
+ : provenDocumentResult && key === 'model'
392
+ ? WorkerRequestData.#documentModel(descriptors[key])
393
+ : WorkerRequestData.#descriptorValue(
394
+ descriptors[key],
395
+ state,
396
+ depth
397
+ ),
340
398
  writable: true
341
399
  })
342
400
  }
@@ -353,40 +411,113 @@ export class WorkerRequestData {
353
411
  * @returns {any[]} Prepared array.
354
412
  */
355
413
  static #prepareArray(value, prototype, descriptors, state, depth) {
356
- const keys = Reflect.ownKeys(descriptors)
357
- const length = WorkerRequestData.#dataValue(descriptors.length)
358
- const visibleKeys =
359
- state.output && !state.strictDescriptors
360
- ? keys.filter(
361
- (key) =>
362
- key !== 'length' &&
363
- typeof key === 'string' &&
364
- descriptors[key].enumerable
414
+ const length = WorkerResultShape.arrayLength(
415
+ prototype,
416
+ descriptors,
417
+ state.output,
418
+ state.strictDescriptors
419
+ )
420
+ WorkerRequestData.#reserveValues(state, length)
421
+ const prepared = new Array(length)
422
+ state.prepared.set(value, prepared)
423
+ const projectDocuments = state.projectDocuments === value
424
+ for (let index = 0; index < length; index += 1) {
425
+ prepared[index] = projectDocuments
426
+ ? WorkerRequestData.#projectDocument(
427
+ descriptors[String(index)],
428
+ state,
429
+ depth
365
430
  )
366
- : keys
367
- if (
368
- prototype !== Array.prototype ||
369
- !Number.isSafeInteger(length) ||
370
- length < 0 ||
371
- (state.output && !state.strictDescriptors
372
- ? visibleKeys.length !== length
373
- : keys.length !== length + 1)
374
- ) {
431
+ : WorkerRequestData.#descriptorValue(
432
+ descriptors[String(index)],
433
+ state,
434
+ depth
435
+ )
436
+ }
437
+ return prepared
438
+ }
439
+
440
+ /** @param {PropertyDescriptor | undefined} descriptor Descriptor. @param {Record<string, any>} state State. @param {number} depth Depth. @returns {unknown} Documents. */
441
+ static #projectDocuments(descriptor, state, depth) {
442
+ const value = WorkerRequestData.#descriptorDataValue(descriptor, state)
443
+ const reused = state.prepared.has(value)
444
+ state.projectDocuments = value
445
+ try {
446
+ const prepared = WorkerRequestData.#prepareValue(
447
+ value,
448
+ state,
449
+ depth + 1
450
+ )
451
+ if (reused) {
452
+ WorkerRequestData.#accountProjectDocuments(
453
+ value,
454
+ state,
455
+ depth + 1
456
+ )
457
+ } else {
458
+ // Descendants were charged only to their document scopes.
459
+ state.unscopedIncomplete.add(value)
460
+ }
461
+ return prepared
462
+ } finally {
463
+ state.projectDocuments = null
464
+ }
465
+ }
466
+
467
+ /** @param {object} value Documents. @param {Record<string, any>} state State. @param {number} depth Depth. @returns {void} */
468
+ static #accountProjectDocuments(value, state, depth) {
469
+ let prototype
470
+ let descriptors
471
+ try {
472
+ prototype = Object.getPrototypeOf(value)
473
+ descriptors = Object.getOwnPropertyDescriptors(value)
474
+ } catch {
375
475
  throw new TypeError(
376
- 'Worker request arrays must be bounded, dense, and plain.'
476
+ 'Worker request data could not be inspected safely.'
377
477
  )
378
478
  }
379
- WorkerRequestData.#reserveValues(state, length)
380
- const prepared = new Array(length)
381
- state.prepared.set(value, prepared)
479
+ const length = WorkerResultShape.arrayLength(
480
+ prototype,
481
+ descriptors,
482
+ state.output,
483
+ state.strictDescriptors
484
+ )
382
485
  for (let index = 0; index < length; index += 1) {
383
- prepared[index] = WorkerRequestData.#descriptorValue(
486
+ WorkerRequestData.#projectDocument(
384
487
  descriptors[String(index)],
385
488
  state,
386
489
  depth
387
490
  )
388
491
  }
389
- return prepared
492
+ }
493
+
494
+ /**
495
+ * Prepares one project document under the normal single-result ceiling.
496
+ * @param {PropertyDescriptor | undefined} descriptor Document descriptor.
497
+ * @param {Record<string, any>} state Traversal state.
498
+ * @param {number} depth Parent depth.
499
+ * @returns {unknown} Prepared document.
500
+ */
501
+ static #projectDocument(descriptor, state, depth) {
502
+ const previousMaxDocumentValues = state.maxDocumentValues
503
+ const document = WorkerRequestData.#dataValue(descriptor)
504
+ state.maxDocumentValues = WorkerResultShape.documentCandidate(
505
+ document,
506
+ state.strictDescriptors
507
+ )
508
+ ? MAX_DOCUMENT_RESULT_VALUES
509
+ : state.maxUnscopedValues
510
+ state.documentAccounted = new WeakSet()
511
+ state.documentBytes = 0
512
+ state.documentValues = 0
513
+ try {
514
+ return WorkerRequestData.#descriptorValue(descriptor, state, depth)
515
+ } finally {
516
+ state.documentAccounted = null
517
+ state.documentBytes = null
518
+ state.documentValues = null
519
+ state.maxDocumentValues = previousMaxDocumentValues
520
+ }
390
521
  }
391
522
 
392
523
  /**
@@ -397,24 +528,30 @@ export class WorkerRequestData {
397
528
  * @returns {unknown} Prepared value.
398
529
  */
399
530
  static #descriptorValue(descriptor, state, depth) {
531
+ return WorkerRequestData.#prepareValue(
532
+ WorkerRequestData.#descriptorDataValue(descriptor, state),
533
+ state,
534
+ depth + 1
535
+ )
536
+ }
537
+
538
+ /**
539
+ * Reads one enumerable descriptor through trusted internal boundaries only.
540
+ * @param {PropertyDescriptor | undefined} descriptor Descriptor candidate.
541
+ * @param {Record<string, any>} state Traversal state.
542
+ * @returns {unknown} Descriptor data value.
543
+ */
544
+ static #descriptorDataValue(descriptor, state) {
400
545
  if (state.trustProof) {
401
546
  const protectedData =
402
547
  CircuitJsonReadOnlyDocument.readProtectedAssetData(descriptor)
403
548
  if (protectedData.trusted) {
404
- return WorkerRequestData.#prepareValue(
405
- protectedData.value,
406
- state,
407
- depth + 1
408
- )
549
+ return protectedData.value
409
550
  }
410
551
  const protectedExtensionBinary =
411
552
  ProtectedExtensionBinaryBoundary.read(descriptor)
412
553
  if (protectedExtensionBinary.trusted) {
413
- return WorkerRequestData.#prepareValue(
414
- protectedExtensionBinary.value,
415
- state,
416
- depth + 1
417
- )
554
+ return protectedExtensionBinary.value
418
555
  }
419
556
  }
420
557
  if (
@@ -426,8 +563,138 @@ export class WorkerRequestData {
426
563
  'Worker requests may contain only enumerable data properties.'
427
564
  )
428
565
  }
429
- return WorkerRequestData.#prepareValue(
430
- descriptor.value,
566
+ return descriptor.value
567
+ }
568
+
569
+ /**
570
+ * Charges one previously prepared graph to the active project document.
571
+ * @param {unknown} value Reused prepared value.
572
+ * @param {Record<string, any>} state Traversal state.
573
+ * @param {number} depth Container depth.
574
+ * @returns {void}
575
+ */
576
+ static #accountPreparedValue(value, state, depth) {
577
+ const type = typeof value
578
+ if (
579
+ value === null ||
580
+ ['undefined', 'boolean', 'number', 'bigint'].includes(type)
581
+ ) {
582
+ return
583
+ }
584
+ if (type === 'string') {
585
+ WorkerRequestData.#reserveAccountedBytes(state, value.length * 2)
586
+ return
587
+ }
588
+ if (type !== 'object') {
589
+ throw new TypeError(
590
+ 'Worker requests may contain only clone-safe data.'
591
+ )
592
+ }
593
+ RuntimeProxyBoundary.assert(value, 'Worker request data')
594
+ if (depth > MAX_REQUEST_DEPTH) {
595
+ throw new TypeError('Worker request data is nested too deeply.')
596
+ }
597
+ const accounted = WorkerRequestData.#accountedScope(state)
598
+ if (accounted.has(value)) return
599
+ accounted.add(value)
600
+ WorkerRequestData.#reserveAccountedValues(state, 1)
601
+ const bufferLength = WorkerRequestData.#bufferLength(value)
602
+ if (bufferLength !== null) {
603
+ WorkerRequestData.#reserveAccountedBytes(state, bufferLength)
604
+ return
605
+ }
606
+ const sharedLength = WorkerRequestData.#sharedBufferLength(value)
607
+ if (sharedLength !== null) {
608
+ WorkerRequestData.#reserveAccountedBytes(state, sharedLength)
609
+ return
610
+ }
611
+ const view = WorkerRequestData.#view(value)
612
+ if (view) {
613
+ WorkerRequestData.#reserveAccountedBytes(state, view.byteLength)
614
+ return
615
+ }
616
+ WorkerRequestData.#accountPreparedContainer(value, state, depth)
617
+ }
618
+
619
+ /** @param {object} value Container. @param {Record<string, any>} state State. @param {number} depth Depth. @param {boolean} [accountEntries] Whether to charge its own entries. @returns {void} */
620
+ static #accountPreparedContainer(
621
+ value,
622
+ state,
623
+ depth,
624
+ accountEntries = true
625
+ ) {
626
+ let prototype
627
+ let descriptors
628
+ try {
629
+ prototype = Object.getPrototypeOf(value)
630
+ descriptors = Object.getOwnPropertyDescriptors(value)
631
+ } catch {
632
+ throw new TypeError(
633
+ 'Worker request data could not be inspected safely.'
634
+ )
635
+ }
636
+ if (Array.isArray(value)) {
637
+ const length = WorkerResultShape.arrayLength(
638
+ prototype,
639
+ descriptors,
640
+ state.output,
641
+ state.strictDescriptors
642
+ )
643
+ if (accountEntries) {
644
+ WorkerRequestData.#reserveAccountedValues(state, length)
645
+ }
646
+ for (let index = 0; index < length; index += 1) {
647
+ WorkerRequestData.#accountDescriptor(
648
+ descriptors[String(index)],
649
+ state,
650
+ depth
651
+ )
652
+ }
653
+ return
654
+ }
655
+ if (prototype !== Object.prototype && prototype !== null) {
656
+ throw new TypeError(
657
+ 'Worker requests may contain only plain data objects.'
658
+ )
659
+ }
660
+ const keys = WorkerResultShape.keys(
661
+ descriptors,
662
+ state.output,
663
+ state.strictDescriptors
664
+ )
665
+ const provenDocumentResult =
666
+ state.output &&
667
+ state.trustProof &&
668
+ WorkerResultShape.document(descriptors, keys) &&
669
+ CircuitJsonValidationProof.has(value)
670
+ if (accountEntries) {
671
+ WorkerRequestData.#reserveAccountedValues(state, keys.length)
672
+ }
673
+ for (const key of keys) {
674
+ if (typeof key !== 'string') {
675
+ throw new TypeError('Worker request keys must be strings.')
676
+ }
677
+ WorkerRequestData.#reserveAccountedBytes(state, key.length * 2)
678
+ if (!(provenDocumentResult && key === 'model')) {
679
+ WorkerRequestData.#accountDescriptor(
680
+ descriptors[key],
681
+ state,
682
+ depth
683
+ )
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Charges one reused descriptor to the active document.
690
+ * @param {PropertyDescriptor | undefined} descriptor Descriptor candidate.
691
+ * @param {Record<string, any>} state Traversal state.
692
+ * @param {number} depth Parent depth.
693
+ * @returns {void}
694
+ */
695
+ static #accountDescriptor(descriptor, state, depth) {
696
+ WorkerRequestData.#accountPreparedValue(
697
+ WorkerRequestData.#descriptorDataValue(descriptor, state),
431
698
  state,
432
699
  depth + 1
433
700
  )
@@ -624,27 +891,106 @@ export class WorkerRequestData {
624
891
  transfer.items.push(value)
625
892
  }
626
893
 
627
- /** @param {{ bytes: number }} state State. @param {number} count Bytes. */
894
+ /** @param {Record<string, any>} state State. @returns {WeakSet<object> | null} Active identities. */
895
+ static #accountedScope(state) {
896
+ if (state.documentAccounted) return state.documentAccounted
897
+ return state.projectResult ? state.unscopedAccounted : null
898
+ }
899
+
900
+ /** @param {{ documentBytes: number | null, maxDocumentBytes: number, maxUnscopedBytes: number, projectResult: boolean, unscopedBytes: number }} state State. @param {number} count Bytes. */
901
+ static #reserveAccountedBytes(state, count) {
902
+ const invalid = !Number.isSafeInteger(count) || count < 0
903
+ if (state.documentBytes !== null) {
904
+ if (
905
+ invalid ||
906
+ state.documentBytes + count > state.maxDocumentBytes
907
+ ) {
908
+ throw new TypeError(
909
+ 'Worker request data exceeds its byte limit.'
910
+ )
911
+ }
912
+ state.documentBytes += count
913
+ return
914
+ }
915
+ if (
916
+ invalid ||
917
+ !state.projectResult ||
918
+ state.unscopedBytes + count > state.maxUnscopedBytes
919
+ ) {
920
+ throw new TypeError('Worker request data exceeds its byte limit.')
921
+ }
922
+ state.unscopedBytes += count
923
+ }
924
+
925
+ /** @param {{ documentValues: number | null, maxDocumentValues: number, maxUnscopedValues: number, maxValues: number, projectResult: boolean, reaccountValues: number, unscopedValues: number }} state State. @param {number} count Values. */
926
+ static #reserveAccountedValues(state, count) {
927
+ const invalid =
928
+ !Number.isSafeInteger(count) ||
929
+ count < 0 ||
930
+ state.reaccountValues + count > state.maxValues
931
+ if (state.documentValues !== null) {
932
+ if (
933
+ invalid ||
934
+ state.documentValues + count > state.maxDocumentValues
935
+ ) {
936
+ throw new TypeError('Worker request data is too large.')
937
+ }
938
+ state.documentValues += count
939
+ state.reaccountValues += count
940
+ return
941
+ }
942
+ if (
943
+ invalid ||
944
+ !state.projectResult ||
945
+ state.unscopedValues + count > state.maxUnscopedValues
946
+ ) {
947
+ throw new TypeError('Worker request data is too large.')
948
+ }
949
+ state.unscopedValues += count
950
+ state.reaccountValues += count
951
+ }
952
+
953
+ /** @param {{ bytes: number, documentBytes: number | null, maxBytes: number, maxDocumentBytes: number, maxUnscopedBytes: number, projectResult: boolean, unscopedBytes: number }} state State. @param {number} count Bytes. */
628
954
  static #reserveBytes(state, count) {
955
+ const documentBytes = state.documentBytes
629
956
  if (
630
957
  !Number.isSafeInteger(count) ||
631
958
  count < 0 ||
632
- state.bytes + count > state.maxBytes
959
+ state.bytes + count > state.maxBytes ||
960
+ (documentBytes !== null
961
+ ? documentBytes + count > state.maxDocumentBytes
962
+ : state.projectResult &&
963
+ state.unscopedBytes + count > state.maxUnscopedBytes)
633
964
  ) {
634
965
  throw new TypeError('Worker request data exceeds its byte limit.')
635
966
  }
636
967
  state.bytes += count
968
+ if (documentBytes !== null) {
969
+ state.documentBytes += count
970
+ } else if (state.projectResult) {
971
+ state.unscopedBytes += count
972
+ }
637
973
  }
638
974
 
639
- /** @param {{ values: number }} state State. @param {number} count Values. */
975
+ /** @param {{ documentValues: number | null, maxDocumentValues: number, maxUnscopedValues: number, maxValues: number, projectResult: boolean, unscopedValues: number, values: number }} state State. @param {number} count Values. */
640
976
  static #reserveValues(state, count) {
977
+ const documentValues = state.documentValues
641
978
  if (
642
979
  !Number.isSafeInteger(count) ||
643
980
  count < 0 ||
644
- state.values + count > state.maxValues
981
+ state.values + count > state.maxValues ||
982
+ (documentValues !== null
983
+ ? documentValues + count > state.maxDocumentValues
984
+ : state.projectResult &&
985
+ state.unscopedValues + count > state.maxUnscopedValues)
645
986
  ) {
646
987
  throw new TypeError('Worker request data is too large.')
647
988
  }
648
989
  state.values += count
990
+ if (documentValues !== null) {
991
+ state.documentValues += count
992
+ } else if (state.projectResult) {
993
+ state.unscopedValues += count
994
+ }
649
995
  }
650
996
  }
@@ -0,0 +1,222 @@
1
+ import { RuntimeProxyBoundary } from '../contracts/RuntimeProxyBoundary.mjs'
2
+
3
+ const DOCUMENT_FIELDS = Object.freeze([
4
+ 'schema',
5
+ 'id',
6
+ 'modelSchema',
7
+ 'model',
8
+ 'source',
9
+ 'extensions',
10
+ 'assets',
11
+ 'diagnostics',
12
+ 'statistics'
13
+ ])
14
+ const PROJECT_FIELDS = Object.freeze([
15
+ 'schema',
16
+ 'id',
17
+ 'source',
18
+ 'documents',
19
+ 'project',
20
+ 'extensions',
21
+ 'assets',
22
+ 'diagnostics',
23
+ 'statistics'
24
+ ])
25
+
26
+ /**
27
+ * Classifies canonical result envelopes through data descriptors only.
28
+ */
29
+ export class WorkerResultShape {
30
+ /**
31
+ * Selects the exact property set traversed for one result container.
32
+ * @param {Record<string, PropertyDescriptor>} descriptors Descriptors.
33
+ * @param {boolean} output Whether result-output rules apply.
34
+ * @param {boolean} strictDescriptors Whether hidden properties are traversed.
35
+ * @returns {PropertyKey[]} Traversed keys.
36
+ */
37
+ static keys(descriptors, output, strictDescriptors) {
38
+ return output && !strictDescriptors
39
+ ? Reflect.ownKeys(descriptors).filter(
40
+ (key) =>
41
+ typeof key === 'string' && descriptors[key].enumerable
42
+ )
43
+ : Reflect.ownKeys(descriptors)
44
+ }
45
+
46
+ /**
47
+ * Validates a dense plain array and returns its captured length.
48
+ * @param {object | null} prototype Array prototype.
49
+ * @param {Record<string, PropertyDescriptor>} descriptors Descriptors.
50
+ * @param {boolean} output Whether result-output rules apply.
51
+ * @param {boolean} strictDescriptors Whether hidden properties are traversed.
52
+ * @returns {number} Dense array length.
53
+ */
54
+ static arrayLength(prototype, descriptors, output, strictDescriptors) {
55
+ const keys = Reflect.ownKeys(descriptors)
56
+ const length = WorkerResultShape.#value(descriptors.length)
57
+ const visibleKeys =
58
+ output && !strictDescriptors
59
+ ? keys.filter(
60
+ (key) =>
61
+ key !== 'length' &&
62
+ typeof key === 'string' &&
63
+ descriptors[key].enumerable
64
+ )
65
+ : keys
66
+ if (
67
+ prototype !== Array.prototype ||
68
+ !Number.isSafeInteger(length) ||
69
+ length < 0 ||
70
+ (output && !strictDescriptors
71
+ ? visibleKeys.length !== length
72
+ : keys.length !== length + 1)
73
+ ) {
74
+ throw new TypeError(
75
+ 'Worker request arrays must be bounded, dense, and plain.'
76
+ )
77
+ }
78
+ return length
79
+ }
80
+
81
+ /**
82
+ * Tests one already-inspected canonical document envelope.
83
+ * @param {Record<string, PropertyDescriptor>} descriptors Descriptors.
84
+ * @param {PropertyKey[]} keys Traversed keys.
85
+ * @returns {boolean} Whether the exact structural document shape matches.
86
+ */
87
+ static document(descriptors, keys) {
88
+ if (
89
+ !WorkerResultShape.#exactDataFields(
90
+ descriptors,
91
+ keys,
92
+ DOCUMENT_FIELDS
93
+ )
94
+ ) {
95
+ return false
96
+ }
97
+ return (
98
+ WorkerResultShape.#value(descriptors.schema) ===
99
+ 'ecad-toolkit.document.v1' &&
100
+ typeof WorkerResultShape.#value(descriptors.id) === 'string' &&
101
+ WorkerResultShape.#record(
102
+ WorkerResultShape.#value(descriptors.modelSchema)
103
+ ) &&
104
+ Array.isArray(WorkerResultShape.#value(descriptors.model)) &&
105
+ WorkerResultShape.#record(
106
+ WorkerResultShape.#value(descriptors.source)
107
+ ) &&
108
+ WorkerResultShape.#record(
109
+ WorkerResultShape.#value(descriptors.extensions)
110
+ ) &&
111
+ Array.isArray(WorkerResultShape.#value(descriptors.assets)) &&
112
+ Array.isArray(WorkerResultShape.#value(descriptors.diagnostics)) &&
113
+ WorkerResultShape.#record(
114
+ WorkerResultShape.#value(descriptors.statistics)
115
+ )
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Tests one already-inspected canonical project envelope.
121
+ * @param {Record<string, PropertyDescriptor>} descriptors Descriptors.
122
+ * @param {PropertyKey[]} keys Traversed keys.
123
+ * @returns {boolean} Whether the exact structural project shape matches.
124
+ */
125
+ static project(descriptors, keys) {
126
+ if (
127
+ !WorkerResultShape.#exactDataFields(
128
+ descriptors,
129
+ keys,
130
+ PROJECT_FIELDS
131
+ )
132
+ ) {
133
+ return false
134
+ }
135
+ const project = WorkerResultShape.#value(descriptors.project)
136
+ return (
137
+ WorkerResultShape.#value(descriptors.schema) ===
138
+ 'ecad-toolkit.project.v1' &&
139
+ typeof WorkerResultShape.#value(descriptors.id) === 'string' &&
140
+ WorkerResultShape.#record(
141
+ WorkerResultShape.#value(descriptors.source)
142
+ ) &&
143
+ Array.isArray(WorkerResultShape.#value(descriptors.documents)) &&
144
+ (project === null || WorkerResultShape.#record(project)) &&
145
+ WorkerResultShape.#record(
146
+ WorkerResultShape.#value(descriptors.extensions)
147
+ ) &&
148
+ Array.isArray(WorkerResultShape.#value(descriptors.assets)) &&
149
+ Array.isArray(WorkerResultShape.#value(descriptors.diagnostics)) &&
150
+ WorkerResultShape.#record(
151
+ WorkerResultShape.#value(descriptors.statistics)
152
+ )
153
+ )
154
+ }
155
+
156
+ /**
157
+ * Inspects a nested project document without reading accessor values.
158
+ * @param {unknown} value Candidate document.
159
+ * @param {boolean} strictDescriptors Whether hidden properties are traversed.
160
+ * @returns {boolean} Whether the exact structural document shape matches.
161
+ */
162
+ static documentCandidate(value, strictDescriptors) {
163
+ if (!WorkerResultShape.#record(value)) return false
164
+ RuntimeProxyBoundary.assert(value, 'Worker request data')
165
+ let prototype
166
+ let descriptors
167
+ try {
168
+ prototype = Object.getPrototypeOf(value)
169
+ descriptors = Object.getOwnPropertyDescriptors(value)
170
+ } catch {
171
+ return false
172
+ }
173
+ if (prototype !== Object.prototype && prototype !== null) return false
174
+ const keys = WorkerResultShape.keys(
175
+ descriptors,
176
+ true,
177
+ strictDescriptors
178
+ )
179
+ return WorkerResultShape.document(descriptors, keys)
180
+ }
181
+
182
+ /**
183
+ * Requires the exact enumerable own data field set.
184
+ * @param {Record<string, PropertyDescriptor>} descriptors Descriptors.
185
+ * @param {PropertyKey[]} keys Traversed keys.
186
+ * @param {string[]} fields Expected fields.
187
+ * @returns {boolean} Whether the field set matches.
188
+ */
189
+ static #exactDataFields(descriptors, keys, fields) {
190
+ if (
191
+ keys.length !== fields.length ||
192
+ keys.some((key) => typeof key !== 'string' || !fields.includes(key))
193
+ ) {
194
+ return false
195
+ }
196
+ return fields.every((field) => {
197
+ const descriptor = descriptors[field]
198
+ return Boolean(
199
+ descriptor &&
200
+ descriptor.enumerable === true &&
201
+ Object.hasOwn(descriptor, 'value')
202
+ )
203
+ })
204
+ }
205
+
206
+ /** @param {unknown} value Candidate. @returns {boolean} Whether value is record-shaped. */
207
+ static #record(value) {
208
+ return (
209
+ value !== null && typeof value === 'object' && !Array.isArray(value)
210
+ )
211
+ }
212
+
213
+ /** @param {PropertyDescriptor | undefined} descriptor Descriptor. @returns {unknown} Data value. */
214
+ static #value(descriptor) {
215
+ return descriptor && Object.hasOwn(descriptor, 'value')
216
+ ? descriptor.value
217
+ : undefined
218
+ }
219
+ }
220
+
221
+ Object.freeze(WorkerResultShape.prototype)
222
+ Object.freeze(WorkerResultShape)