@contember/bindx 0.1.46 → 0.1.48

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 (121) hide show
  1. package/dist/adapter/MockAdapter.d.ts +4 -1
  2. package/dist/adapter/MockAdapter.d.ts.map +1 -1
  3. package/dist/core/ActionDispatcher.d.ts +4 -1
  4. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  5. package/dist/core/actions.d.ts +7 -1
  6. package/dist/core/actions.d.ts.map +1 -1
  7. package/dist/events/EventEmitter.d.ts +13 -1
  8. package/dist/events/EventEmitter.d.ts.map +1 -1
  9. package/dist/handles/BaseHandle.d.ts +14 -3
  10. package/dist/handles/BaseHandle.d.ts.map +1 -1
  11. package/dist/handles/EntityHandle.d.ts +14 -5
  12. package/dist/handles/EntityHandle.d.ts.map +1 -1
  13. package/dist/handles/FieldHandle.d.ts +11 -4
  14. package/dist/handles/FieldHandle.d.ts.map +1 -1
  15. package/dist/handles/HasManyListHandle.d.ts +36 -2
  16. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  17. package/dist/handles/HasOneHandle.d.ts +52 -9
  18. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  19. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  20. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  21. package/dist/handles/index.d.ts +1 -1
  22. package/dist/handles/index.d.ts.map +1 -1
  23. package/dist/handles/proxyFactory.d.ts +7 -1
  24. package/dist/handles/proxyFactory.d.ts.map +1 -1
  25. package/dist/handles/types.d.ts +33 -0
  26. package/dist/handles/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/persistence/BatchPersister.d.ts +53 -37
  30. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  31. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  32. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  33. package/dist/persistence/MutationCollector.d.ts +106 -3
  34. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  35. package/dist/persistence/PersistExecution.d.ts +26 -0
  36. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  37. package/dist/persistence/types.d.ts +8 -2
  38. package/dist/persistence/types.d.ts.map +1 -1
  39. package/dist/store/EntityMetaStore.d.ts +32 -10
  40. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  41. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  42. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  43. package/dist/store/ErrorStore.d.ts +3 -2
  44. package/dist/store/ErrorStore.d.ts.map +1 -1
  45. package/dist/store/HasManyStore.d.ts +214 -0
  46. package/dist/store/HasManyStore.d.ts.map +1 -0
  47. package/dist/store/HasOneStore.d.ts +159 -0
  48. package/dist/store/HasOneStore.d.ts.map +1 -0
  49. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  50. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  51. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  52. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  53. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  54. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  55. package/dist/store/RelationStore.d.ts +74 -137
  56. package/dist/store/RelationStore.d.ts.map +1 -1
  57. package/dist/store/RootRegistry.d.ts +25 -4
  58. package/dist/store/RootRegistry.d.ts.map +1 -1
  59. package/dist/store/SnapshotStore.d.ts +137 -13
  60. package/dist/store/SnapshotStore.d.ts.map +1 -1
  61. package/dist/store/SubscriptionManager.d.ts +63 -14
  62. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  63. package/dist/store/TouchedStore.d.ts +3 -2
  64. package/dist/store/TouchedStore.d.ts.map +1 -1
  65. package/dist/store/relationKey.d.ts +15 -0
  66. package/dist/store/relationKey.d.ts.map +1 -0
  67. package/dist/undo/UndoJournal.d.ts +133 -0
  68. package/dist/undo/UndoJournal.d.ts.map +1 -0
  69. package/dist/undo/UndoManager.d.ts +25 -82
  70. package/dist/undo/UndoManager.d.ts.map +1 -1
  71. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  72. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  73. package/dist/undo/index.d.ts +3 -1
  74. package/dist/undo/index.d.ts.map +1 -1
  75. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  76. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  77. package/dist/undo/types.d.ts +0 -48
  78. package/dist/undo/types.d.ts.map +1 -1
  79. package/package.json +2 -2
  80. package/src/adapter/MockAdapter.ts +15 -5
  81. package/src/core/ActionDispatcher.ts +56 -36
  82. package/src/core/actions.ts +8 -1
  83. package/src/events/EventEmitter.ts +78 -24
  84. package/src/handles/BaseHandle.ts +19 -2
  85. package/src/handles/EntityHandle.ts +48 -17
  86. package/src/handles/FieldHandle.ts +36 -10
  87. package/src/handles/HasManyListHandle.ts +120 -16
  88. package/src/handles/HasOneHandle.ts +177 -44
  89. package/src/handles/PlaceholderHandle.ts +184 -36
  90. package/src/handles/index.ts +3 -0
  91. package/src/handles/proxyFactory.ts +27 -0
  92. package/src/handles/types.ts +39 -0
  93. package/src/index.ts +4 -4
  94. package/src/persistence/BatchPersister.ts +810 -446
  95. package/src/persistence/ChangeRegistry.ts +27 -3
  96. package/src/persistence/MutationCollector.ts +265 -31
  97. package/src/persistence/PersistExecution.ts +217 -0
  98. package/src/persistence/types.ts +8 -2
  99. package/src/store/EntityMetaStore.ts +89 -45
  100. package/src/store/EntitySnapshotStore.ts +102 -19
  101. package/src/store/ErrorStore.ts +6 -5
  102. package/src/store/HasManyStore.ts +810 -0
  103. package/src/store/HasOneStore.ts +454 -0
  104. package/src/store/ReachabilityAnalyzer.ts +37 -0
  105. package/src/store/RekeyOrchestrator.ts +123 -0
  106. package/src/store/RelationEdgeIndex.ts +79 -0
  107. package/src/store/RelationStore.ts +176 -725
  108. package/src/store/RootRegistry.ts +50 -8
  109. package/src/store/SnapshotStore.ts +572 -101
  110. package/src/store/SubscriptionManager.ts +122 -75
  111. package/src/store/TouchedStore.ts +5 -2
  112. package/src/store/relationKey.ts +20 -0
  113. package/src/undo/UndoJournal.ts +258 -0
  114. package/src/undo/UndoManager.ts +151 -281
  115. package/src/undo/UnrecordedWriteError.ts +23 -0
  116. package/src/undo/index.ts +8 -7
  117. package/src/undo/rekeyJournalEntry.ts +145 -0
  118. package/src/undo/types.ts +0 -58
  119. package/dist/core/actionClassification.d.ts +0 -48
  120. package/dist/core/actionClassification.d.ts.map +0 -1
  121. package/src/core/actionClassification.ts +0 -199
