@forgeax/engine-ecs 0.1.23 → 0.1.24

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 (38) hide show
  1. package/README.md +33 -1
  2. package/dist/component.d.ts +9 -6
  3. package/dist/component.d.ts.map +1 -1
  4. package/dist/errors/query-and-component-errors.d.ts +1 -1
  5. package/dist/errors/query-and-component-errors.d.ts.map +1 -1
  6. package/dist/externalization/index.mjs.map +1 -1
  7. package/dist/index.mjs +566 -131
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/internal.d.ts +3 -1
  10. package/dist/internal.d.ts.map +1 -1
  11. package/dist/internal.mjs +6 -1
  12. package/dist/internal.mjs.map +1 -1
  13. package/dist/projection/index.mjs.map +1 -1
  14. package/dist/query/derived-range-writer.d.ts +14 -0
  15. package/dist/query/derived-range-writer.d.ts.map +1 -1
  16. package/dist/query/query.d.ts.map +1 -1
  17. package/dist/shared.mjs.map +1 -1
  18. package/dist/world-component-access.d.ts +63 -18
  19. package/dist/world-component-access.d.ts.map +1 -1
  20. package/dist/world-component-storage.d.ts +26 -10
  21. package/dist/world-component-storage.d.ts.map +1 -1
  22. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  23. package/dist/world-internal.d.ts +1 -1
  24. package/dist/world-internal.d.ts.map +1 -1
  25. package/dist/world.d.ts +5 -0
  26. package/dist/world.d.ts.map +1 -1
  27. package/package.json +4 -4
  28. package/src/__tests__/relationship-index.test.ts +147 -1
  29. package/src/component.ts +9 -6
  30. package/src/errors/query-and-component-errors.ts +4 -1
  31. package/src/internal.ts +3 -0
  32. package/src/query/derived-range-writer.ts +111 -0
  33. package/src/query/query.ts +26 -1
  34. package/src/world-component-access.ts +428 -127
  35. package/src/world-component-storage.ts +104 -18
  36. package/src/world-entity-lifecycle.ts +49 -9
  37. package/src/world-internal.ts +5 -0
  38. 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,91 @@ 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
+ const maximum = Math.floor(262_144 / meta.byteSize);
436
+ if (!Number.isSafeInteger(currentLength + 1) || currentLength + 1 > maximum) {
437
+ return err(new ManagedBufferOutOfBoundsError(requiredBytes, 262_144));
438
+ }
439
+
440
+ const preparation: RelationshipPreparation = {
441
+ target,
442
+ mirror,
443
+ fieldName,
444
+ mirrorPresent,
445
+ reservedSlotId: undefined,
446
+ };
447
+ if (!mirrorPresent || slotId === 0) {
448
+ const allocated = this.bufferPool.alloc(requiredBytes);
449
+ if (!allocated.ok) return allocated;
450
+ preparation.reservedSlotId = allocated.value.id;
451
+ } else if (this.bufferPool.view(slotId).byteLength < requiredBytes) {
452
+ const grown = this.bufferPool.grow(slotId, requiredBytes);
453
+ if (!grown.ok) return grown;
454
+ }
455
+ return ok(preparation);
456
+ }
457
+
458
+ /** Release a target-capacity reservation that did not reach commit. */
459
+ releaseRelationshipPreparation(preparation: RelationshipPreparation | undefined): void {
460
+ if (preparation === undefined || preparation.reservedSlotId === undefined) return;
461
+ const slotId = preparation.reservedSlotId;
462
+ this.bufferPool.release(slotId);
463
+ preparation.reservedSlotId = undefined;
464
+ }
465
+
466
+ /** Install a reserved slot after the target mirror archetype exists. */
467
+ private installRelationshipPreparation(
468
+ preparation: RelationshipPreparation,
469
+ ): Result<void, EcsError> {
470
+ const record = this.lookupAlive(
471
+ preparation.target,
472
+ 'relationship-capacity',
473
+ preparation.mirror.name,
474
+ );
475
+ if (!record.ok) return record;
476
+ const arch = this.graph.archetypes[record.value.archetypeId];
477
+ if (arch === undefined) {
478
+ return err(
479
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
480
+ );
481
+ }
482
+ const fieldCols = this.table(arch).storage.get(componentId(preparation.mirror))?.fields;
483
+ const column = fieldCols?.get(preparation.fieldName);
484
+ if (column === undefined) {
485
+ return err(
486
+ new ComponentNotPresentError(preparation.target as number, preparation.mirror.name),
382
487
  );
383
- if (!added.ok) return added;
384
488
  }
385
- const length = this.relationshipTargetEntries(component, target).length;
386
- return this.ensureArrayCapacity(target, mirror, role.targetField as never, length + 1);
489
+ const slotId = preparation.reservedSlotId;
490
+ if (slotId !== undefined) {
491
+ column.view[this.tableRow(record.value)] = slotId;
492
+ preparation.reservedSlotId = undefined;
493
+ }
494
+ return ok(undefined);
387
495
  }
