@contember/bindx 0.1.45 → 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 +121 -17
  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
@@ -1,10 +1,14 @@
1
1
  import type { EntitySnapshot, LoadStatus } from './snapshots.js'
2
+ import { createEntitySnapshot } from './snapshots.js'
2
3
  import type { FieldError } from '../errors/types.js'
3
4
  import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js'
4
5
  import { ErrorStore } from './ErrorStore.js'
5
6
  import {
6
7
  RelationStore,
7
8
  type HasManyRemovalType,
9
+ type RelationReconciliationResult,
10
+ type SentHasManyDelta,
11
+ type SentHasOneTransition,
8
12
  type StoredHasManyState,
9
13
  type StoredRelationState,
10
14
  } from './RelationStore.js'
@@ -15,8 +19,28 @@ import { DirtyTracker } from './DirtyTracker.js'
15
19
  import { EntitySnapshotStore } from './EntitySnapshotStore.js'
16
20
  import { RootRegistry } from './RootRegistry.js'
17
21
  import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js'
18
-
19
- export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js'
22
+ import { RekeyOrchestrator } from './RekeyOrchestrator.js'
23
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
24
+ import type {
25
+ UndoJournal,
26
+ JournalTarget,
27
+ JournalCellImage,
28
+ EntityCellImage,
29
+ RelationCellImage,
30
+ HasManyCellImage,
31
+ EditableWriteCounters,
32
+ } from '../undo/UndoJournal.js'
33
+
34
+ export type {
35
+ HasManyRemovalType,
36
+ RelationReconciliationResult,
37
+ SentHasManyAddition,
38
+ SentHasManyDelta,
39
+ SentHasManyRemoval,
40
+ SentHasOneTransition,
41
+ StoredHasManyState,
42
+ StoredRelationState,
43
+ } from './RelationStore.js'
20
44
  export type { EntityMeta } from './EntityMetaStore.js'
21
45
  export { isTempId, isPlaceholderId, isPersistedId, generatePlaceholderId } from './entityId.js'
22
46
 
@@ -39,7 +63,7 @@ type Subscriber = () => void
39
63
  * - EntityMetaStore — entity metadata, load state, persisting, temp ID mapping
40
64
  * - TouchedStore — field touched state tracking
41
65
  */
42
- export class SnapshotStore implements SnapshotVersionBumper {
66
+ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
43
67
  private readonly entitySnapshots = new EntitySnapshotStore()
44
68
  private readonly subscriptions = new SubscriptionManager()
45
69
  private readonly errors = new ErrorStore()
@@ -49,6 +73,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
49
73
  private readonly roots = new RootRegistry()
50
74
  private readonly reachability: ReachabilityAnalyzer
51
75
  private readonly dirtyTracker: DirtyTracker
76
+ private readonly rekeyOrchestrator: RekeyOrchestrator
52
77
 
53
78
  /**
54
79
  * Tracks the last embedded data reference propagated from parent to child.
@@ -58,37 +83,120 @@ export class SnapshotStore implements SnapshotVersionBumper {
58
83
  */
59
84
  private readonly lastPropagatedData = new Map<string, unknown>()
60
85
 
86
+ /**
87
+ * Optional write-journal. When set (by an attached UndoManager), mutating
88
+ * methods record editable-layer pre-images of the cells they touch so a gesture
89
+ * can be undone. Null ⇒ no undo tracking and the transaction helpers are no-ops.
90
+ */
91
+ private journal: UndoJournal | null = null
92
+
61
93
  constructor() {
62
94
  this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots)
63
95
  this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability)
96
+ // Parent re-render propagation is derived from the relation store's live
97
+ // edges — the single source of truth for relation membership — rather than
98
+ // a separate parent-child registry.
99
+ this.subscriptions.setParentKeyLookup(this.relations)
100
+ // The participants are visited in this exact order on every rekey — see the
101
+ // ordering contract in RekeyOrchestrator.rekey(). Propagation tracking lives
102
+ // on this store, so it joins as a small inline adapter.
103
+ this.rekeyOrchestrator = new RekeyOrchestrator([
104
+ this.roots,
105
+ this.entitySnapshots,
106
+ this.meta,
107
+ this.subscriptions,
108
+ this.relations,
109
+ this.errors,
110
+ this.touched,
111
+ { rekey: ctx => this.rekeyPropagatedData(ctx.oldKeyPrefix, ctx.newKeyPrefix) },
112
+ ])
113
+ }
114
+
115
+ // ==================== Undo Journal ====================
116
+
117
+ /**
118
+ * Attaches (or detaches) the write-journal. Called by UndoManager when it is
119
+ * wired to a dispatcher.
120
+ */
121
+ setJournal(journal: UndoJournal | null): void {
122
+ this.journal = journal
64
123
  }
65
124
 
66
- // ==================== Key Generation ====================
125
+ clearJournal(journal: UndoJournal): void {
126
+ if (this.journal === journal) {
127
+ this.journal = null
128
+ }
129
+ }
67
130
 
