@forgeax/engine-ecs 0.1.23 → 0.1.25

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.
Files changed (42) hide show
  1. package/README.md +42 -1
  2. package/dist/buffer-pool.d.ts +4 -5
  3. package/dist/buffer-pool.d.ts.map +1 -1
  4. package/dist/component.d.ts +9 -6
  5. package/dist/component.d.ts.map +1 -1
  6. package/dist/errors/query-and-component-errors.d.ts +1 -1
  7. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  8. package/dist/externalization/index.mjs.map +1 -1
  9. package/dist/index.mjs +597 -152
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/internal.d.ts +3 -1
  12. package/dist/internal.d.ts.map +1 -1
  13. package/dist/internal.mjs +6 -1
  14. package/dist/internal.mjs.map +1 -1
  15. package/dist/projection/index.mjs.map +1 -1
  16. package/dist/query/derived-range-writer.d.ts +14 -0
  17. package/dist/query/derived-range-writer.d.ts.map +1 -1
  18. package/dist/query/query.d.ts.map +1 -1
  19. package/dist/shared.mjs.map +1 -1
  20. package/dist/world-component-access.d.ts +63 -18
  21. package/dist/world-component-access.d.ts.map +1 -1
  22. package/dist/world-component-storage.d.ts +26 -10
  23. package/dist/world-component-storage.d.ts.map +1 -1
  24. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  25. package/dist/world-internal.d.ts +1 -1
  26. package/dist/world-internal.d.ts.map +1 -1
  27. package/dist/world.d.ts +5 -0
  28. package/dist/world.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/__tests__/archetype.unit.test.ts +38 -10
  31. package/src/__tests__/relationship-index.test.ts +147 -1
  32. package/src/buffer-pool.ts +47 -74
  33. package/src/component.ts +9 -6
  34. package/src/errors/query-and-component-errors.ts +4 -1
  35. package/src/internal.ts +3 -0
  36. package/src/query/derived-range-writer.ts +111 -0
  37. package/src/query/query.ts +26 -1
  38. package/src/world-component-access.ts +427 -127
  39. package/src/world-component-storage.ts +104 -18
  40. package/src/world-entity-lifecycle.ts +49 -9
  41. package/src/world-internal.ts +5 -0
  42. package/src/world.ts +52 -3
@@ -71,6 +71,22 @@ import { ComponentStorage } from './world-component-storage';
71
71
 
72
72
  type ErrorContext = { readonly systemName: string };
73
73
 
74
+ /**
75
+ * Prepared target-side work for one relationship source write.
76
+ *
77
+ * A reservation is deliberately kept outside the World columns until the
78
+ * source operation is ready to commit. That lets direct, spawn, and deferred
79
+ * source writes observe BufferPool failures before adding a mirror component
80
+ * or advancing any ECS epoch.
81
+ */
82
+ interface RelationshipPreparation {
83
+ readonly target: EntityHandle;
84
+ readonly mirror: Component;
85
+ readonly fieldName: string;
86
+ readonly mirrorPresent: boolean;
87
+ reservedSlotId: number | undefined;
88
+ }
89
+
74
90
  type ArrayFieldsOf<S extends ComponentSchema> = {
75
91
  [K in keyof S]: S[K] extends
76
92
  | `array<${ManagedArrayElementType}>`
@@ -96,6 +112,15 @@ function relationshipPayloadWrites(data: Readonly<Record<string, unknown>>): boo
96
112
  });
97
113
  }
98
114
 