388
496
 
389
497
  /** Append `holder` to the materialized target list. */
@@ -391,6 +499,7 @@ export class WorldComponentAccess {
391
499
  holder: EntityHandle,
392
500
  component: Component,
393
501
  value: Record<string, unknown>,
502
+ preparation?: RelationshipPreparation,
394
503
  ): Result<void, EcsError> {
395
504
  const role = relationshipRole(component);
396
505
  if (role?.kind !== 'source') return ok(undefined);
@@ -400,19 +509,28 @@ export class WorldComponentAccess {
400
509
  /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
401
510
  if (mirror === undefined) return ok(undefined);
402
511
 
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;
512
+ let prepared = preparation;
513
+ if (prepared === undefined) {
514
+ const preparedResult = this.prepareRelationshipInsert(component, value);
515
+ if (!preparedResult.ok) return preparedResult;
516
+ prepared = preparedResult.value;
410
517
  }
518
+ if (prepared === undefined) return ok(undefined);
411
519
 
412
520
  // Lazy-create the mirror component on the target when absent (D-3c).
413
521
  const targetSlot = entityIndex(target);
414
522
  const targetRec = this.records[targetSlot];
415
- if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
523
+ const actualGeneration = targetRec?.generation ?? -1;
524
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) {
525
+ return err(
526
+ new StaleEntityError(target as number, targetSlot, entityGeneration(target), {
527
+ operation: 'relationship-insert',
528
+ component: component.name,
529
+ expectedGeneration: entityGeneration(target),
530
+ actualGeneration: actualGeneration,
531
+ }),
532
+ );
533
+ }
416
534
  const targetArch = this.graph.archetypes[targetRec.archetypeId];
417
535
  const mirrorLocalId = componentId(mirror);
418
536
  const hasMirror =
@@ -425,8 +543,18 @@ export class WorldComponentAccess {
425
543
  data: {} as Partial<ShapeOf<ComponentSchema>>,
426
544
  },
427
545
  true,
546
+ false,
547
+ true,
428
548
  );
429
- if (!added.ok) return added;
549
+ if (!added.ok) {
550
+ this.releaseRelationshipPreparation(prepared);
551
+ return added;
552
+ }
553
+ }
554
+ const installed = this.installRelationshipPreparation(prepared);
555
+ if (!installed.ok) {
556
+ this.releaseRelationshipPreparation(prepared);
557
+ return installed;
430
558
  }
431
559
  const targetEntries = this.relationshipTargetEntries(component, target);
432
560
  const slot = targetEntries.length;
@@ -436,7 +564,10 @@ export class WorldComponentAccess {
436
564
  role.targetField as never,
437
565
  holder as never,
438
566
  );
439
- if (!mirrored.ok) return mirrored;
567
+ if (!mirrored.ok) {
568
+ this.releaseRelationshipPreparation(prepared);
569
+ return mirrored;
570
+ }
440
571
  this.relationshipIndex(component)?.attach(holder, target, slot);
441
572
  return ok(undefined);
442
573
  }