@@ -37,6 +37,9 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
37
37
  dispatcher: ActionDispatcher,
38
38
  private readonly _enumName?: string,
39
39
  private readonly _columnType?: string,
40
+ private readonly dataFieldPath: string[] = fieldPath,
41
+ // Absolute chain from the query root; see FieldRefMeta.fullPath.
42
+ private readonly _fullPath: readonly string[] = fieldPath,
40
43
  ) {
41
44
  super(entityType, entityId, store, dispatcher)
42
45
  }
@@ -49,8 +52,10 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
49
52
  dispatcher: ActionDispatcher,
50
53
  enumName?: string,
51
54
  columnType?: string,
55
+ dataFieldPath?: string[],
56
+ fullPath?: readonly string[],
52
57
  ): FieldAccessor<T> {
53
- return createAliasProxy<FieldHandle<T>, FieldAccessor<T>>(new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType))
58
+ return createAliasProxy<FieldHandle<T>, FieldAccessor<T>>(new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath))
54
59
  }
55
60
 
56
61
  static createRaw<T = unknown>(
@@ -61,8 +66,10 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
61
66
  dispatcher: ActionDispatcher,
62
67
  enumName?: string,
63
68
  columnType?: string,
69
+ dataFieldPath?: string[],
70
+ fullPath?: readonly string[],
64
71
  ): FieldHandle<T> {
65
- return new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType)
72
+ return new FieldHandle<T>(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath)
66
73
  }