68
- /** Maps temp ID entity keys to their persisted ID entity keys for transparent resolution */
69
- private readonly rekeyedEntities = new Map<string, string>()
131
+ private recordExistingEntity(key: string): void {
132
+ if (this.entitySnapshots.has(key)) {
133
+ this.journal?.recordEntity(key)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Aggregates the sub-stores' editable-layer write counters into the three
139
+ * journal kinds. The "entity" kind fuses snapshot + meta + roots — the cells a
140
+ * single {@link recordEntity} pre-image captures together. Read by the journal's
141
+ * write-guard at each transaction boundary; a few integer adds, hot-path cheap.
142
+ */
143
+ getEditableWriteCounters(): EditableWriteCounters {
144
+ return {
145
+ entity: this.entitySnapshots.getEditableWriteVersion()
146
+ + this.meta.getEditableWriteVersion()
147
+ + this.roots.getEditableWriteVersion(),
148
+ relation: this.relations.getEditableHasOneWriteVersion(),
149
+ hasMany: this.relations.getEditableHasManyWriteVersion(),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Opens a journal transaction (re-entrant). All primary-store writes until the
155
+ * matching {@link commitTransaction} are captured as ONE undoable gesture.
156
+ */
157
+ beginTransaction(): void {
158
+ this.journal?.begin()
159
+ }
160
+
161
+ commitTransaction(): void {
162
+ this.journal?.commit()
163
+ }
164
+
165
+ /**
166
+ * Runs a gesture as a single undoable transaction. Used by handle operations
167
+ * (e.g. list add / has-one create) that write to the store outside the
168
+ * dispatcher, so their pre-create writes join the same undo entry.
169
+ */
170
+ transaction<T>(fn: () => T): T {
171
+ this.beginTransaction()
172
+ try {
173
+ return fn()
174
+ } finally {
175
+ this.commitTransaction()
176
+ }
177
+ }
178
+
179
+ // ==================== Key Generation ====================
70
180
 
71
181
  private getEntityKey(entityType: string, id: string): string {
72
- const key = `${entityType}:${id}`
73
- return this.rekeyedEntities.get(key) ?? key
182
+ return this.rekeyOrchestrator.resolveKey(entityType, id)
74
183
  }
75
184
 
76
185
  private getRelationKey(parentType: string, parentId: string, fieldName: string): string {
77
- const resolvedParentId = this.resolveId(parentType, parentId)
186
+ const resolvedParentId = this.resolveEntityId(parentType, parentId)
78
187
  return `${parentType}:${resolvedParentId}:${fieldName}`
79
188
  }
80
189
 
81
190
  /**
82
191
  * Resolves an ID to its persisted ID if it has been rekeyed.
83
192
  */
84
- private resolveId(entityType: string, id: string): string {
85
- const key = `${entityType}:${id}`
86
- const rekeyed = this.rekeyedEntities.get(key)
87
- if (rekeyed) {
88
- // Extract the ID from the rekeyed key (format: "EntityType:id")
89
- return rekeyed.slice(entityType.length + 1)
90
- }
91
- return id
193
+ resolveEntityId(entityType: string, id: string): string {
194
+ return this.rekeyOrchestrator.resolveId(entityType, id)
195
+ }
196
+
197
+ /** Attaches identity-keyed state to the store's single rekey fan-out. */
198
+ attachRekeyParticipant(participant: Rekeyable): void {
199
+ this.rekeyOrchestrator.registerParticipant(participant)
92
200
  }
93
201
 
94
202
  // ==================== SnapshotVersionBumper ====================
@@ -134,7 +242,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
134
242
  * Removes all propagation tracking entries for a given parent entity.
135
243
  */
136
244
  clearPropagatedDataForEntity(parentType: string, parentId: string): void {
137
- const prefix = `${parentType}:${this.resolveId(parentType, parentId)}:`
245
+ const prefix = `${parentType}:${this.resolveEntityId(parentType, parentId)}:`
138
246
  for (const key of this.lastPropagatedData.keys()) {
139
247
  if (key.startsWith(prefix)) {
140
248
  this.lastPropagatedData.delete(key)
@@ -174,7 +282,11 @@ export class SnapshotStore implements SnapshotVersionBumper {
174
282
  skipNotify: boolean = false,
175
283
  ): EntitySnapshot<T> {
176
284
  const key = this.getEntityKey(entityType, id)
177
- const newSnapshot = this.entitySnapshots.setData(key, id, entityType, data, isServerData)
285
+ // Server loads are not user gestures; only journal local data sets (incl. the
286
+ // initial write of a freshly created entity, which captures an absent pre-image).
287
+ if (!isServerData) this.journal?.recordEntity(key)
288
+ // The caller may still hold a rekeyed temp id; the snapshot must carry the live one.
289
+ const newSnapshot = this.entitySnapshots.setData(key, this.resolveEntityId(entityType, id), entityType, data, isServerData)
178
290
 
179
291
  if (isServerData) {
180
292
  this.meta.setExistsOnServer(key, true)
@@ -198,7 +310,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
198
310
  skipNotify: boolean = false,
199
311
  ): EntitySnapshot<T> {
200
312
  const key = this.getEntityKey(entityType, id)
201
- const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data)
313
+ const newSnapshot = this.entitySnapshots.refreshServerData(key, this.resolveEntityId(entityType, id), entityType, data)
202
314
  this.meta.setExistsOnServer(key, true)
203
315
  if (!skipNotify) {
204
316
  this.notifyEntitySubscribers(key)
@@ -212,6 +324,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
212
324
  updates: Partial<T>,
213
325
  ): EntitySnapshot<T> | undefined {
214
326
  const key = this.getEntityKey(entityType, id)
327
+ this.recordExistingEntity(key)
215
328
  const result = this.entitySnapshots.updateFields<T>(key, updates)
216
329
  if (result) {
217
330
  this.notifyEntitySubscribers(key)
@@ -226,6 +339,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
226
339
  value: unknown,
227
340
  ): void {
228
341
  const key = this.getEntityKey(entityType, id)
342
+ this.recordExistingEntity(key)
229
343
  if (this.entitySnapshots.setFieldValue(key, fieldPath, value)) {
230
344
  this.notifyEntitySubscribers(key)
231
345
  }
@@ -239,6 +353,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
239
353
 
240
354
  resetEntity(entityType: string, id: string): void {
241
355
  const key = this.getEntityKey(entityType, id)
356
+ this.recordExistingEntity(key)
242
357
  this.entitySnapshots.reset(key)
243
358
  this.notifyEntitySubscribers(key)
244
359
  }
@@ -317,12 +432,14 @@ export class SnapshotStore implements SnapshotVersionBumper {
317
432
 
318
433
  scheduleForDeletion(entityType: string, id: string): void {
319
434
  const key = this.getEntityKey(entityType, id)
435
+ this.recordExistingEntity(key)
320
436
  this.meta.scheduleForDeletion(key)
321
437
  this.notifyEntitySubscribers(key)
322
438
  }
323
439
 
324
440
  unscheduleForDeletion(entityType: string, id: string): void {
325
441
  const key = this.getEntityKey(entityType, id)
442
+ this.recordExistingEntity(key)
326
443
  this.meta.unscheduleForDeletion(key)
327
444
  this.notifyEntitySubscribers(key)
328
445
  }
@@ -342,15 +459,21 @@ export class SnapshotStore implements SnapshotVersionBumper {
342
459
  const data = { ...initialData, id }
343
460
 
344
461
  this.setEntityData(entityType, id, data, false)
345
- this.setExistsOnServer(entityType, id, false)
346
462
 
347
463
  // A freshly created entity is pending-persist by default — a root for
348
464
  // reachability-based create detection. It stops being a root the moment a
349
465
  // relation anchors it as a child (see registerParentChild). A top-level
350
466
  // create (<Entity create>, useEntityList add) is never anchored, so it stays
351
467
  // a root and is reported as a `create`.
468
+ //
469
+ // Registered after the snapshot exists (the reachability seed skips a root
470
+ // without one) but BEFORE the notifying setExistsOnServer, so the last
471
+ // notification of the create already carries it — a subscriber reading
472
+ // getAllDirtyEntities() would otherwise miss the new create entirely.
352
473
  this.roots.register(this.getEntityKey(entityType, id))
353
474
 
475
+ this.setExistsOnServer(entityType, id, false)
476
+
354
477
  return id
355
478
  }
356
479
 
@@ -367,52 +490,32 @@ export class SnapshotStore implements SnapshotVersionBumper {
367
490
  }
368
491
 
369
492
  mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void {
370
- // Use raw keys (bypass resolveId) since we're the ones creating the mapping
371
- const oldKey = `${entityType}:${tempId}`
372
- const newKey = `${entityType}:${persistedId}`
373
- const oldKeyPrefix = `${entityType}:${tempId}:`
374
- const newKeyPrefix = `${entityType}:${persistedId}:`
375
-
376
- // Register redirect so future lookups by temp ID resolve to persisted key
377
- this.rekeyedEntities.set(oldKey, newKey)
378
-
379
- // Keep root registration consistent across the rekey (the persisted entity
380
- // is now a server root via existsOnServer, but keep the registry aligned).
381
- this.roots.rekey(oldKey, newKey)
382
-
383
- // Rekey entity snapshot (moves data, updates id field)
384
- this.entitySnapshots.rekey(oldKey, newKey, persistedId)
385
-
386
- // Rekey metadata FIRST, then set persisted mapping (which sets existsOnServer)
387
- this.meta.rekey(oldKey, newKey)
388
- this.meta.mapTempIdToPersistedId(newKey, persistedId)
389
-
390
- // Rekey subscriptions, parent-child relationships, and relation subscribers
391
- this.subscriptions.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
392
-
393
- // Rekey relations/hasMany owned by this entity (parent key changes)
394
- this.relations.rekeyOwner(oldKeyPrefix, newKeyPrefix)
395
-
396
- // Replace tempId with persistedId in all relation/hasMany VALUE references
397
- this.relations.replaceEntityId(tempId, persistedId)
398
-
399
- // Rekey errors, touched state, and propagation tracking
400
- this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
401
- this.touched.rekey(oldKeyPrefix, newKeyPrefix)
402
- this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix)
493
+ // The orchestrator owns the temp→persisted redirect and drives the rekey
494
+ // fan-out across every sub-store in its documented order.
495
+ this.rekeyOrchestrator.rekey(entityType, tempId, persistedId)
496
+
497
+ // Keep the undo journal aligned: rewrite stored keys + embedded id references
498
+ // in every entry, and seal the now-persisted create so undo can't delete it.
499
+ const ctx: RekeyContext = {
500
+ oldKey: `${entityType}:${tempId}`,
501
+ newKey: `${entityType}:${persistedId}`,
502
+ oldKeyPrefix: `${entityType}:${tempId}:`,
503
+ newKeyPrefix: `${entityType}:${persistedId}:`,
504
+ oldId: tempId,
505
+ newId: persistedId,
506
+ }
507
+ this.journal?.rekey(ctx)
403
508
 
404
- // Notify on the NEW key so React picks up the change
405
- this.notifyEntitySubscribers(newKey)
509
+ // Notify on the NEW key so React picks up the change.
510
+ this.notifyEntitySubscribers(`${entityType}:${persistedId}`)
406
511
  }
407
512
 
408
513
  getPersistedId(entityType: string, id: string): string | null {
409
- const key = this.getEntityKey(entityType, id)
410
- return this.meta.getPersistedId(key, id)
514
+ return this.rekeyOrchestrator.getPersistedId(entityType, id)
411
515
  }
412
516
 
413
517
  isNewEntity(entityType: string, id: string): boolean {
414
- const key = this.getEntityKey(entityType, id)
415
- return this.meta.isNewEntity(key, id)
518
+ return this.rekeyOrchestrator.isNewEntity(entityType, id)
416
519
  }
417
520
 
418
521
  // ==================== Has-Many State (delegated to RelationStore) ====================
@@ -459,6 +562,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
459
562
  alias?: string,
460
563
  ): void {
461
564
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
565
+ this.journal?.recordHasMany(key)
462
566
  this.relations.planHasManyRemoval(key, itemId, type)
463
567
  this.notifyRelationSubscribers(key)
464
568
  }
@@ -481,6 +585,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
481
585
  alias?: string,
482
586
  ): void {
483
587
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
588
+ this.journal?.recordHasMany(key)
484
589
  this.relations.planHasManyConnection(key, itemId)
485
590
  this.notifyRelationSubscribers(key)
486
591
  }
@@ -492,7 +597,9 @@ export class SnapshotStore implements SnapshotVersionBumper {
492
597
  alias?: string,
493
598
  ): Set<string> | undefined {
494
599
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
495
- return this.relations.getHasMany(key)?.plannedConnections
600
+ const state = this.relations.getHasMany(key)
601
+ if (!state) return undefined
602
+ return new Set(state.plannedAdditions.keys())
496
603
  }
497
604
 
498
605
  commitHasMany(
@@ -507,6 +614,19 @@ export class SnapshotStore implements SnapshotVersionBumper {
507
614
  this.notifyRelationSubscribers(key)
508
615
  }
509
616
 
617
+ reconcileSentHasMany(
618
+ parentType: string,
619
+ parentId: string,
620
+ fieldName: string,
621
+ delta: SentHasManyDelta,
622
+ alias?: string,
623
+ ): RelationReconciliationResult {
624
+ const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
625
+ const result = this.relations.reconcileSentHasMany(key, delta)
626
+ this.notifyRelationSubscribers(key)
627
+ return result
628
+ }
629
+
510
630
  resetHasMany(
511
631
  parentType: string,
512
632
  parentId: string,
@@ -514,6 +634,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
514
634
  alias?: string,
515
635
  ): void {
516
636
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
637
+ this.journal?.recordHasMany(key)
517
638
  this.relations.resetHasMany(key)
518
639
  this.notifyRelationSubscribers(key)
519
640
  }
@@ -526,6 +647,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
526
647
  alias?: string,
527
648
  ): void {
528
649
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
650
+ this.journal?.recordHasMany(key)
529
651
  this.relations.addToHasMany(key, itemId)
530
652
  this.notifyRelationSubscribers(key)
531
653
  }
@@ -537,6 +659,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
537
659
  itemId: string,
538
660
  ): void {
539
661
  const key = this.getRelationKey(parentType, parentId, fieldName)
662
+ this.journal?.recordHasMany(key)
540
663
  this.relations.connectExistingToHasMany(key, itemId)
541
664
  this.notifyRelationSubscribers(key)
542
665
  }
@@ -550,6 +673,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
550
673
  alias?: string,
551
674
  ): void {
552
675
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
676
+ this.journal?.recordHasMany(key)
553
677
  // Cancelling the add of a never-persisted child just removes it from the
554
678
  // list; its now-unreachable snapshot is no longer reported as a `create` and
555
679
  // is collected by the lazy memory sweep.
@@ -567,6 +691,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
567
691
  alias?: string,
568
692
  ): void {
569
693
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
694
+ this.journal?.recordHasMany(key)
570
695
  this.relations.moveInHasMany(key, fromIndex, toIndex)
571
696
  this.notifyRelationSubscribers(key)
572
697
  }
@@ -581,6 +706,21 @@ export class SnapshotStore implements SnapshotVersionBumper {
581
706
  return this.relations.getHasManyOrderedIds(key)
582
707
  }
583
708
 
709
+ getPresentationHasManyOrderedIds(
710
+ parentType: string,
711
+ parentId: string,
712
+ fieldName: string,
713
+ alias?: string,
714
+ ): string[] {
715
+ const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
716
+ if (!this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) {
717
+ return this.relations.getHasManyOrderedIds(key)
718
+ }
719
+
720
+ const state = this.relations.getHasMany(key)
721
+ return state ? [...state.serverIds] : []
722
+ }
723
+
584
724
  isHasManyItemCreated(
585
725
  parentType: string,
586
726
  parentId: string,
@@ -590,7 +730,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
590
730
  ): boolean {
591
731
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
592
732
  const state = this.relations.getHasMany(key)
593
- return state?.createdEntities.has(itemId) ?? false
733
+ return state?.plannedAdditions.get(itemId) === 'created'
594
734
  }
595
735
 
596
736
  getHasManyCreatedEntities(
@@ -600,7 +740,13 @@ export class SnapshotStore implements SnapshotVersionBumper {
600
740
  alias?: string,
601
741
  ): Set<string> | undefined {
602
742
  const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
603
- return this.relations.getHasMany(key)?.createdEntities
743
+ const state = this.relations.getHasMany(key)
744
+ if (!state) return undefined
745
+ const created = new Set<string>()
746
+ for (const [id, kind] of state.plannedAdditions) {
747
+ if (kind === 'created') created.add(id)
748
+ }
749
+ return created
604
750
  }
605
751
 
606
752
  // ==================== Persisting State (delegated to EntityMetaStore) ====================
@@ -610,12 +756,47 @@ export class SnapshotStore implements SnapshotVersionBumper {
610
756
  return this.meta.isPersisting(key)
611
757
  }
612
758
 
613
- setPersisting(entityType: string, id: string, isPersisting: boolean): void {
759
+ setPersisting(entityType: string, id: string, isPersisting: boolean, pessimistic: boolean = false): void {
614
760
  const key = this.getEntityKey(entityType, id)
615
- this.meta.setPersisting(key, isPersisting)
761
+ const wasPessimistic = this.meta.isPessimisticInFlight(key)
762
+ this.meta.setPersisting(key, isPersisting, pessimistic)
763
+ if (wasPessimistic !== this.meta.isPessimisticInFlight(key)) {
764
+ this.bumpEntitySnapshotVersion(key)
765
+ }
616
766
  this.notifyEntitySubscribers(key)
617
767
  }
618
768
 
769
+ /**
770
+ * Returns the snapshot a consumer should DISPLAY for an entity.
771
+ *
772
+ * This equals the canonical {@link getEntitySnapshot} except while the entity
773
+ * is pessimistically in-flight, when it returns the server baseline (data ===
774
+ * serverData) WITHOUT mutating the store. The canonical snapshot stays dirty,
775
+ * so dirty tracking, mutation building, and retry are unaffected — only the
776
+ * presented value is the pre-persist server view.
777
+ *
778
+ * Inert until consumers route their display reads through it (see PR 4); a
779
+ * non-pessimistic entity is returned verbatim, so optimistic mode and the
780
+ * not-persisting case share this one path.
781
+ */
782
+ getPresentationSnapshot<T extends object>(entityType: string, id: string): EntitySnapshot<T> | undefined {
783
+ const snapshot = this.getEntitySnapshot<T>(entityType, id)
784
+ if (!snapshot) return undefined
785
+
786
+ const key = this.getEntityKey(entityType, id)
787
+ if (!this.meta.isPessimisticInFlight(key)) {
788
+ return snapshot
789
+ }
790
+
791
+ return createEntitySnapshot<T>(
792
+ snapshot.id,
793
+ snapshot.entityType,
794
+ snapshot.serverData,
795
+ snapshot.serverData,
796
+ snapshot.version,
797
+ )
798
+ }
799
+
619
800
  // ==================== Error State (delegated to ErrorStore) ====================
620
801
 
621
802
  getFieldErrors(entityType: string, id: string, fieldName: string): readonly FieldError[] {
@@ -690,6 +871,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
690
871
  const entityKey = this.getEntityKey(entityType, id)
691
872
  const keyPrefix = `${entityType}:${id}:`
692
873
  this.errors.clearAllServerErrors(entityKey, keyPrefix)
874
+ this.notifyEntitySubscribers(entityKey)
693
875
  }
694
876
 
695
877
  clearAllErrors(entityType: string, id: string): void {
@@ -752,17 +934,42 @@ export class SnapshotStore implements SnapshotVersionBumper {
752
934
  return this.relations.getRelation(key)
753
935
  }
754
936
 
937
+ getPresentationRelation(
938
+ parentType: string,
939
+ parentId: string,
940
+ fieldName: string,
941
+ ): StoredRelationState | undefined {
942
+ const relation = this.getRelation(parentType, parentId, fieldName)
943
+ if (!relation || !this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) {
944
+ return relation
945
+ }
946
+
947
+ return {
948
+ ...relation,
949
+ currentId: relation.serverId,
950
+ state: relation.serverState,
951
+ placeholderData: {},
952
+ }
953
+ }
954
+
755
955
  setRelation(
756
956
  parentType: string,
757
957
  parentId: string,
758
958
  fieldName: string,
759
959
  updates: Partial<Omit<StoredRelationState, 'version'>>,
960
+ skipNotify: boolean = false,
760
961
  ): void {
761
962
  const key = this.getRelationKey(parentType, parentId, fieldName)
963
+ this.journal?.recordRelation(key)
762
964
  const entityKey = this.getEntityKey(parentType, parentId)
763
965
  const entitySnapshot = this.entitySnapshots.get(entityKey)
764
966
  this.relations.setRelation(key, updates, entitySnapshot, fieldName)
765
- this.notifyRelationSubscribers(key)
967
+ // skipNotify is for writes that happen during a render-phase read (e.g.
968
+ // HasOneHandle materialization) where the change that triggered them already
969
+ // notified subscribers — notifying again would call subscribers mid-render.
970
+ if (!skipNotify) {
971
+ this.notifyRelationSubscribers(key)
972
+ }
766
973
  }
767
974
 
768
975
  commitRelation(parentType: string, parentId: string, fieldName: string): void {
@@ -771,15 +978,36 @@ export class SnapshotStore implements SnapshotVersionBumper {
771
978
  this.notifyRelationSubscribers(key)
772
979
  }
773
980
 
981
+ reconcileSentRelation(
982
+ parentType: string,
983
+ parentId: string,
984
+ fieldName: string,
985
+ transition: SentHasOneTransition,
986
+ ): RelationReconciliationResult {
987
+ const key = this.getRelationKey(parentType, parentId, fieldName)
988
+ const result = this.relations.reconcileSentRelation(key, transition)
989
+ this.notifyRelationSubscribers(key)
990
+ return result
991
+ }
992
+
774
993
  resetRelation(parentType: string, parentId: string, fieldName: string): void {
775
994
  const key = this.getRelationKey(parentType, parentId, fieldName)
995
+ this.journal?.recordRelation(key)
776
996
  this.relations.resetRelation(key)
777
997
  this.notifyRelationSubscribers(key)
778
998
  }
779
999
 
780
- commitAllRelations(entityType: string, entityId: string): void {
1000
+ /**
1001
+ * Commits every relation of an entity. Items listed in `pendingItems` (by relation
1002
+ * key) were not sent in the persist and stay planned.
1003
+ */
1004
+ commitAllRelations(
1005
+ entityType: string,
1006
+ entityId: string,
1007
+ pendingItems?: ReadonlyMap<string, ReadonlySet<string>>,
1008
+ ): void {
781
1009
  const keyPrefix = `${entityType}:${entityId}:`
782
- this.relations.commitAllRelations(keyPrefix)
1010
+ this.relations.commitAllRelations(keyPrefix, pendingItems)
783
1011
  }
784
1012
 
785
1013
  resetAllRelations(entityType: string, entityId: string): void {
@@ -787,28 +1015,6 @@ export class SnapshotStore implements SnapshotVersionBumper {
787
1015
  this.relations.resetAllRelations(keyPrefix)
788
1016
  }
789
1017
 
790
- getAllRelationsForEntity(entityType: string, entityId: string): Map<string, StoredRelationState> {
791
- const keyPrefix = `${entityType}:${entityId}:`
792
- return this.relations.getAllRelationsForEntity(keyPrefix)
793
- }
794
-
795
- getAllHasManyForEntity(entityType: string, entityId: string): Map<string, StoredHasManyState> {
796
- const keyPrefix = `${entityType}:${entityId}:`
797
- return this.relations.getAllHasManyForEntity(keyPrefix)
798
- }
799
-
800
- restoreHasManyState(
801
- parentType: string,
802
- parentId: string,
803
- fieldName: string,
804
- state: StoredHasManyState,
805
- alias?: string,
806
- ): void {
807
- const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
808
- this.relations.restoreHasManyState(key, state)
809
- this.notifyRelationSubscribers(key)
810
- }
811
-
812
1018
  // ==================== Subscriptions (delegated to SubscriptionManager) ====================
813
1019
 
814
1020
  subscribeToEntity(entityType: string, id: string, callback: Subscriber): () => void {
@@ -840,21 +1046,25 @@ export class SnapshotStore implements SnapshotVersionBumper {
840
1046
 
841
1047
  // ==================== Parent-Child Relationships ====================
842
1048
 
1049
+ /**
1050
+ * Anchors a child under a parent relation. Parent re-render notification is now
1051
+ * DERIVED from the relation store's live edges (see
1052
+ * {@link SubscriptionManager.getParentKeys}), so the only remaining effect is
1053
+ * the reachability one: a child anchored by a parent relation is no longer a
1054
+ * top-level root; its reachability flows through the parent. (No-op for server
1055
+ * children that were never roots.)
1056
+ *
1057
+ * Callers (handles, the action dispatcher) keep calling this on connect so the
1058
+ * root-unregister stays centralized in one place.
1059
+ */
843
1060
  registerParentChild(parentType: string, parentId: string, childType: string, childId: string): void {
844
- const parentKey = this.getEntityKey(parentType, parentId)
845
1061
  const childKey = this.getEntityKey(childType, childId)
846
- this.subscriptions.registerParentChild(parentKey, childKey)
847
- // A child anchored by a parent relation is no longer a top-level root; its
848
- // reachability now flows through the parent. (No-op for server children.)
1062
+ // Capture the child's root membership before it is dropped, so undoing the
1063
+ // connect/add that anchored it restores it as a reachable create.
1064
+ this.recordExistingEntity(childKey)
849
1065
  this.roots.unregister(childKey)
850
1066
  }
851
1067
 
852
- unregisterParentChild(parentType: string, parentId: string, childType: string, childId: string): void {
853
- const parentKey = this.getEntityKey(parentType, parentId)
854
- const childKey = this.getEntityKey(childType, childId)
855
- this.subscriptions.unregisterParentChild(parentKey, childKey)
856
- }
857
-
858
1068
  // ==================== Partial Snapshot Export/Import ====================
859
1069
 
860
1070
  exportPartialSnapshot(keys: {
@@ -900,8 +1110,245 @@ export class SnapshotStore implements SnapshotVersionBumper {
900
1110
  }
901
1111
  }
902
1112
 
1113
+ // ==================== Undo Journal Target (cell capture / restore) ====================
1114
+
1115
+ /**
1116
+ * Captures the editable-layer pre-image of an entity cell: the current snapshot
1117
+ * (data + baseline), plus the meta and root membership needed to restore or
1118
+ * re-create it. `present: false` means the entity did not exist — undo removes it.
1119
+ */
1120
+ exportEntityCell(key: string): EntityCellImage {
1121
+ const snapshot = this.entitySnapshots.get(key)
1122
+ if (!snapshot) {
1123
+ return { kind: 'entity', key, present: false }
1124
+ }
1125
+ return {
1126
+ kind: 'entity',
1127
+ key,
1128
+ present: true,
1129
+ snapshot,
1130
+ existsOnServer: this.meta.existsOnServer(key),
1131
+ isScheduledForDeletion: this.meta.isScheduledForDeletion(key),
1132
+ isRoot: this.roots.has(key),
1133
+ }
1134
+ }
1135
+
1136
+ exportRelationCell(key: string): RelationCellImage {
1137
+ const state = this.relations.getRelation(key)
1138
+ if (!state) return { kind: 'relation', key, present: false }
1139
+ return {
1140
+ kind: 'relation',
1141
+ key,
1142
+ present: true,
1143
+ state: { ...state, placeholderData: { ...state.placeholderData } },
1144
+ }
1145
+ }
1146
+
1147
+ /**
1148
+ * Live server-member ids of a has-many list by its raw relation key. Used by the
1149
+ * journal's rekey to rebase a pre-image when a just-persisted create became a
1150
+ * permanent member of the list (membership rebase for sealed creates).
1151
+ */
1152
+ getLiveHasManyServerIds(relationKey: string): Set<string> {
1153
+ const state = this.relations.getHasMany(relationKey)
1154
+ return new Set(state?.serverIds ?? [])
1155
+ }
1156
+
1157
+ exportHasManyCell(key: string): HasManyCellImage {
1158
+ const state = this.relations.getHasMany(key)
1159
+ if (!state) return { kind: 'hasMany', key, present: false }
1160
+ return {
1161
+ kind: 'hasMany',
1162
+ key,
1163
+ present: true,
1164
+ state: {
1165
+ serverIds: new Set(state.serverIds),
1166
+ orderedIds: state.orderedIds ? [...state.orderedIds] : null,
1167
+ plannedRemovals: new Map(state.plannedRemovals),
1168
+ plannedAdditions: new Map(state.plannedAdditions),
1169
+ version: state.version,
1170
+ },
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Entry-closure export: starting from the given seed ids, walks and exports the
1176
+ * entity + owned-relation cell images of every created, currently-unreachable
1177
+ * subtree — the exact `!existsOnServer && !reachable` set {@link sweepUnreachableCreated}
1178
+ * may reclaim. Referenced children (has-one currentId, has-many members / planned
1179
+ * additions) are pushed back on the worklist, so nested created grandchildren are
1180
+ * captured transitively. Lets an undo restore a detached create even after a sweep.
1181
+ */
1182
+ exportUnreachableCreatedSubgraph(seedIds: ReadonlySet<string>): JournalCellImage[] {
1183
+ const reachable = this.reachability.computeReachableCreated()
1184
+ const images: JournalCellImage[] = []
1185
+ const visited = new Set<string>()
1186
+ const worklist: string[] = [...seedIds]
1187
+
1188
+ while (worklist.length > 0) {
1189
+ const id = worklist.pop()!
1190
+ const key = this.entitySnapshots.keyForId(id)
1191
+ if (!key || visited.has(key)) continue
1192
+ visited.add(key)
1193
+ // The sweep gate: only created, currently-unreachable entities are reclaimed.
1194
+ if (this.meta.existsOnServer(key) || reachable.has(key)) continue
1195
+ if (!this.entitySnapshots.has(key)) continue // placeholder-only: nothing to restore
1196
+
1197
+ images.push(this.exportEntityCell(key))
1198
+
1199
+ const ownerPrefix = `${key}:`
1200
+ for (const relationKey of this.relations.getOwnedRelationKeys(ownerPrefix)) {
1201
+ const image = this.exportRelationCell(relationKey)
1202
+ images.push(image)
1203
+ if (image.state?.currentId) worklist.push(image.state.currentId)
1204
+ }
1205
+ for (const hasManyKey of this.relations.getOwnedHasManyKeys(ownerPrefix)) {
1206
+ const image = this.exportHasManyCell(hasManyKey)
1207
+ images.push(image)
1208
+ if (image.state) {
1209
+ if (image.state.orderedIds) worklist.push(...image.state.orderedIds)
1210
+ for (const additionId of image.state.plannedAdditions.keys()) worklist.push(additionId)
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ return images
1216
+ }
1217
+
1218
+ /**
1219
+ * Restores a set of cell pre-images through the write paths (so the edge index
1220
+ * reconciles and the reachability cache invalidates). Order matters: present
1221
+ * entities are restored/recreated first so relations can reference live targets,
1222
+ * then present relations, then un-creates, then dropped relations.
1223
+ */
1224
+ applyJournalImages(images: JournalCellImage[]): void {
1225
+ const notifyEntities = new Set<string>()
1226
+ const notifyRelations = new Set<string>()
1227
+
1228
+ const presentEntities: EntityCellImage[] = []
1229
+ const presentRelations: Array<RelationCellImage | HasManyCellImage> = []
1230
+ const absentEntities: EntityCellImage[] = []
1231
+ const absentRelations: Array<RelationCellImage | HasManyCellImage> = []
1232
+
1233
+ for (const img of images) {
1234
+ if (img.kind === 'entity') {
1235
+ notifyEntities.add(img.key)
1236
+ ;(img.present ? presentEntities : absentEntities).push(img)
1237
+ } else {
1238
+ notifyRelations.add(img.key)
1239
+ notifyEntities.add(entityKeyOfRelationKey(img.key))
1240
+ ;(img.present ? presentRelations : absentRelations).push(img)
1241
+ }
1242
+ }
1243
+
1244
+ for (const img of presentEntities) this.applyEntityImage(img)
1245
+ for (const img of presentRelations) this.applyRelationImage(img)
1246
+ for (const img of absentEntities) {
1247
+ const [type, id] = splitEntityKey(img.key)
1248
+ this.removeEntity(type, id)
1249
+ }
1250
+ for (const img of absentRelations) {
1251
+ if (img.kind === 'relation') this.relations.removeRelationState(img.key)
1252
+ else this.relations.removeHasManyState(img.key)
1253
+ }
1254
+
1255
+ this.subscriptions.notifyGlobal()
1256
+ for (const key of notifyEntities) this.subscriptions.notifyEntityDirect(key)
1257
+ for (const key of notifyRelations) this.subscriptions.notifyRelationDirect(key)
1258
+ }
1259
+
1260
+ private applyEntityImage(img: EntityCellImage): void {
1261
+ const snapshot = img.snapshot!
1262
+ const live = this.entitySnapshots.get(img.key)
1263
+ if (live) {
1264
+ // Splice the editable layer (data) onto the LIVE server baseline; keep the
1265
+ // live id so an undo after persist re-dirties against the current baseline.
1266
+ const spliced = createEntitySnapshot(
1267
+ live.id,
1268
+ live.entityType,
1269
+ { ...(snapshot.data as Record<string, unknown>), id: live.id },
1270
+ live.serverData,
1271
+ live.version + 1,
1272
+ )
1273
+ this.entitySnapshots.importSnapshots(new Map([[img.key, spliced]]))
1274
+ this.meta.importMetas(new Map([[img.key, {
1275
+ existsOnServer: this.meta.existsOnServer(img.key),
1276
+ isScheduledForDeletion: img.isScheduledForDeletion ?? false,
1277
+ }]]))
1278
+ } else {
1279
+ // Entity is gone (swept / un-created): recreate it from the captured snapshot.
1280
+ this.entitySnapshots.importSnapshots(new Map([[img.key, snapshot]]))
1281
+ this.meta.importMetas(new Map([[img.key, {
1282
+ existsOnServer: img.existsOnServer ?? false,
1283
+ isScheduledForDeletion: img.isScheduledForDeletion ?? false,
1284
+ }]]))
1285
+ }
1286
+ if (img.isRoot) this.roots.register(img.key)
1287
+ else this.roots.unregister(img.key)
1288
+ }
1289
+
1290
+ private applyRelationImage(img: RelationCellImage | HasManyCellImage): void {
1291
+ if (img.kind === 'relation') {
1292
+ const s = img.state!
1293
+ const live = this.relations.getRelation(img.key)
1294
+ this.relations.importRelationStates(new Map([[img.key, {
1295
+ currentId: s.currentId,
1296
+ state: s.state,
1297
+ placeholderData: { ...s.placeholderData },
1298
+ serverId: live ? live.serverId : s.serverId,
1299
+ serverState: live ? live.serverState : s.serverState,
1300
+ version: (live?.version ?? s.version) + 1,
1301
+ }]]))
1302
+ } else {
1303
+ const s = img.state!
1304
+ const live = this.relations.getHasMany(img.key)
1305
+ this.relations.importHasManyStates(new Map([[img.key, {
1306
+ serverIds: new Set(live ? live.serverIds : s.serverIds),
1307
+ orderedIds: s.orderedIds ? [...s.orderedIds] : null,
1308
+ plannedRemovals: new Map(s.plannedRemovals),
1309
+ plannedAdditions: new Map(s.plannedAdditions),
1310
+ version: (live?.version ?? s.version) + 1,
1311
+ }]]))
1312
+ }
1313
+ }
1314
+
903
1315
  // ==================== Dirty Tracking (delegated to DirtyTracker) ====================
904
1316
 
1317
+ /**
1318
+ * Sum of the monotonic mutation counters of every sub-store {@link getAllDirtyEntities}
1319
+ * reads. Strictly increasing, so an unchanged value proves no write could have
1320
+ * changed the dirty set — the cache key {@link ChangeRegistry} memoizes the
1321
+ * full-store scan on.
1322
+ *
1323
+ * Deliberately NOT derived from {@link getVersion} (the notification version):
1324
+ * several writes change dirtiness without notifying — `createEntity` registers
1325
+ * its root after its last notify, `registerParentChild` un-registers one,
1326
+ * `commit/resetAllRelations` never notify, and `refreshServerData` can skip it —
1327
+ * so a notification-keyed memo would serve a stale result. Counters bump inside
1328
+ * the write itself and cannot be bypassed that way.
1329
+ *
1330
+ * The entity snapshot store contributes its data-write counter alone: it bumps
1331
+ * on a superset of the writes `getMutationVersion()` covers.
1332
+ *
1333
+ * REQUIRES snapshot values to be replaced, never mutated in place.
1334
+ * `createEntitySnapshot` freezes only the top level, so a nested object inside
1335
+ * `data` (a rich-text JSON payload, say) stays writable. Editing one in place
1336
+ * changes dirtiness without touching any counter, and the memo will serve the
1337
+ * pre-edit answer — where the former unconditional scan happened to notice it on
1338
+ * the next read. The store already implies this rule by freezing at all; the memo
1339
+ * is what makes breaking it consequential.
1340
+ *
1341
+ * @internal Cache key for the dirty-scan memo. Monotonic and otherwise
1342
+ * meaningless — the absolute value carries no consumer-facing information.
1343
+ */
1344
+ getDirtyVersion(): number {
1345
+ return this.entitySnapshots.getDataWriteVersion()
1346
+ + this.meta.getMutationVersion()
1347
+ + this.meta.getEditableWriteVersion()
1348
+ + this.relations.getMutationVersion()
1349
+ + this.roots.getMutationVersion()
1350
+ }
1351
+
905
1352
  getAllDirtyEntities(): Array<{
906
1353
  entityType: string
907
1354
  entityId: string
@@ -953,8 +1400,32 @@ export class SnapshotStore implements SnapshotVersionBumper {
953
1400
  this.errors.clear()
954
1401
  this.touched.clear()
955
1402
  this.roots.clear()
1403
+ this.rekeyOrchestrator.clear()
956
1404
  this.lastPropagatedData.clear()
1405
+ // Undo history describes the now-wiped world; drop it with the store.
1406
+ this.journal?.clear()
957
1407
 
958
- this.subscriptions.notify()
1408
+ // Every subscription's data just disappeared, so notify entity/relation subscribers too —
1409
+ // a global-only notify leaves them rendering wiped data with no consumer-side remedy.
1410
+ this.subscriptions.notifyAll()
959
1411
  }
960
1412
  }
1413
+
1414
+ /**
1415
+ * Splits an entity key "entityType:id" into its parts. Entity ids never contain
1416
+ * ':' (store-wide invariant), so the first ':' is the type/id boundary.
1417
+ */
1418
+ function splitEntityKey(key: string): [string, string] {
1419
+ const i = key.indexOf(':')
1420
+ return [key.slice(0, i), key.slice(i + 1)]
1421
+ }
1422
+
1423
+ /**
1424
+ * Derives the owning entity key "entityType:id" from a relation/has-many key
1425
+ * "entityType:id:fieldName".
1426
+ */
1427
+ function entityKeyOfRelationKey(key: string): string {
1428
+ const first = key.indexOf(':')
1429
+ const second = key.indexOf(':', first + 1)
1430
+ return second === -1 ? key : key.slice(0, second)
1431
+ }