115
+ /**
116
+ * Relationship target arrays are public read-only projections. Keep the
117
+ * detached-copy rule at the World.get boundary while internal relationship
118
+ * owners continue to borrow the live storage through `_getArrayView`.
119
+ */
120
+ function detachRelationshipTargetArray(value: unknown): unknown {
121
+ return value instanceof Uint32Array ? value.slice() : value;
122
+ }
123
+
99
124
  export interface ComponentAccessState {
100
125
  readonly graph: ArchetypeGraph;
101
126
  readonly records: EntityRecord[];
@@ -161,16 +186,19 @@ export class WorldComponentAccess {
161
186
  return index;
162
187
  }
163
188
 
164
- /** Read the World-owned materialized target array; never consults a shadow list. */
189
+ /**
190
+ * Read the World-owned materialized target array without a public snapshot.
191
+ * This is an internal relationship-owner path: it borrows the live
192
+ * `Uint32Array` so attach/detach stays zero-copy. Public `World.get` detaches
193
+ * target arrays before returning them to callers.
194
+ */
165
195
  relationshipTargetEntries(source: Component, target: EntityHandle): readonly EntityHandle[] {
166
196
  const role = relationshipRole(source);
167
197
  if (role?.kind !== 'source') return [];
168
198
  const mirror = relationshipMirror(source);
169
199
  if (mirror === undefined) return [];
170
- const result = this.get(target, mirror);
171
- if (!result.ok) return [];
172
- const entries = (result.value as Record<string, unknown>)[role.targetField];
173
- return entries !== undefined && typeof entries === 'object' ? (entries as EntityHandle[]) : [];
200
+ const entries = this._getArrayView(target, mirror, role.targetField);
201
+ return entries === undefined ? [] : (entries as unknown as readonly EntityHandle[]);
174
202
  }
175
203
 
176
204
  private markComponentAdded(entity: EntityHandle, component: Component): void {
@@ -270,9 +298,10 @@ export class WorldComponentAccess {
270
298
  const targetRecord = this.records[entityIndex(target)];
271
299
  const actualGeneration = targetRecord?.generation ?? -1;
272
300
  const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
273
- const holderIsPending =
274
- holder === null || pendingEntities?.has(holder as unknown as number) === true;
275
- if (!targetIsPending && !targetLive && !holderIsPending) {
301
+ // A source edge is never allowed to publish a dangling target. Pending
302
+ // targets are the only exception, and are admitted only for the deferred
303
+ // command batch that reserved that exact handle.
304
+ if (!targetIsPending && !targetLive) {
276
305
  return err(
277
306
  new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
278
307
  operation: 'relationship-insert',
@@ -347,11 +376,18 @@ export class WorldComponentAccess {
347
376
  }
348
377
  }
349
378
 
350
- /** Prepare the target side before a source archetype mutation commits. */
351
- private prepareRelationshipInsert(
379
+ /**
380
+ * Reserve target-side relationship capacity without touching World columns.
381
+ *
382
+ * A missing mirror reserves its first slot before the mirror archetype is
383
+ * created. An existing mirror either reserves an empty slot or grows its
384
+ * existing BufferPool slot; both failure paths return before any component,
385
+ * relationship index, or ECS epoch changes.
386
+ */
387
+ prepareRelationshipInsert(
352
388
  component: Component,
353
389
  value: Record<string, unknown>,
354
- ): Result<void, EcsError> {
390
+ ): Result<RelationshipPreparation | undefined, EcsError> {
355
391
  const role = relationshipRole(component);
356
392
  if (role?.kind !== 'source') return ok(undefined);
357
393
  const target = this.relationshipTargetEntity(component, value);
@@ -371,19 +407,90 @@ export class WorldComponentAccess {
371
407
  );
372
408
  }
373
409
  const targetArch = this.graph.archetypes[targetRec.archetypeId];
374
- const hasMirror =
375
- targetArch?.components.some((candidate) => componentId(candidate) === componentId(mirror)) ??
376
- false;
377
- if (!hasMirror) {
378
- const added = this._addComponentCore(
379
- target,
380
- { component: mirror, data: {} as Partial<ShapeOf<ComponentSchema>> },
381
- true,
410
+ const mirrorLocalId = componentId(mirror);
411
+ const mirrorPresent =
412
+ targetArch?.components.some((candidate) => componentId(candidate) === mirrorLocalId) ?? false;
413
+ const fieldName = role.targetField;
414
+ const arrayMeta = componentDefinition(mirror).fields[fieldName]?.arrayMeta;
415
+ if (arrayMeta === undefined) {
416
+ return err(new ComponentNotPresentError(target as number, mirror.name));
417
+ }
418
+ const meta = TYPE_METADATA[arrayMeta.elementType];
419
+ if (meta?.byteSize === undefined) {
420
+ return err(new ComponentNotPresentError(target as number, mirror.name));
421
+ }
422
+ let currentLength = 0;
423
+ let slotId = 0;
424
+ if (mirrorPresent) {
425
+ currentLength = this._getArrayLength(target, mirror, fieldName) ?? 0;
426
+ const fieldCols = this.table(targetArch as Archetype).storage.get(mirrorLocalId)?.fields;
427
+ const column = fieldCols?.get(fieldName);
428
+ if (column === undefined) {
429
+ return err(new ComponentNotPresentError(target as number, mirror.name));
430
+ }
431
+ const row = this.tableRow(targetRec);
432
+ slotId = column.view[row] as number;
433
+ }
434
+ const requiredBytes = (currentLength + 1) * meta.byteSize;
435
+ if (!Number.isSafeInteger(requiredBytes)) {
436
+ return err(new ManagedBufferOutOfBoundsError(requiredBytes, Number.MAX_SAFE_INTEGER));
437
+ }
438
+
439
+ const preparation: RelationshipPreparation = {
440
+ target,
441
+ mirror,
442
+ fieldName,
443
+ mirrorPresent,
444
+ reservedSlotId: undefined,
445
+ };
446
+ if (!mirrorPresent || slotId === 0) {
447
+ const allocated = this.bufferPool.alloc(requiredBytes);
448
+ if (!allocated.ok) return allocated;
449
+ preparation.reservedSlotId = allocated.value.id;
450
+ } else if (this.bufferPool.view(slotId).byteLength < requiredBytes) {
451
+ const grown = this.bufferPool.grow(slotId, requiredBytes);
452
+ if (!grown.ok) return grown;
453
+ }
454
+ return ok(preparation);
455
+ }
456
+
457
+ /** Release a target-capacity reservation that did not reach commit. */
458
+ releaseRelationshipPreparation(preparation: RelationshipPreparation | undefined): void {
459
+ if (preparation === undefined || preparation.reservedSlotId === undefined) return;
460
+ const slotId = preparation.reservedSlotId;
461
+ this.bufferPool.release(slotId);
462
+ preparation.reservedSlotId = undefined;
463
+ }
464
+
465
+ /** Install a reserved slot after the target mirror archetype exists. */
466
+ private installRelationshipPreparation(
467
+ preparation: RelationshipPreparation,
468
+ ): Result<void, EcsError> {
469
+ const record = this.lookupAlive(
470
+ preparation.target,
471
+ 'relationship-capacity',
472
+ preparation.mirror.name,
473
+ );
474
+ if (!record.ok) return record;
475
+ const arch = this.graph.archetypes[record.value.archetypeId];
476
+ if (arch === undefined) {
477
+ return err(
478
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
479
+ );
480
+ }
481
+ const fieldCols = this.table(arch).storage.get(componentId(preparation.mirror))?.fields;
482
+ const column = fieldCols?.get(preparation.fieldName);
483
+ if (column === undefined) {
484
+ return err(
485
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
382
486
  );
383
- if (!added.ok) return added;
384
487
  }
385
- const length = this.relationshipTargetEntries(component, target).length;
386
- return this.ensureArrayCapacity(target, mirror, role.targetField as never, length + 1);
488
+ const slotId = preparation.reservedSlotId;
489
+ if (slotId !== undefined) {
490
+ column.view[this.tableRow(record.value)] = slotId;
491
+ preparation.reservedSlotId = undefined;
492
+ }
493
+ return ok(undefined);
387
494
  }
388
495
 
389
496
  /** Append `holder` to the materialized target list. */
@@ -391,6 +498,7 @@ export class WorldComponentAccess {
391
498
  holder: EntityHandle,
392
499
  component: Component,
393
500
  value: Record<string, unknown>,
501
+ preparation?: RelationshipPreparation,
394
502
  ): Result<void, EcsError> {
395
503
  const role = relationshipRole(component);
396
504
  if (role?.kind !== 'source') return ok(undefined);
@@ -400,19 +508,28 @@ export class WorldComponentAccess {
400
508
  /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
401
509
  if (mirror === undefined) return ok(undefined);
402
510
 
403
- const prepared = this.prepareRelationshipInsert(component, value);
404
- if (!prepared.ok) {
405
- // A dangling source edge is still useful state: hierarchy/animation
406
- // projections report the missing target. The target mirror cannot be
407
- // updated, but insertion itself remains atomic and successful.
408
- if (prepared.error.code === 'stale-entity') return ok(undefined);
409
- return prepared;
511
+ let prepared = preparation;
512
+ if (prepared === undefined) {
513
+ const preparedResult = this.prepareRelationshipInsert(component, value);
514
+ if (!preparedResult.ok) return preparedResult;
515
+ prepared = preparedResult.value;
410
516
  }
517
+ if (prepared === undefined) return ok(undefined);
411
518
 
412
519
  // Lazy-create the mirror component on the target when absent (D-3c).
413
520
  const targetSlot = entityIndex(target);
414
521
  const targetRec = this.records[targetSlot];
415
- if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
522
+ const actualGeneration = targetRec?.generation ?? -1;
523
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) {
524
+ return err(
525
+ new StaleEntityError(target as number, targetSlot, entityGeneration(target), {
526
+ operation: 'relationship-insert',
527
+ component: component.name,
528
+ expectedGeneration: entityGeneration(target),
529
+ actualGeneration: actualGeneration,
530
+ }),
531
+ );
532
+ }
416
533
  const targetArch = this.graph.archetypes[targetRec.archetypeId];
417
534
  const mirrorLocalId = componentId(mirror);
418
535
  const hasMirror =
@@ -425,8 +542,18 @@ export class WorldComponentAccess {
425
542
  data: {} as Partial<ShapeOf<ComponentSchema>>,
426
543
  },
427
544
  true,
545
+ false,
546
+ true,
428
547
  );
429
- if (!added.ok) return added;
548
+ if (!added.ok) {
549
+ this.releaseRelationshipPreparation(prepared);
550
+ return added;
551
+ }
552
+ }
553
+ const installed = this.installRelationshipPreparation(prepared);
554
+ if (!installed.ok) {
555
+ this.releaseRelationshipPreparation(prepared);
556
+ return installed;
430
557
  }
431
558
  const targetEntries = this.relationshipTargetEntries(component, target);
432
559
  const slot = targetEntries.length;
@@ -436,7 +563,10 @@ export class WorldComponentAccess {
436
563
  role.targetField as never,
437
564
  holder as never,
438
565
  );
439
- if (!mirrored.ok) return mirrored;
566
+ if (!mirrored.ok) {
567
+ this.releaseRelationshipPreparation(prepared);
568
+ return mirrored;
569
+ }
440
570
  this.relationshipIndex(component)?.attach(holder, target, slot);
441
571
  return ok(undefined);
442
572
  }
@@ -477,15 +607,16 @@ export class WorldComponentAccess {
477
607
  /**
478
608
  * Read component data from an entity.
479
609
  *
480
- * **Transient view contract (feat-20260602):** for fixed-capacity
481
- * `array<T,N>` and `buffer<N>` fields, the returned `TypedArray` (and any
482
- * subarray of it) aliases the archetype column buffer directly. The view is
483
- * valid only until the next structural change (`spawn` / `despawn` /
484
- * `addComponent` / `removeComponent`). Holding a view across a structural
485
- * change is undefined behaviour -- the backing `ArrayBuffer` is detached on
486
- * column growth, and swap-remove at the same row index points to the wrong
487
- * entity. **Re-fetch `world.get(e, C)` on every access.** See
488
- * `packages/ecs/README.md` Transient view contract section.
610
+ * **Public array contract:** relationship target `array<entity>` fields are
611
+ * detached `Uint32Array` copies. Mutating that returned array cannot alter
612
+ * the materialized target, relationship index, or source. Other array
613
+ * fields retain the existing transient view contract: fixed-capacity
614
+ * `array<T,N>` and `buffer<N>` fields alias the archetype column buffer
615
+ * directly, while variable managed arrays alias their BufferPool slot. Those
616
+ * views are valid only until the next structural change (`spawn` /
617
+ * `despawn` / `addComponent` / `removeComponent`); callers must re-fetch
618
+ * `world.get(e, C)` on every access. Internal owners use `readRow` and
619
+ * `_getArrayView` directly and retain zero-copy access.
489
620
  *
490
621
  * @returns `Result<ShapeOf<S>, EcsError>` —
491
622
  * `ok(ShapeOf<S>)` on success;
@@ -533,7 +664,13 @@ export class WorldComponentAccess {
533
664
  return err(new ComponentNotPresentError(entity as number, component.name));
534
665
  }
535
666
 
536
- return ok(this.storage.readRow(arch, component, this.tableRow(rec)));
667
+ const value = this.storage.readRow(arch, component, this.tableRow(rec));
668
+ const role = relationshipRole(component);
669
+ if (role?.kind === 'target') {
670
+ const targetValue = value as Record<string, unknown>;
671
+ targetValue[role.targetField] = detachRelationshipTargetArray(targetValue[role.targetField]);
672
+ }
673
+ return ok(value);
537
674
  }
538
675
 
539
676
  /**
@@ -568,9 +705,10 @@ export class WorldComponentAccess {
568
705
  * Returns `undefined` when the entity is dead, the component is absent, the
569
706
  * field does not exist, or the field is not an `array<...>` column.
570
707
  *
571
- * @internal Engine-internal fast path; AI users read the typed view through
572
- * `world.get(e, GlobalTransform).world`. The accessor is the zero-materialization
573
- * route the propagate kernel and render walk use.
708
+ * @internal Engine-internal fast path; AI users read public component values
709
+ * through `world.get`. This accessor is the zero-materialization route the
710
+ * propagate kernel, relationship owner, and render walk use; it bypasses
711
+ * the detached public relationship-target snapshot.
574
712
  */
575
713
  _getArrayView(
576
714
  entity: EntityHandle,
@@ -586,6 +724,141 @@ export class WorldComponentAccess {
586
724
  return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
587
725
  }
588
726
 
727
+ /** Internal zero-materialisation read for ECS-owned relationship lists. */
728
+ _getArrayLength(
729
+ entity: EntityHandle,
730
+ component: Component,
731
+ fieldName: string,
732
+ ): number | undefined {
733
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
734
+ if (!record.ok) return undefined;
735
+ const arch = this.graph.archetypes[record.value.archetypeId];
736
+ if (arch === undefined) return undefined;
737
+ return this.storage.readArrayLength(arch, component, this.tableRow(record.value), fieldName);
738
+ }
739
+
740
+ /** Internal zero-materialisation read for one ECS-owned array element. */
741
+ _getArrayElement(
742
+ entity: EntityHandle,
743
+ component: Component,
744
+ fieldName: string,
745
+ index: number,
746
+ ): number | undefined {
747
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
748
+ if (!record.ok) return undefined;
749
+ const arch = this.graph.archetypes[record.value.archetypeId];
750
+ if (arch === undefined) return undefined;
751
+ return this.storage.readArrayElement(
752
+ arch,
753
+ component,
754
+ this.tableRow(record.value),
755
+ fieldName,
756
+ index,
757
+ );
758
+ }
759
+
760
+ /** Internal scalar-column read used by parent-first hierarchy traversal. */
761
+ _getFieldValue(
762
+ entity: EntityHandle,
763
+ component: Component,
764
+ fieldName: string,
765
+ ): number | undefined {
766
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
767
+ if (!record.ok) return undefined;
768
+ const arch = this.graph.archetypes[record.value.archetypeId];
769
+ if (arch === undefined) return undefined;
770
+ return this.storage.readFieldValue(arch, component, this.tableRow(record.value), fieldName);
771
+ }
772
+
773
+ /**
774
+ * Converge one writable relationship source mutation through the source
775
+ * owner. The source scalar and its materialized target list are committed as
776
+ * one operation; no caller receives a raw source column view that could
777
+ * bypass the mirror/index maintenance.
778
+ */
779
+ private setRelationshipSource(
780
+ entity: EntityHandle,
781
+ component: Component,
782
+ value: Record<string, unknown>,
783
+ record: EntityRecord,
784
+ arch: Archetype,
785
+ markChanged: boolean,
786
+ ): Result<void, EcsError> {
787
+ const role = relationshipRole(component);
788
+ if (role?.kind !== 'source') return ok(undefined);
789
+ const row = this.tableRow(record);
790
+ const current = this.storage.readRow(arch, component, row) as Record<string, unknown>;
791
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
792
+ component,
793
+ data: value as never,
794
+ });
795
+ if (!valuePreflight.ok) return valuePreflight;
796
+ const merged = { ...current, ...value };
797
+ const enumError = validateEnumFieldValues(component, merged, entity as number);
798
+ if (enumError !== null) return err(enumError as unknown as EcsError);
799
+
800
+ const oldTarget = this.relationshipTargetEntity(component, current);
801
+ const target = this.relationshipTargetEntity(component, merged);
802
+ if (target !== null) {
803
+ const targetRecord = this.records[entityIndex(target)];
804
+ const actualGeneration = targetRecord?.generation ?? -1;
805
+ if (!this.recordIsLive(targetRecord, entityGeneration(target))) {
806
+ return err(
807
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
808
+ operation: 'relationship-insert',
809
+ component: component.name,
810
+ expectedGeneration: entityGeneration(target),
811
+ actualGeneration,
812
+ }),
813
+ );
814
+ }
815
+ const roleAllowsSelf = role.allowSelf;
816
+ if (entity === target && !roleAllowsSelf) {
817
+ return err(
818
+ new RelationshipSelfCycleError(component.name, entity as number, target as number),
819
+ );
820
+ }
821
+ const cycleHit =
822
+ entity === target && roleAllowsSelf
823
+ ? null
824
+ : this.relationshipCycleHit(component, target, entity);
825
+ if (cycleHit !== null) {
826
+ return err(
827
+ new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
828
+ );
829
+ }
830
+ }
831
+
832
+ // A same-target source write still counts as authored evidence but does
833
+ // not churn the target mirror or relationship index.
834
+ if (oldTarget !== target) {
835
+ const prepared = this.prepareRelationshipInsert(component, merged);
836
+ if (!prepared.ok) return prepared;
837
+ const preparation = prepared.value;
838
+ if (oldTarget !== null) {
839
+ const detached = this.relationshipOnRemove(entity, component, current);
840
+ if (!detached.ok) {
841
+ this.releaseRelationshipPreparation(preparation);
842
+ return detached;
843
+ }
844
+ }
845
+ const attached = this.relationshipOnInsert(entity, component, merged, preparation);
846
+ if (!attached.ok) {
847
+ this.releaseRelationshipPreparation(preparation);
848
+ return attached;
849
+ }
850
+ }
851
+
852
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
853
+ const sourceColumn = fieldCols?.get(role.sourceField);
854
+ if (sourceColumn === undefined) {
855
+ return err(new ComponentNotPresentError(entity as number, component.name));
856
+ }
857
+ sourceColumn.view[row] = target === null ? ENTITY_NULL_RAW : (target as number);
858
+ if (markChanged) this.markComponentChanged(entity, component);
859
+ return ok(undefined);
860
+ }
861
+
589
862
  /**
590
863
  * Write (partial) component data to an entity.
591
864
  *
@@ -632,6 +905,20 @@ export class WorldComponentAccess {
632
905
  // F-02: set on missing component returns err instead of silent ignore
633
906
  return err(new ComponentNotPresentError(entity as number, component.name));
634
907
  }
908
+ const role = relationshipRole(component as Component);
909
+ if (role?.kind === 'target') {
910
+ return err(new RelationshipTargetReadonlyError(component.name, 'set'));
911
+ }
912
+ if (role?.kind === 'source') {
913
+ return this.setRelationshipSource(
914
+ entity,
915
+ component,
916
+ value as Record<string, unknown>,
917
+ rec,
918
+ arch,
919
+ markChanged,
920
+ );
921
+ }
635
922
  const valuePreflight = this.preflightComponentFieldValues(entity, {
636
923
  component,
637
924
  data: value,
@@ -875,58 +1162,6 @@ export class WorldComponentAccess {
875
1162
  return ok(undefined);
876
1163
  }
877
1164
 
878
- private ensureArrayCapacity<S extends ComponentSchema, K extends ArrayFieldsOf<S>>(
879
- entity: EntityHandle,
880
- component: Component<string, S>,
881
- fieldName: K,
882
- minimum: number,
883
- ): Result<void, EcsError> {
884
- const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
885
- if (!record.ok) return record;
886
- const rec = record.value;
887
- const arch = this.graph.archetypes[rec.archetypeId];
888
- if (!arch) {
889
- return err(
890
- new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
891
- operation: 'relationship-capacity',
892
- component: component.name,
893
- expectedGeneration: entityGeneration(entity),
894
- actualGeneration: rec.generation,
895
- }),
896
- );
897
- }
898
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
899
- if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
900
- const fieldNameStr = fieldName as string;
901
- const col = fieldCols.get(fieldNameStr);
902
- if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
903
- const arrayMeta = componentDefinition(component).fields[fieldNameStr]?.arrayMeta;
904
- if (arrayMeta === undefined) {
905
- return err(new ComponentNotPresentError(entity as number, component.name));
906
- }
907
- const meta = TYPE_METADATA[arrayMeta.elementType];
908
- if (!meta?.byteSize) {
909
- return err(new ComponentNotPresentError(entity as number, component.name));
910
- }
911
- const maximum = Math.floor(262_144 / meta.byteSize);
912
- if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
913
- return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
914
- }
915
-
916
- const byteLength = minimum * meta.byteSize;
917
- const slotId = col.view[this.tableRow(rec)] as number;
918
- if (slotId === 0) {
919
- if (minimum === 0) return ok(undefined);
920
- const allocated = this.bufferPool.alloc(byteLength);
921
- if (!allocated.ok) return allocated;
922
- col.view[this.tableRow(rec)] = allocated.value.id;
923
- return ok(undefined);
924
- }
925
- if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
926
- const grown = this.bufferPool.grow(slotId, byteLength);
927
- return grown.ok ? ok(undefined) : grown;
928
- }
929
-
930
1165
  /**
931
1166
  * Remove one variable-array element at a known slot. Relationship holders
932
1167
  * supply the slot from their backpointer, so this is O(1) and never scans
@@ -1013,6 +1248,7 @@ export class WorldComponentAccess {
1013
1248
  componentData: ComponentData<S>,
1014
1249
  internal: boolean,
1015
1250
  resolveRequirements = true,
1251
+ skipVariableArrayInitialization = false,
1016
1252
  ): Result<void, EcsError> {
1017
1253
  const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
1018
1254
  if (!record.ok) return record;
@@ -1068,6 +1304,27 @@ export class WorldComponentAccess {
1068
1304
  return err(enumErr as unknown as EcsError);
1069
1305
  }
1070
1306
 
1307
+ const localId = componentId(componentData.component);
1308
+ let relationshipPreparation: RelationshipPreparation | undefined;
1309
+ const componentAlreadyPresent = srcArch.components.some(
1310
+ (candidate) => componentId(candidate) === localId,
1311
+ );
1312
+ // Reserve relationship target storage before resolving required source
1313
+ // components. A failed mirror allocation must not leave a requirement
1314
+ // component (or its epochs) behind on the source entity.
1315
+ if (
1316
+ !internal &&
1317
+ !componentAlreadyPresent &&
1318
+ relationshipRole(componentData.component as Component)?.kind === 'source'
1319
+ ) {
1320
+ const prepared = this.prepareRelationshipInsert(
1321
+ componentData.component as Component,
1322
+ filled as Record<string, unknown>,
1323
+ );
1324
+ if (!prepared.ok) return prepared;
1325
+ relationshipPreparation = prepared.value;
1326
+ }
1327
+
1071
1328
  // Generic component requirements are resolved once at the structural
1072
1329
  // boundary. Explicit data remains authoritative; only missing required
1073
1330
  // identities are added before the requested component is migrated.
@@ -1085,9 +1342,13 @@ export class WorldComponentAccess {
1085
1342
  // each member so malformed dependency cycles remain finite and the
1086
1343
  // structural work still happens in one deterministic sequence.
1087
1344
  const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
1088
- if (!added.ok) return added;
1345
+ if (!added.ok) {
1346
+ this.releaseRelationshipPreparation(relationshipPreparation);
1347
+ return added;
1348
+ }
1089
1349
  srcArch = this.graph.archetypes[rec.archetypeId];
1090
1350
  if (!srcArch) {
1351
+ this.releaseRelationshipPreparation(relationshipPreparation);
1091
1352
  return err(
1092
1353
  new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1093
1354
  operation: 'addComponent',
@@ -1101,44 +1362,27 @@ export class WorldComponentAccess {
1101
1362
  }
1102
1363
 
1103
1364
  // Check if entity already has this component (using World-local ID).
1104
- const localId = componentId(componentData.component);
1105
1365
  if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
1366
+ this.releaseRelationshipPreparation(relationshipPreparation);
1106
1367
  // M2 exclusive relationship: re-adding the holder with a (possibly new)
1107
- // target auto-reparents instead of failing (AC-12). Prune the old side
1108
- // first (removeComponent prunes the old target), then fall through to
1109
- // the normal add (which appends the new target). The two steps keep the
1110
- // materialized target list consistent (AC-13);
1111
- // removeComponent + addComponent each touch the mirror exactly once and
1112
- // the mirror component carries no relationship of its own, so there is
1113
- // no recursion. Reparent only fires for top-level user calls
1114
- // (!internal); engine-internal lazy create / append
1115
- // never re-add an existing relationship component.
1368
+ // target auto-reparents instead of failing (AC-12). Route the complete
1369
+ // source mutation through one owner operation so the old mirror, new
1370
+ // mirror, source scalar, and backpointer converge atomically. Engine
1371
+ // internal mirror maintenance never re-adds a source component.
1116
1372
  const role = relationshipRole(componentData.component as Component);
1117
1373
  if (role?.kind === 'source' && role.exclusive && !internal) {
1118
- const prepared = this.prepareRelationshipInsert(
1119
- componentData.component as Component,
1120
- filled as Record<string, unknown>,
1121
- );
1122
- if (!prepared.ok) return prepared;
1123
- const removeR = this._removeComponentCore(
1374
+ return this.setRelationshipSource(
1124
1375
  entity,
1125
1376
  componentData.component as Component,
1126
- false,
1377
+ filled as Record<string, unknown>,
1378
+ rec,
1379
+ srcArch,
1380
+ true,
1127
1381
  );
1128
- if (!removeR.ok) return removeR;
1129
- return this._addComponentCore(entity, componentData, false);
1130
1382
  }
1131
1383
  return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
1132
1384
  }
1133
1385
 
1134
- if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
1135
- const prepared = this.prepareRelationshipInsert(
1136
- componentData.component as Component,
1137
- filled as Record<string, unknown>,
1138
- );
1139
- if (!prepared.ok) return prepared;
1140
- }
1141
-
1142
1386
  // Get target archetype via edge cache.
1143
1387
  const targetArch = getAddEdge(
1144
1388
  this.graph,
@@ -1164,6 +1408,7 @@ export class WorldComponentAccess {
1164
1408
  componentData.component,
1165
1409
  this.tableRow(rec),
1166
1410
  filled as ShapeOf<S>,
1411
+ skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
1167
1412
  );
1168
1413
  }
1169
1414
  this.markComponentAdded(entity, componentData.component as Component);
@@ -1173,8 +1418,12 @@ export class WorldComponentAccess {
1173
1418
  entity,
1174
1419
  componentData.component as Component,
1175
1420
  filled as Record<string, unknown>,
1421
+ relationshipPreparation,
1176
1422
  );
1177
- if (!relationshipResult.ok) return relationshipResult;
1423
+ if (!relationshipResult.ok) {
1424
+ this.releaseRelationshipPreparation(relationshipPreparation);
1425
+ return relationshipResult;
1426
+ }
1178
1427
  }
1179
1428
 
1180
1429
  this.markStructureChanged();
@@ -1340,6 +1589,51 @@ export class WorldComponentAccess {
1340
1589
  const record = this.records[slot];
1341
1590
  if (!record || record.archetypeId !== -1) return ok(undefined);
1342
1591
 
1592
+ // Reserve target-side relationship storage before materializing this
1593
+ // pending row. The command preflight has already checked the batch graph;
1594
+ // this owner-level reservation closes the remaining managed-capacity
1595
+ // failure window without adding a mirror component or advancing an epoch.
1596
+ const relationshipPreparations: (RelationshipPreparation | undefined)[] = [];
1597
+ for (let index = 0; index < componentDatas.length; index += 1) {
1598
+ const componentData = componentDatas[index];
1599
+ if (
1600
+ componentData === undefined ||
1601
+ relationshipRole(componentData.component as Component)?.kind !== 'source'
1602
+ ) {
1603
+ continue;
1604
+ }
1605
+ const filled = fillComponentDefaults(
1606
+ componentData.component,
1607
+ componentData.data as Record<string, unknown>,
1608
+ );
1609
+ const target = this.relationshipTargetEntity(
1610
+ componentData.component as Component,
1611
+ filled as Record<string, unknown>,
1612
+ );
1613
+ const targetRecord = target === null ? undefined : this.records[entityIndex(target)];
1614
+ // A pending target is materialized by an earlier command in the normal
1615
+ // supported order. Leave it to the existing relationship callback when
1616
+ // that target row becomes live; current-world targets are fully
1617
+ // prepared before this source row is appended.
1618
+ if (
1619
+ target !== null &&
1620
+ targetRecord !== undefined &&
1621
+ this.recordIsLive(targetRecord, entityGeneration(target))
1622
+ ) {
1623
+ const prepared = this.prepareRelationshipInsert(
1624
+ componentData.component as Component,
1625
+ filled as Record<string, unknown>,
1626
+ );
1627
+ if (!prepared.ok) {
1628
+ for (const reservation of relationshipPreparations) {
1629
+ this.releaseRelationshipPreparation(reservation);
1630
+ }
1631
+ return prepared;
1632
+ }
1633
+ relationshipPreparations[index] = prepared.value;
1634
+ }
1635
+ }
1636
+
1343
1637
  // Find or create target archetype (using World-local IDs).
1344
1638
  const componentIds = componentDatas.map((cd) => componentId(cd.component));
1345
1639
  const components = componentDatas.map((cd) => cd.component);
@@ -1371,15 +1665,21 @@ export class WorldComponentAccess {
1371
1665
  this.storage.writeEntitySelf(arch, tableRow, entity);
1372
1666
 
1373
1667
  // Publish relationship targets after all rows are written.
1374
- for (const cd of componentDatas) {
1668
+ for (let index = 0; index < componentDatas.length; index += 1) {
1669
+ const cd = componentDatas[index];
1670
+ if (cd === undefined) continue;
1375
1671
  if (relationshipRole(cd.component as Component)?.kind === 'source') {
1376
1672
  const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
1377
1673
  const relationshipResult = this.relationshipOnInsert(
1378
1674
  entity,
1379
1675
  cd.component as Component,
1380
1676
  filled as Record<string, unknown>,
1677
+ relationshipPreparations[index],
1381
1678
  );
1382
1679
  if (!relationshipResult.ok) {
1680
+ for (const reservation of relationshipPreparations) {
1681
+ this.releaseRelationshipPreparation(reservation);
1682
+ }
1383
1683
  return relationshipResult;
1384
1684
  }
1385
1685
  }