@contember/bindx 0.1.46 → 0.1.47

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 (118) hide show
  1. package/dist/core/ActionDispatcher.d.ts +4 -1
  2. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  3. package/dist/core/actions.d.ts +7 -1
  4. package/dist/core/actions.d.ts.map +1 -1
  5. package/dist/events/EventEmitter.d.ts +13 -1
  6. package/dist/events/EventEmitter.d.ts.map +1 -1
  7. package/dist/handles/BaseHandle.d.ts +14 -3
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/EntityHandle.d.ts +14 -5
  10. package/dist/handles/EntityHandle.d.ts.map +1 -1
  11. package/dist/handles/FieldHandle.d.ts +11 -4
  12. package/dist/handles/FieldHandle.d.ts.map +1 -1
  13. package/dist/handles/HasManyListHandle.d.ts +36 -2
  14. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  15. package/dist/handles/HasOneHandle.d.ts +52 -9
  16. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  17. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  18. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  19. package/dist/handles/index.d.ts +1 -1
  20. package/dist/handles/index.d.ts.map +1 -1
  21. package/dist/handles/proxyFactory.d.ts +7 -1
  22. package/dist/handles/proxyFactory.d.ts.map +1 -1
  23. package/dist/handles/types.d.ts +33 -0
  24. package/dist/handles/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/persistence/BatchPersister.d.ts +53 -37
  28. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  29. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  30. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  31. package/dist/persistence/MutationCollector.d.ts +106 -3
  32. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  33. package/dist/persistence/PersistExecution.d.ts +26 -0
  34. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  35. package/dist/persistence/types.d.ts +8 -2
  36. package/dist/persistence/types.d.ts.map +1 -1
  37. package/dist/store/EntityMetaStore.d.ts +32 -10
  38. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  39. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  40. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  41. package/dist/store/ErrorStore.d.ts +3 -2
  42. package/dist/store/ErrorStore.d.ts.map +1 -1
  43. package/dist/store/HasManyStore.d.ts +214 -0
  44. package/dist/store/HasManyStore.d.ts.map +1 -0
  45. package/dist/store/HasOneStore.d.ts +159 -0
  46. package/dist/store/HasOneStore.d.ts.map +1 -0
  47. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  48. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  49. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  50. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  51. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  52. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  53. package/dist/store/RelationStore.d.ts +74 -137
  54. package/dist/store/RelationStore.d.ts.map +1 -1
  55. package/dist/store/RootRegistry.d.ts +25 -4
  56. package/dist/store/RootRegistry.d.ts.map +1 -1
  57. package/dist/store/SnapshotStore.d.ts +137 -13
  58. package/dist/store/SnapshotStore.d.ts.map +1 -1
  59. package/dist/store/SubscriptionManager.d.ts +63 -14
  60. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  61. package/dist/store/TouchedStore.d.ts +3 -2
  62. package/dist/store/TouchedStore.d.ts.map +1 -1
  63. package/dist/store/relationKey.d.ts +15 -0
  64. package/dist/store/relationKey.d.ts.map +1 -0
  65. package/dist/undo/UndoJournal.d.ts +133 -0
  66. package/dist/undo/UndoJournal.d.ts.map +1 -0
  67. package/dist/undo/UndoManager.d.ts +25 -82
  68. package/dist/undo/UndoManager.d.ts.map +1 -1
  69. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  70. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  71. package/dist/undo/index.d.ts +3 -1
  72. package/dist/undo/index.d.ts.map +1 -1
  73. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  74. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  75. package/dist/undo/types.d.ts +0 -48
  76. package/dist/undo/types.d.ts.map +1 -1
  77. package/package.json +2 -2
  78. package/src/core/ActionDispatcher.ts +56 -36
  79. package/src/core/actions.ts +8 -1
  80. package/src/events/EventEmitter.ts +78 -24
  81. package/src/handles/BaseHandle.ts +19 -2
  82. package/src/handles/EntityHandle.ts +48 -17
  83. package/src/handles/FieldHandle.ts +36 -10
  84. package/src/handles/HasManyListHandle.ts +120 -16
  85. package/src/handles/HasOneHandle.ts +177 -44
  86. package/src/handles/PlaceholderHandle.ts +184 -36
  87. package/src/handles/index.ts +3 -0
  88. package/src/handles/proxyFactory.ts +27 -0
  89. package/src/handles/types.ts +39 -0
  90. package/src/index.ts +4 -4
  91. package/src/persistence/BatchPersister.ts +810 -446
  92. package/src/persistence/ChangeRegistry.ts +27 -3
  93. package/src/persistence/MutationCollector.ts +265 -31
  94. package/src/persistence/PersistExecution.ts +217 -0
  95. package/src/persistence/types.ts +8 -2
  96. package/src/store/EntityMetaStore.ts +89 -45
  97. package/src/store/EntitySnapshotStore.ts +102 -19
  98. package/src/store/ErrorStore.ts +6 -5
  99. package/src/store/HasManyStore.ts +810 -0
  100. package/src/store/HasOneStore.ts +454 -0
  101. package/src/store/ReachabilityAnalyzer.ts +37 -0
  102. package/src/store/RekeyOrchestrator.ts +123 -0
  103. package/src/store/RelationEdgeIndex.ts +79 -0
  104. package/src/store/RelationStore.ts +176 -725
  105. package/src/store/RootRegistry.ts +50 -8
  106. package/src/store/SnapshotStore.ts +572 -101
  107. package/src/store/SubscriptionManager.ts +122 -75
  108. package/src/store/TouchedStore.ts +5 -2
  109. package/src/store/relationKey.ts +20 -0
  110. package/src/undo/UndoJournal.ts +258 -0
  111. package/src/undo/UndoManager.ts +151 -281
  112. package/src/undo/UnrecordedWriteError.ts +23 -0
  113. package/src/undo/index.ts +8 -7
  114. package/src/undo/rekeyJournalEntry.ts +145 -0
  115. package/src/undo/types.ts +0 -58
  116. package/dist/core/actionClassification.d.ts +0 -48
  117. package/dist/core/actionClassification.d.ts.map +0 -1
  118. package/src/core/actionClassification.ts +0 -199