@@ -477,15 +608,16 @@ export class WorldComponentAccess {
477
608
  /**
478
609
  * Read component data from an entity.
479
610
  *
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.
611
+ * **Public array contract:** relationship target `array<entity>` fields are
612
+ * detached `Uint32Array` copies. Mutating that returned array cannot alter
613
+ * the materialized target, relationship index, or source. Other array
614
+ * fields retain the existing transient view contract: fixed-capacity
615
+ * `array<T,N>` and `buffer<N>` fields alias the archetype column buffer
616
+ * directly, while variable managed arrays alias their BufferPool slot. Those
617
+ * views are valid only until the next structural change (`spawn` /
618
+ * `despawn` / `addComponent` / `removeComponent`); callers must re-fetch
619
+ * `world.get(e, C)` on every access. Internal owners use `readRow` and
620
+ * `_getArrayView` directly and retain zero-copy access.
489
621
  *
490
622
  * @returns `Result<ShapeOf<S>, EcsError>` —
491
623
  * `ok(ShapeOf<S>)` on success;
@@ -533,7 +665,13 @@ export class WorldComponentAccess {
533
665
  return err(new ComponentNotPresentError(entity as number, component.name));
534
666
  }
535
667
 
536
- return ok(this.storage.readRow(arch, component, this.tableRow(rec)));
668
+ const value = this.storage.readRow(arch, component, this.tableRow(rec));
669
+ const role = relationshipRole(component);
670
+ if (role?.kind === 'target') {
671
+ const targetValue = value as Record<string, unknown>;
672
+ targetValue[role.targetField] = detachRelationshipTargetArray(targetValue[role.targetField]);
673
+ }
674
+ return ok(value);
537
675
  }
538
676
 
539
677
  /**
@@ -568,9 +706,10 @@ export class WorldComponentAccess {
568
706
  * Returns `undefined` when the entity is dead, the component is absent, the
569
707
  * field does not exist, or the field is not an `array<...>` column.
570
708
  *
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.
709
+ * @internal Engine-internal fast path; AI users read public component values
710
+ * through `world.get`. This accessor is the zero-materialization route the
711
+ * propagate kernel, relationship owner, and render walk use; it bypasses
712
+ * the detached public relationship-target snapshot.
574
713
  */
575
714
  _getArrayView(
576
715
  entity: EntityHandle,
@@ -586,6 +725,141 @@ export class WorldComponentAccess {
586
725
  return this.storage.readArrayView(arch, component, this.tableRow(rec), fieldName);
587
726
  }
588
727
 
728
+ /** Internal zero-materialisation read for ECS-owned relationship lists. */
729
+ _getArrayLength(
730
+ entity: EntityHandle,
731
+ component: Component,
732
+ fieldName: string,
733
+ ): number | undefined {
734
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
735
+ if (!record.ok) return undefined;
736
+ const arch = this.graph.archetypes[record.value.archetypeId];
737
+ if (arch === undefined) return undefined;
738
+ return this.storage.readArrayLength(arch, component, this.tableRow(record.value), fieldName);
739
+ }
740
+
741
+ /** Internal zero-materialisation read for one ECS-owned array element. */
742
+ _getArrayElement(
743
+ entity: EntityHandle,
744
+ component: Component,
745
+ fieldName: string,
746
+ index: number,
747
+ ): number | undefined {
748
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
749
+ if (!record.ok) return undefined;
750
+ const arch = this.graph.archetypes[record.value.archetypeId];
751
+ if (arch === undefined) return undefined;
752
+ return this.storage.readArrayElement(
753
+ arch,
754
+ component,
755
+ this.tableRow(record.value),
756
+ fieldName,
757
+ index,
758
+ );
759
+ }
760
+
761
+ /** Internal scalar-column read used by parent-first hierarchy traversal. */
762
+ _getFieldValue(
763
+ entity: EntityHandle,
764
+ component: Component,
765
+ fieldName: string,
766
+ ): number | undefined {
767
+ const record = this.lookupAlive(entity, 'relationship-read', component.name);
768
+ if (!record.ok) return undefined;
769
+ const arch = this.graph.archetypes[record.value.archetypeId];
770
+ if (arch === undefined) return undefined;
771
+ return this.storage.readFieldValue(arch, component, this.tableRow(record.value), fieldName);
772
+ }
773
+
774
+ /**
775
+ * Converge one writable relationship source mutation through the source
776
+ * owner. The source scalar and its materialized target list are committed as
777
+ * one operation; no caller receives a raw source column view that could
778
+ * bypass the mirror/index maintenance.
779
+ */
780
+ private setRelationshipSource(
781
+ entity: EntityHandle,
782
+ component: Component,
783
+ value: Record<string, unknown>,
784
+ record: EntityRecord,
785
+ arch: Archetype,
786
+ markChanged: boolean,
787
+ ): Result<void, EcsError> {
788
+ const role = relationshipRole(component);
789
+ if (role?.kind !== 'source') return ok(undefined);
790
+ const row = this.tableRow(record);
791
+ const current = this.storage.readRow(arch, component, row) as Record<string, unknown>;
792
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
793
+ component,
794
+ data: value as never,
795
+ });
796
+ if (!valuePreflight.ok) return valuePreflight;
797
+ const merged = { ...current, ...value };
798
+ const enumError = validateEnumFieldValues(component, merged, entity as number);
799
+ if (enumError !== null) return err(enumError as unknown as EcsError);
800
+
801
+ const oldTarget = this.relationshipTargetEntity(component, current);
802
+ const target = this.relationshipTargetEntity(component, merged);
803
+ if (target !== null) {
804
+ const targetRecord = this.records[entityIndex(target)];
805
+ const actualGeneration = targetRecord?.generation ?? -1;
806
+ if (!this.recordIsLive(targetRecord, entityGeneration(target))) {
807
+ return err(
808
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
809
+ operation: 'relationship-insert',
810
+ component: component.name,
811
+ expectedGeneration: entityGeneration(target),
812
+ actualGeneration,
813
+ }),
814
+ );
815
+ }
816
+ const roleAllowsSelf = role.allowSelf;
817
+ if (entity === target && !roleAllowsSelf) {
818
+ return err(
819
+ new RelationshipSelfCycleError(component.name, entity as number, target as number),
820
+ );
821
+ }
822
+ const cycleHit =
823
+ entity === target && roleAllowsSelf
824
+ ? null
825
+ : this.relationshipCycleHit(component, target, entity);
826
+ if (cycleHit !== null) {
827
+ return err(
828
+ new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
829
+ );
830
+ }
831
+ }
832
+
833
+ // A same-target source write still counts as authored evidence but does
834
+ // not churn the target mirror or relationship index.
835
+ if (oldTarget !== target) {
836
+ const prepared = this.prepareRelationshipInsert(component, merged);
837
+ if (!prepared.ok) return prepared;
838
+ const preparation = prepared.value;
839
+ if (oldTarget !== null) {
840
+ const detached = this.relationshipOnRemove(entity, component, current);
841
+ if (!detached.ok) {
842
+ this.releaseRelationshipPreparation(preparation);
843
+ return detached;
844
+ }
845
+ }
846
+ const attached = this.relationshipOnInsert(entity, component, merged, preparation);
847
+ if (!attached.ok) {
848
+ this.releaseRelationshipPreparation(preparation);
849
+ return attached;
850
+ }
851
+ }
852
+
853
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
854
+ const sourceColumn = fieldCols?.get(role.sourceField);
855
+ if (sourceColumn === undefined) {
856
+ return err(new ComponentNotPresentError(entity as number, component.name));
857
+ }
858
+ sourceColumn.view[row] = target === null ? ENTITY_NULL_RAW : (target as number);
859
+ if (markChanged) this.markComponentChanged(entity, component);
860
+ return ok(undefined);
861
+ }
862
+
589
863
  /**
590
864
  * Write (partial) component data to an entity.
591
865
  *
@@ -632,6 +906,20 @@ export class WorldComponentAccess {
632
906
  // F-02: set on missing component returns err instead of silent ignore
633
907
  return err(new ComponentNotPresentError(entity as number, component.name));
634
908
  }
909
+ const role = relationshipRole(component as Component);
910
+ if (role?.kind === 'target') {
911
+ return err(new RelationshipTargetReadonlyError(component.name, 'set'));
912
+ }
913
+ if (role?.kind === 'source') {
914
+ return this.setRelationshipSource(
915
+ entity,
916
+ component,
917
+ value as Record<string, unknown>,
918
+ rec,
919
+ arch,
920
+ markChanged,
921
+ );
922
+ }
635
923
  const valuePreflight = this.preflightComponentFieldValues(entity, {
636
924
  component,
637
925
  data: value,
@@ -875,58 +1163,6 @@ export class WorldComponentAccess {
875
1163
  return ok(undefined);
876
1164
  }
877
1165
 
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
1166
  /**
931
1167
  * Remove one variable-array element at a known slot. Relationship holders
932
1168
  * supply the slot from their backpointer, so this is O(1) and never scans
@@ -1013,6 +1249,7 @@ export class WorldComponentAccess {
1013
1249
  componentData: ComponentData<S>,
1014
1250
  internal: boolean,
1015
1251
  resolveRequirements = true,
1252
+ skipVariableArrayInitialization = false,
1016
1253
  ): Result<void, EcsError> {
1017
1254
  const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
1018
1255
  if (!record.ok) return record;
@@ -1068,6 +1305,27 @@ export class WorldComponentAccess {
1068
1305
  return err(enumErr as unknown as EcsError);
1069
1306
  }
1070
1307
 
1308
+ const localId = componentId(componentData.component);
1309
+ let relationshipPreparation: RelationshipPreparation | undefined;
1310
+ const componentAlreadyPresent = srcArch.components.some(
1311
+ (candidate) => componentId(candidate) === localId,
1312
+ );
1313
+ // Reserve relationship target storage before resolving required source
1314
+ // components. A failed mirror allocation must not leave a requirement
1315
+ // component (or its epochs) behind on the source entity.
1316
+ if (
1317
+ !internal &&
1318
+ !componentAlreadyPresent &&
1319
+ relationshipRole(componentData.component as Component)?.kind === 'source'
1320
+ ) {
1321
+ const prepared = this.prepareRelationshipInsert(
1322
+ componentData.component as Component,
1323
+ filled as Record<string, unknown>,
1324
+ );
1325
+ if (!prepared.ok) return prepared;
1326
+ relationshipPreparation = prepared.value;
1327
+ }
1328
+
1071
1329
  // Generic component requirements are resolved once at the structural
1072
1330
  // boundary. Explicit data remains authoritative; only missing required
1073
1331
  // identities are added before the requested component is migrated.
@@ -1085,9 +1343,13 @@ export class WorldComponentAccess {
1085
1343
  // each member so malformed dependency cycles remain finite and the
1086
1344
  // structural work still happens in one deterministic sequence.
1087
1345
  const added = this._addComponentCore(entity, requirement as ComponentData, internal, false);
1088
- if (!added.ok) return added;
1346
+ if (!added.ok) {
1347
+ this.releaseRelationshipPreparation(relationshipPreparation);
1348
+ return added;
1349
+ }
1089
1350
  srcArch = this.graph.archetypes[rec.archetypeId];
1090
1351
  if (!srcArch) {
1352
+ this.releaseRelationshipPreparation(relationshipPreparation);
1091
1353
  return err(
1092
1354
  new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1093
1355
  operation: 'addComponent',
@@ -1101,44 +1363,27 @@ export class WorldComponentAccess {
1101
1363
  }
1102
1364
 
1103
1365
  // Check if entity already has this component (using World-local ID).
1104
- const localId = componentId(componentData.component);
1105
1366
  if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
1367
+ this.releaseRelationshipPreparation(relationshipPreparation);
1106
1368
  // 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.
1369
+ // target auto-reparents instead of failing (AC-12). Route the complete
1370
+ // source mutation through one owner operation so the old mirror, new
1371
+ // mirror, source scalar, and backpointer converge atomically. Engine
1372
+ // internal mirror maintenance never re-adds a source component.
1116
1373
  const role = relationshipRole(componentData.component as Component);
1117
1374
  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(
1375
+ return this.setRelationshipSource(
1124
1376
  entity,
1125
1377
  componentData.component as Component,
1126
- false,
1378
+ filled as Record<string, unknown>,
1379
+ rec,
1380
+ srcArch,
1381
+ true,
1127
1382
  );
1128
- if (!removeR.ok) return removeR;
1129
- return this._addComponentCore(entity, componentData, false);
1130
1383
  }
1131
1384
  return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
1132
1385
  }
1133
1386
 
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
1387
  // Get target archetype via edge cache.
1143
1388
  const targetArch = getAddEdge(
1144
1389
  this.graph,
@@ -1164,6 +1409,7 @@ export class WorldComponentAccess {
1164
1409
  componentData.component,
1165
1410
  this.tableRow(rec),
1166
1411
  filled as ShapeOf<S>,
1412
+ skipVariableArrayInitialization ? { skipVariableArrayInitialization: true } : undefined,
1167
1413
  );
1168
1414
  }
1169
1415
  this.markComponentAdded(entity, componentData.component as Component);
@@ -1173,8 +1419,12 @@ export class WorldComponentAccess {
1173
1419
  entity,
1174
1420
  componentData.component as Component,
1175
1421
  filled as Record<string, unknown>,
1422
+ relationshipPreparation,
1176
1423
  );
1177
- if (!relationshipResult.ok) return relationshipResult;
1424
+ if (!relationshipResult.ok) {
1425
+ this.releaseRelationshipPreparation(relationshipPreparation);
1426
+ return relationshipResult;
1427
+ }
1178
1428
  }
1179
1429
 
1180
1430
  this.markStructureChanged();
@@ -1340,6 +1590,51 @@ export class WorldComponentAccess {
1340
1590
  const record = this.records[slot];
1341
1591
  if (!record || record.archetypeId !== -1) return ok(undefined);
1342
1592
 
1593
+ // Reserve target-side relationship storage before materializing this
1594
+ // pending row. The command preflight has already checked the batch graph;
1595
+ // this owner-level reservation closes the remaining managed-capacity
1596
+ // failure window without adding a mirror component or advancing an epoch.
1597
+ const relationshipPreparations: (RelationshipPreparation | undefined)[] = [];
1598
+ for (let index = 0; index < componentDatas.length; index += 1) {
1599
+ const componentData = componentDatas[index];
1600
+ if (
1601
+ componentData === undefined ||
1602
+ relationshipRole(componentData.component as Component)?.kind !== 'source'
1603
+ ) {
1604
+ continue;
1605
+ }
1606
+ const filled = fillComponentDefaults(
1607
+ componentData.component,
1608
+ componentData.data as Record<string, unknown>,
1609
+ );
1610
+ const target = this.relationshipTargetEntity(
1611
+ componentData.component as Component,
1612
+ filled as Record<string, unknown>,
1613
+ );
1614
+ const targetRecord = target === null ? undefined : this.records[entityIndex(target)];
1615
+ // A pending target is materialized by an earlier command in the normal
1616
+ // supported order. Leave it to the existing relationship callback when
1617
+ // that target row becomes live; current-world targets are fully
1618
+ // prepared before this source row is appended.
1619
+ if (
1620
+ target !== null &&
1621
+ targetRecord !== undefined &&
1622
+ this.recordIsLive(targetRecord, entityGeneration(target))
1623
+ ) {
1624
+ const prepared = this.prepareRelationshipInsert(
1625
+ componentData.component as Component,
1626
+ filled as Record<string, unknown>,
1627
+ );
1628
+ if (!prepared.ok) {
1629
+ for (const reservation of relationshipPreparations) {
1630
+ this.releaseRelationshipPreparation(reservation);
1631
+ }
1632
+ return prepared;
1633
+ }
1634
+ relationshipPreparations[index] = prepared.value;
1635
+ }
1636
+ }
1637
+
1343
1638
  // Find or create target archetype (using World-local IDs).
1344
1639
  const componentIds = componentDatas.map((cd) => componentId(cd.component));
1345
1640
  const components = componentDatas.map((cd) => cd.component);
@@ -1371,15 +1666,21 @@ export class WorldComponentAccess {
1371
1666
  this.storage.writeEntitySelf(arch, tableRow, entity);
1372
1667
 
1373
1668
  // Publish relationship targets after all rows are written.
1374
- for (const cd of componentDatas) {
1669
+ for (let index = 0; index < componentDatas.length; index += 1) {
1670
+ const cd = componentDatas[index];
1671
+ if (cd === undefined) continue;
1375
1672
  if (relationshipRole(cd.component as Component)?.kind === 'source') {
1376
1673
  const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
1377
1674
  const relationshipResult = this.relationshipOnInsert(
1378
1675
  entity,
1379
1676
  cd.component as Component,
1380
1677
  filled as Record<string, unknown>,
1678
+ relationshipPreparations[index],
1381
1679
  );
1382
1680
  if (!relationshipResult.ok) {
1681
+ for (const reservation of relationshipPreparations) {
1682
+ this.releaseRelationshipPreparation(reservation);
1683
+ }
1383
1684
  return relationshipResult;
1384
1685
  }
1385
1686
  }