67
74
 
68
75
  static wrapProxy<T>(handle: FieldHandle<T>): FieldAccessor<T> {
@@ -83,16 +90,18 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
83
90
  isRelation: false,
84
91
  enumName: this._enumName,
85
92
  columnType: this._columnType,
93
+ fullPath: this._fullPath,
86
94
  }
87
95
  }
88
96
 
89
97
  /**
90
- * Gets the current field value.
98
+ * Gets the current field value to DISPLAY. While a pessimistic persist is
99
+ * in-flight this is the server baseline; otherwise it is the live value.
91
100
  */
92
101
  get value(): T | null {
93
- const data = this.getEntityData()
102
+ const data = this.getPresentationData()
94
103
  if (!data) return null
95
- return getNestedValue(data, this.fieldPath) as T | null
104
+ return this.getCurrentValue(data) as T | null
96
105
  }
97
106
 
98
107
  /**
@@ -101,14 +110,28 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
101
110
  get serverValue(): T | null {
102
111
  const serverData = this.getServerData()
103
112
  if (!serverData) return null
104
- return getNestedValue(serverData, this.fieldPath) as T | null
113
+ return this.getServerValue(serverData) as T | null
105
114
  }
106
115
 
107
116
  /**
108
- * Checks if the field has been modified.
117
+ * Checks if the field has been modified. Compares the CANONICAL value against
118
+ * the server value — never the presented value, so a field stays correctly
119
+ * dirty even while its display shows the server baseline mid-pessimistic-persist.
109
120
  */
110
121
  get isDirty(): boolean {
111
- return !deepEqual(this.value, this.serverValue)
122
+ const data = this.getEntityData()
123
+ const value = data ? (this.getCurrentValue(data) as T | null) : null
124
+ return !deepEqual(value, this.serverValue)
125
+ }
126
+
127
+ private getCurrentValue(data: Record<string, unknown>): unknown {
128
+ const value = getNestedValue(data, this.fieldPath)
129
+ return value === undefined ? getNestedValue(data, this.dataFieldPath) : value
130
+ }
131
+
132
+ private getServerValue(data: Record<string, unknown>): unknown {
133
+ const value = getNestedValue(data, this.fieldPath)
134
+ return value === undefined ? getNestedValue(data, this.dataFieldPath) : value
112
135
  }
113
136
 
114
137
  /**
@@ -217,12 +240,16 @@ export class FieldHandle<T = unknown> extends EntityRelatedHandle {
217
240
  nested<K extends keyof NonNullable<T>>(
218
241
  key: K,
219
242
  ): FieldAccessor<NonNullable<T>[K]> {
243
+ const fieldName = String(key)
220
244
  return FieldHandle.create<NonNullable<T>[K]>(
221
245
  this.entityType,
222
246
  this.entityId,
223
- [...this.fieldPath, key as string],
247
+ [...this.fieldPath, fieldName],
224
248
  this.store,
225
249
  this.dispatcher,
250
+ undefined,
251
+ undefined,
252
+ [...this.dataFieldPath, fieldName],
226
253
  )
227
254
  }
228
255
 
@@ -277,4 +304,3 @@ function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
277
304
 
278
305
  return current
279
306
  }
280
-
@@ -33,7 +33,10 @@ import { createAliasProxy } from './proxyFactory.js'
33
33
  * @typeParam TSelected - The selected subset of fields (defaults to TEntity for backwards compatibility)
34
34
  */