@@ -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,
@@ -1,6 +1,7 @@
1
1
  import { EntityRelatedHandle, embeddedDataMatchesSnapshot } from './BaseHandle.js'
2
2
  import type { ActionDispatcher } from '../core/ActionDispatcher.js'
3
3
  import type { SnapshotStore } from '../store/SnapshotStore.js'
4
+ import type { StoredRelationState } from '../store/RelationStore.js'
4
5
  import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
5
6
  import type { SelectionMeta } from '../selection/types.js'
6
7
  import {
@@ -17,6 +18,7 @@ import {
17
18
  type Unsubscribe,
18
19
  type EntityAccessor,
19
20
  type HasOneAccessor,
21
+ type HasOneRelationState,
20
22
  } from './types.js'
21
23
  import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js'
22
24
  import type {
@@ -63,6 +65,10 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
63
65
  private readonly schema: SchemaRegistry,
64
66
  brands?: Set<symbol>,
65
67
  private readonly selection?: SelectionMeta,
68
+ private readonly dataFieldName: string = fieldName,
69
+ // Segments from the query root down to this relation; also the entity path
70
+ // of whatever it resolves to. See FieldRefMeta.fullPath.
71
+ private readonly relationPath: readonly string[] = [fieldName],
66
72
  ) {
67
73
  super(parentEntityType, parentEntityId, store, dispatcher)
68
74
  this.__brands = brands
@@ -78,8 +84,10 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
78
84
  schema: SchemaRegistry,
79
85
  brands?: Set<symbol>,
80
86
  selection?: SelectionMeta,
87
+ dataFieldName?: string,
88
+ relationPath?: readonly string[],
81
89
  ): HasOneAccessor<TEntity, TSelected> {
82
- return createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection), (target) => target.entityRaw.fields)
90
+ return HasOneHandle.wrapProxy(new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath))
83
91
  }
84
92
 
85
93
  static createRaw<TEntity extends object = object, TSelected = TEntity>(
@@ -92,12 +100,18 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
92
100
  schema: SchemaRegistry,
93
101
  brands?: Set<symbol>,
94
102
  selection?: SelectionMeta,
103
+ dataFieldName?: string,
104
+ relationPath?: readonly string[],
95
105
  ): HasOneHandle<TEntity, TSelected> {
96
- return new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection)
106
+ return new HasOneHandle<TEntity, TSelected>(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath)
97
107
  }