35
35
  export class HasManyListHandle<TEntity extends object = object, TSelected = TEntity> extends EntityRelatedHandle {
36
- private itemHandleCacheRaw = new Map<string, EntityHandle<TEntity, TSelected>>()
36
+ // Per-item accessor cache, keyed by the item's canonical (post-rekey) id. Identity is the
37
+ // point: a still-listed item keeps the same proxy — and with it the same handle, which the
38
+ // proxy is the only reference to. Kept bounded by syncItemHandleCache, which drops ids
39
+ // that have left the list.
37
40
  private itemHandleCacheProxy = new Map<string, EntityAccessor<TEntity, TSelected>>()
38
41
 
39
42
  /** Runtime brand symbols for validation */
@@ -73,6 +76,9 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
73
76
  brands?: Set<symbol>,
74
77
  alias?: string,
75
78
  private readonly selection?: SelectionMeta,
79
+ // Segments from the query root down to this relation. Items do NOT inherit it:
80
+ // a has-many restarts the chain, mirroring the collector proxy.
81
+ private readonly relationPath: readonly string[] = [fieldName],
76
82
  ) {
77
83
  super(parentEntityType, parentEntityId, store, dispatcher)
78
84
  this.__brands = brands
@@ -90,8 +96,31 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
90
96
  brands?: Set<symbol>,
91
97
  alias?: string,
92
98
  selection?: SelectionMeta,
99
+ relationPath?: readonly string[],
93
100
  ): HasManyAccessor<TEntity, TSelected> {
94
- return createAliasProxy<HasManyListHandle<TEntity, TSelected>, HasManyAccessor<TEntity, TSelected>>(new HasManyListHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection))
101
+ return createAliasProxy<HasManyListHandle<TEntity, TSelected>, HasManyAccessor<TEntity, TSelected>>(
102
+ HasManyListHandle.createRaw<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath),
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Creates the handle without the alias proxy. Mirrors {@link EntityHandle.createRaw}:
108
+ * the class surface (e.g. {@link itemHandleCacheSize}) is not part of HasManyAccessor.
109
+ */
110
+ static createRaw<TEntity extends object = object, TSelected = TEntity>(
111
+ parentEntityType: string,
112
+ parentEntityId: string,
113
+ fieldName: string,
114
+ itemType: string,
115
+ store: SnapshotStore,
116
+ dispatcher: ActionDispatcher,
117
+ schema: SchemaRegistry,
118
+ brands?: Set<symbol>,
119
+ alias?: string,
120
+ selection?: SelectionMeta,
121
+ relationPath?: readonly string[],
122
+ ): HasManyListHandle<TEntity, TSelected> {
123
+ return new HasManyListHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath)
95
124
  }
96
125
 
97
126
  /**
@@ -107,6 +136,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
107
136
  isArray: true,
108
137
  isRelation: true,
109
138
  targetType: this.itemType,
139
+ fullPath: this.relationPath,
110
140
  }
111
141
  }
112
142
 
@@ -115,21 +145,76 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
115
145
  * Returns selection-aware EntityAccessors that support `item.fieldName.value`.
116
146
  * Includes planned connections and excludes planned removals.
117
147
  * Uses ordered IDs to preserve order including after move() operations.
148
+ *
149
+ * This is also where the item-accessor cache is pruned — see {@link syncItemHandleCache}.
118
150
  */
119
151
  get items(): EntityAccessor<TEntity, TSelected>[] {
120
152
  this.materializeEmbeddedItems()
121
153
 
122
154
  // Use ordered IDs from store (handles removals, connections, and ordering)
123
- const orderedIds = this.store.getHasManyOrderedIds(
155
+ const orderedIds = this.store.getPresentationHasManyOrderedIds(
124
156
  this.entityType,
125
157
  this.entityId,
126
158
  this.fieldName,
127
159
  this.alias,
128
160
  )
129
161
 
162
+ this.syncItemHandleCache(orderedIds)
163
+
130
164
  return orderedIds.map((id) => this.getItemHandle(id))
131
165
  }
132
166
 
167
+ /**
168
+ * Canonical cache key for an item id: a temp id follows its temp→persisted rekey, so a
169
+ * lookup by the now-dead temp id reuses the persisted item's handle instead of minting a
170
+ * duplicate for the same entity.
171
+ */
172
+ private resolveItemKey(itemId: string): string {
173
+ return this.store.resolveEntityId(this.itemType, itemId)
174
+ }
175
+
176
+ /**
177
+ * Drops cached item accessors for ids that have left the list. Without it the cache keeps an
178
+ * accessor for every id the relation has ever shown, for as long as the parent handle lives
179
+ * — a paginated / filtered / refetched has-many grows without bound.
180
+ *
181
+ * Runs before the accessors are resolved and never touches a live id, so an item that is
182
+ * still listed keeps the exact same proxy and the exact same handle behind it.
183
+ */
184
+ private syncItemHandleCache(liveIds: readonly string[]): void {
185
+ if (this.itemHandleCacheProxy.size === 0) return
186
+ this.canonicalizeItemHandleCache()
187
+
188
+ // While the parent persists, the presented list hides planned additions — pruning
189
+ // against it would drop handles that reappear as soon as the persist settles.
190
+ if (this.store.isPersisting(this.entityType, this.entityId)) return
191
+
192
+ const liveKeys = new Set(liveIds.map(id => this.resolveItemKey(id)))
193
+ for (const key of this.itemHandleCacheProxy.keys()) {
194
+ if (liveKeys.has(key)) continue
195
+ this.itemHandleCacheProxy.delete(key)
196
+ }
197
+ }
198
+
199
+ private canonicalizeItemHandleCache(): void {
200
+ for (const [key, proxy] of [...this.itemHandleCacheProxy]) {
201
+ const canonicalKey = this.resolveItemKey(key)
202
+ if (canonicalKey === key) continue
203
+ if (!this.itemHandleCacheProxy.has(canonicalKey)) {
204
+ this.itemHandleCacheProxy.set(canonicalKey, proxy)
205
+ }
206
+ this.itemHandleCacheProxy.delete(key)
207
+ }
208
+ }
209
+
210
+ /**
211
+ * @internal Occupancy of the item-accessor cache. Exposed only so eviction tests can
212
+ * assert the cache stays bounded; not part of the HasManyAccessor API.
213
+ */
214
+ get itemHandleCacheSize(): number {
215
+ return this.itemHandleCacheProxy.size
216
+ }
217
+
133
218
  /**
134
219
  * Propagates the parent's embedded has-many data into per-item snapshots and
135
220
  * ensures the has-many state exists in the store.
@@ -139,6 +224,12 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
139
224
  * without ever reading `items`, and that path must also see populated data.
140
225
  * Idempotent within a render — guarded by hasEmbeddedDataChanged.
141
226
  *
227
+ * For the same reason EVERY entry point that reads or writes the has-many state
228
+ * calls this first — isDirty and the mutators as well as items/getById. An
229
+ * embedded list has no state in the store until it is materialised, so a
230
+ * consumer that never iterated saw isDirty === false and had its mutation land
231
+ * on a state nobody reads. Keep new entry points on this guard.
232
+ *
142
233
  * @returns false when there is no embedded list data to materialise.
143
234
  */
144
235
  private materializeEmbeddedItems(): boolean {
@@ -274,11 +365,13 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
274
365
  * Returns selection-aware EntityAccessor that supports direct field access.
275
366
  */
276
367
  getItemHandle(itemId: string): EntityAccessor<TEntity, TSelected> {
277
- let proxy = this.itemHandleCacheProxy.get(itemId)
368
+ this.canonicalizeItemHandleCache()
369
+ const key = this.resolveItemKey(itemId)
370
+ let proxy = this.itemHandleCacheProxy.get(key)
278
371
 
279
372
  if (!proxy) {
280
373
  const raw = EntityHandle.createRaw<TEntity, TSelected>(
281
- itemId,
374
+ key,
282
375
  this.itemType,
283
376
  this.store,
284
377
  this.dispatcher,
@@ -287,8 +380,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
287
380
  this.selection,
288
381
  )
289
382
  proxy = EntityHandle.wrapProxy(raw)
290
- this.itemHandleCacheRaw.set(itemId, raw)
291
- this.itemHandleCacheProxy.set(itemId, proxy)
383
+ this.itemHandleCacheProxy.set(key, proxy)
292
384
  }
293
385
 
294
386
  return proxy
@@ -311,6 +403,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
311
403
  * Checks if the list is dirty (items added/removed/moved/connected/disconnected).
312
404
  */
313
405
  get isDirty(): boolean {
406
+ this.materializeEmbeddedItems()
407
+
314
408
  const state = this.store.getHasMany(
315
409
  this.entityType,
316
410
  this.entityId,
@@ -322,8 +416,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
322
416
 
323
417
  return (
324
418
  state.plannedRemovals.size > 0 ||
325
- state.plannedConnections.size > 0 ||
326
- state.createdEntities.size > 0 ||
419
+ state.plannedAdditions.size > 0 ||
327
420
  state.orderedIds !== null
328
421
  )
329
422
  }
@@ -340,6 +433,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
340
433
  * Connects an existing entity to this has-many relation.
341
434
  */
342
435
  connect(itemId: string): void {
436
+ this.materializeEmbeddedItems()
343
437
  this.dispatcher.dispatch(
344
438
  connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.itemType, this.alias),
345
439
  )
@@ -351,6 +445,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
351
445
  * For created entities (via add()), cancels the add operation.
352
446
  */
353
447
  disconnect(itemId: string): void {
448
+ this.materializeEmbeddedItems()
354
449
  this.dispatcher.dispatch(
355
450
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'disconnect', this.alias),
356
451
  )
@@ -362,6 +457,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
362
457
  * For created entities (via add()), cancels the add operation.
363
458
  */
364
459
  delete(itemId: string): void {
460
+ this.materializeEmbeddedItems()
365
461
  this.dispatcher.dispatch(
366
462
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, 'delete', this.alias),
367
463
  )
@@ -373,16 +469,21 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
373
469
  * For connecting existing entities, use connect() instead.
374
470
  */
375
471
  add(data?: Partial<TEntity>): string {
472
+ this.materializeEmbeddedItems()
376
473
 
377
- // Pre-create entity so we can return tempId
378
- const tempId = this.store.createEntity(this.itemType, data as Record<string, unknown>)
474
+ // One gesture = one undo entry: the pre-create and the list-add are journaled
475
+ // together so undo removes (and redo re-creates) the whole row.
476
+ return this.store.transaction(() => {
477
+ // Pre-create entity so we can return tempId
478
+ const tempId = this.store.createEntity(this.itemType, data as Record<string, unknown>)
379
479
 
380
- // Add to has-many relation through dispatcher (enables events/interceptors)
381
- this.dispatcher.dispatch(
382
- addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias),
383
- )
480
+ // Add to has-many relation through dispatcher (enables events/interceptors)
481
+ this.dispatcher.dispatch(
482
+ addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias),
483
+ )
384
484
 
385
- return tempId
485
+ return tempId
486
+ })
386
487
  }
387
488
 
388
489
  /**
@@ -395,6 +496,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
395
496
  * Falls back to disconnect when schema metadata is not available.
396
497
  */
397
498
  remove(itemId: string): void {
499
+ this.materializeEmbeddedItems()
398
500
  this.dispatcher.dispatch(
399
501
  removeFromList(this.entityType, this.entityId, this.fieldName, itemId, this.resolveRemovalType(), this.alias),
400
502
  )
@@ -425,6 +527,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
425
527
  * use an 'order' field on the entity.
426
528
  */
427
529
  move(fromIndex: number, toIndex: number): void {
530
+ this.materializeEmbeddedItems()
428
531
  this.dispatcher.dispatch(
429
532
  moveInList(this.entityType, this.entityId, this.fieldName, fromIndex, toIndex, this.alias),
430
533
  )
@@ -435,6 +538,7 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
435
538
  * Clears all planned connections and removals.
436
539
  */
437
540
  reset(): void {
541
+ this.materializeEmbeddedItems()
438
542
  this.store.resetHasMany(
439
543
  this.entityType,
440
544
  this.entityId,