98
108
 
99
109
  static wrapProxy<TEntity extends object, TSelected>(handle: HasOneHandle<TEntity, TSelected>): HasOneAccessor<TEntity, TSelected> {
100
- return createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(handle, (target) => target.entityRaw.fields)
110
+ return createHandleProxy<HasOneHandle<TEntity, TSelected>, HasOneAccessor<TEntity, TSelected>>(
111
+ handle,
112
+ (target) => target.entityRaw.fields,
113
+ (target) => target.entityRaw.selectedFieldNames,
114
+ )
101
115
  }
102
116
 
103
117
  /**
@@ -113,6 +127,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
113
127
  isArray: false,
114
128
  isRelation: true,
115
129
  targetType: this.targetType,
130
+ fullPath: this.relationPath,
116
131
  }
117
132
  }
118
133
 
@@ -137,51 +152,141 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
137
152
 
138
153
  /**
139
154
  * Gets the relation state.
140
- * Falls back to snapshot data when no explicit relation state exists,
141
- * so server-loaded has-one relations report 'connected' without
142
- * requiring a prior RelationStore entry.
155
+ * Materializes the RelationStore entry from embedded snapshot data first, so a
156
+ * server-loaded has-one reports 'connected' without a prior explicit entry.
143
157
  */
144
- get state(): 'connected' | 'disconnected' | 'deleted' | 'creating' {
145
- const relation = this.store.getRelation(
158
+ get state(): HasOneRelationState {
159
+ this.ensureEntry()
160
+ const relation = this.store.getPresentationRelation(
146
161
  this.entityType,
147
162
  this.entityId,
148
163
  this.fieldName,
149
164
  )
150
- if (relation) {
151
- return relation.state
152
- }
153
- // No explicit relation state — check if snapshot has embedded data
154
- if (this.relatedId !== null) {
155
- return 'connected'
156
- }
157
- return 'disconnected'
165
+ return relation?.state ?? 'disconnected'
158
166
  }
159
167
 
160
168
  /**
161
- * Gets the current related entity ID.
162
- * Falls back to entity data if relation state is not initialized.
169
+ * Gets the current related entity ID — read exclusively from the RelationStore
170
+ * after materializing the entry from embedded snapshot data.
163
171
  */
164
172
  get relatedId(): string | null {
165
- // First check relation state (for manual changes like connect/disconnect)
166
- const relation = this.store.getRelation(
173
+ this.ensureEntry()
174
+ const relation = this.store.getPresentationRelation(
167
175
  this.entityType,
168
176
  this.entityId,
169
177
  this.fieldName,
170
178
  )
171
- if (relation) {
172
- return relation.currentId
179
+ return relation?.currentId ?? null
180
+ }
181
+
182
+ /**
183
+ * Materializes this has-one's RelationStore entry from the parent's embedded
184
+ * snapshot data, so the store is the single source of truth (symmetric with
185
+ * {@link HasManyListHandle.materializeEmbeddedItems}).
186
+ *
187
+ * Only the relation entry is touched here — child-snapshot propagation stays in
188
+ * {@link ensureRelatedEntitySnapshot}, which owns the per-relation propagation
189
+ * slot so the two paths never double-consume it.
190
+ *
191
+ * - No entry yet + the parent embeds a related object with an id → create a
192
+ * `connected` entry (non-notifying) whose server baseline comes from the
193
+ * parent's serverData, so a freshly loaded relation is not dirty
194
+ * (currentId === serverId, state === serverState).
195
+ * - Existing entry + parent re-fetch (embedded reference changed) that is NOT
196
+ * locally dirty → advance the server baseline to the new related id.
197
+ * - A local connect/disconnect, a placeholder, or a `creating` entry is left
198
+ * untouched — it is detected as a locally-dirty relation.
199
+ * - No embedded data and no entry → the relation stays unmaterialized (null).
200
+ */
201
+ private ensureEntry(): void {
202
+ const existing = this.store.getRelation(this.entityType, this.entityId, this.fieldName)
203
+ const embeddedData = this.readEmbeddedRelatedData()
204
+ const embeddedReference = readEmbeddedRelatedReference(embeddedData)
205
+
206
+ if (!existing) {
207
+ if (embeddedReference.kind !== 'connected') return
208
+ const serverId = this.readServerRelatedId()
209
+ this.store.getOrCreateRelation(this.entityType, this.entityId, this.fieldName, {
210
+ currentId: embeddedReference.id,
211
+ serverId,
212
+ state: 'connected',
213
+ serverState: serverId !== null ? 'connected' : 'disconnected',
214
+ placeholderData: {},
215
+ })
216
+ return
173
217
  }
174
218
 
175
- // Fallback to entity snapshot data (for server-loaded data)
176
- const parentSnapshot = this.store.getEntitySnapshot(this.entityType, this.entityId)
177
- if (parentSnapshot?.data) {
178
- const relatedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
179
- if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
180
- return (relatedData as { id: string }).id
181
- }
219
+ this.advanceServerBaselineOnRefetch(existing, embeddedReference, embeddedData)
220
+ }
221
+
222
+ /**
223
+ * On a parent re-fetch (embedded reference changed) advances the relation's
224
+ * server baseline to the embedded related id, but only when the relation is
225
+ * not locally dirty — a local connect/disconnect/create must survive a re-fetch.
226
+ *
227
+ * Does NOT consume the propagation slot — {@link ensureRelatedEntitySnapshot}
228
+ * owns it. The same reference-change signal drives both, so both run within the
229
+ * one render that observes a new parent reference.
230
+ *
231
+ * The baseline write is NON-NOTIFYING: it runs during a render-phase read, and
232
+ * the parent re-fetch that produced the new embedded reference already notified
233
+ * subscribers. Notifying again here would mutate the store and synchronously call
234
+ * subscribers mid-render, violating the external-store contract (cf.
235
+ * {@link ensureRelatedEntitySnapshot}, which refreshes server data with skipNotify
236
+ * for the same reason).
237
+ */
238
+ private advanceServerBaselineOnRefetch(
239
+ existing: StoredRelationState,
240
+ embeddedReference: EmbeddedRelatedReference,
241
+ embeddedData: unknown,
242
+ ): void {
243
+ if (embeddedReference.kind === 'absent') return
244
+ if (this.isLocallyDirty(existing)) return
245
+
246
+ if (embeddedReference.kind === 'null' && existing.serverId === null && existing.serverState === 'disconnected') {
247
+ return
182
248
  }
249
+ if (embeddedReference.kind === 'connected' && existing.serverId === embeddedReference.id && existing.serverState === 'connected') {
250
+ return
251
+ }
252
+ if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) {
253
+ return
254
+ }
255
+
256
+ if (embeddedReference.kind === 'null') {
257
+ this.store.setRelation(this.entityType, this.entityId, this.fieldName, {
258
+ currentId: null,
259
+ serverId: null,
260
+ state: 'disconnected',
261
+ serverState: 'disconnected',
262
+ }, true)
263
+ return
264
+ }
265
+
266
+ this.store.setRelation(this.entityType, this.entityId, this.fieldName, {
267
+ currentId: embeddedReference.id,
268
+ serverId: embeddedReference.id,
269
+ state: 'connected',
270
+ serverState: 'connected',
271
+ }, true)
272
+ }
183
273
 
184
- return null
274
+ private isLocallyDirty(relation: StoredRelationState): boolean {
275
+ return (
276
+ relation.currentId !== relation.serverId ||
277
+ relation.state !== relation.serverState ||
278
+ Object.keys(relation.placeholderData).length > 0
279
+ )
280
+ }
281
+
282
+ /** Reads the embedded related object from the parent's canonical current data. */
283
+ private readEmbeddedRelatedData(): unknown {
284
+ return this.getEntityData()?.[this.dataFieldName]
285
+ }
286
+
287
+ /** Extracts the related id from the parent's embedded server data, or null. */
288
+ private readServerRelatedId(): string | null {
289
+ return extractRelatedId(this.getServerData()?.[this.dataFieldName])
185
290
  }
186
291
 
187
292
  /**
@@ -222,6 +327,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
222
327
  this.schema,
223
328
  this.__brands,
224
329
  this.selection,
330
+ this.relationPath,
225
331
  )
226
332
  this.entityHandleCacheProxy = EntityHandle.wrapProxy(this.entityHandleCacheRaw)
227
333
  }
@@ -238,6 +344,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
238
344
  this.dispatcher,
239
345
  this.schema,
240
346
  this.__brands,
347
+ this.relationPath,
241
348
  )
242
349
  this.placeholderCacheProxy = PlaceholderHandle.wrapProxy(this.placeholderCacheRaw)
243
350
  }
@@ -289,7 +396,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
289
396
  return
290
397
  }
291
398
 
292
- const embeddedData = (parentSnapshot.data as Record<string, unknown>)[this.fieldName]
399
+ const embeddedData = (parentSnapshot.data as Record<string, unknown>)[this.dataFieldName]
293
400
  if (!embeddedData || typeof embeddedData !== 'object') {
294
401
  return
295
402
  }
@@ -307,7 +414,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
307
414
  // A new reference means the parent was re-fetched from the server.
308
415
  // Same reference means the embedded data is stale and must not overwrite
309
416
  // child state that may have been updated by a local commit.
310
- if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) {
417
+ if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) {
311
418
  return
312
419
  }
313
420
 
@@ -316,7 +423,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
316
423
  // (e.g. polling). A new reference with identical values means no actual change.
317
424
  const existing = this.store.getEntitySnapshot(this.targetType, id)
318
425
  if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record<string, unknown>, existing.serverData as Record<string, unknown>)) {
319
- this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData)
426
+ this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
320
427
  return
321
428
  }
322
429
 
@@ -331,7 +438,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
331
438
  embeddedData as Record<string, unknown>,
332
439
  true, // skipNotify - called during render, data already exists embedded in parent
333
440
  )
334
- this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData)
441
+ this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData)
335
442
  }
336
443
 
337
444
  /**
@@ -354,6 +461,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
354
461
  * Checks if the relation is dirty.
355
462
  */
356
463
  get isDirty(): boolean {
464
+ this.ensureEntry()
357
465
  const relation = this.store.getRelation(
358
466
  this.entityType,
359
467
  this.entityId,
@@ -361,11 +469,7 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
361
469
  )
362
470
  if (!relation) return false
363
471
 
364
- return (
365
- relation.currentId !== relation.serverId ||
366
- relation.state !== relation.serverState ||
367
- Object.keys(relation.placeholderData).length > 0
368
- )
472
+ return this.isLocallyDirty(relation)
369
473
  }
370
474
 
371
475
  /**
@@ -374,11 +478,15 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
374
478
  * Accessible via proxy as `$create()`.
375
479
  */
376
480
  create(data?: Partial<TEntity>): string {
377
- const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
378
- this.dispatcher.dispatch(
379
- connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
380
- )
381
- return tempId
481
+ // One gesture = one undo entry: the pre-create and the connect are journaled
482
+ // together so undo removes (and redo re-creates) the created target.
483
+ return this.store.transaction(() => {
484
+ const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
485
+ this.dispatcher.dispatch(
486
+ connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
487
+ )
488
+ return tempId
489
+ })
382
490
  }
383
491
 
384
492
  /**
@@ -574,3 +682,28 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
574
682
  }
575
683
 
576
684
  }
685
+
686
+ type EmbeddedRelatedReference =
687
+ | { kind: 'absent' }
688
+ | { kind: 'null' }
689
+ | { kind: 'connected'; id: string }
690
+
691
+ /**
692
+ * Reads an embedded has-one value while preserving explicit null.
693
+ */
694
+ function readEmbeddedRelatedReference(embedded: unknown): EmbeddedRelatedReference {
695
+ if (embedded === undefined) return { kind: 'absent' }
696
+ if (embedded === null) return { kind: 'null' }
697
+ if (typeof embedded !== 'object' || !('id' in embedded)) return { kind: 'absent' }
698
+ const id = embedded.id
699
+ return typeof id === 'string' ? { kind: 'connected', id } : { kind: 'absent' }
700
+ }
701
+
702
+ /**
703
+ * Extracts the related entity id from an embedded has-one object, or null when
704
+ * the value is not an object with a string id.
705
+ */
706
+ function extractRelatedId(embedded: unknown): string | null {
707
+ const reference = readEmbeddedRelatedReference(embedded)
708
+ return reference.kind === 'connected' ? reference.id : null
709
+ }