@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
@@ -1,3 +1,5 @@
1
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
2
+
1
3
  type Subscriber = () => void
2
4
 
3
5
  /**
@@ -8,6 +10,16 @@ export interface SnapshotVersionBumper {
8
10
  bumpEntitySnapshotVersion(key: string): void
9
11
  }
10
12
 
13
+ /**
14
+ * Resolves the parent entity keys that currently have a LIVE relation edge to a
15
+ * child, so a child-field change can propagate up to its parents. Implemented by
16
+ * the relation store (the single source of truth for relation membership); this
17
+ * interface keeps {@link SubscriptionManager} decoupled from its concrete type.
18
+ */
19
+ export interface ParentKeyLookup {
20
+ getParentKeysForChild(childId: string): Set<string>
21
+ }
22
+
11
23
  /**
12
24
  * Manages subscriptions for entity and relation changes.
13
25
  *
@@ -15,10 +27,10 @@ export interface SnapshotVersionBumper {
15
27
  * - Entity-level subscriptions
16
28
  * - Relation-level subscriptions
17
29
  * - Global subscriptions (any change)
18
- * - Parent-child change propagation
30
+ * - Parent-child change propagation (derived from live relation edges)
19
31
  * - Global version tracking for change detection
20
32
  */
21
- export class SubscriptionManager {
33
+ export class SubscriptionManager implements Rekeyable {
22
34
  /** Subscribers per entity key */
23
35
  private readonly entitySubscribers = new Map<string, Set<Subscriber>>()
24
36
 
@@ -28,15 +40,19 @@ export class SubscriptionManager {
28
40
  /** Global subscribers (notified on any change) */
29
41
  private readonly globalSubscribers = new Set<Subscriber>()
30
42
 
31
- /** Parent-child relationships: childKey -> Set of parentKeys */
32
- private readonly childToParents = new Map<string, Set<string>>()
33
-
34
43
  /** Maps old keys → new keys after rekey, so unsubscribe closures can find migrated callbacks */
35
44
  private readonly rekeyedKeys = new Map<string, string>()
36
45
 
37
46
  /** Global version number for change detection */
38
47
  private globalVersion = 0
39
48
 
49
+ /**
50
+ * Resolves a child's parents from live relation edges. Injected after
51
+ * construction (the relation store and this manager are siblings under
52
+ * SnapshotStore) via {@link setParentKeyLookup}.
53
+ */
54
+ private parentKeyLookup: ParentKeyLookup | undefined
55
+
40
56
  /**
41
57
  * Resolves a key through the rekey redirect chain.
42
58
  * The rekey() method collapses chains (A→C instead of A→B→C), so this should
@@ -116,32 +132,54 @@ export class SubscriptionManager {
116
132
  }
117
133
  }
118
134
 
135
+ /**
136
+ * Notifies every registered subscriber — entity, relation and global.
137
+ *
138
+ * For store-wide events such as clear(), where every subscription's data is gone at once and
139
+ * there is no per-key change to notify from. Registrations are left intact: the subscribers
140
+ * belong to mounted components that must learn their entity is no longer there.
141
+ */
142
+ notifyAll(): void {
143
+ this.globalVersion++
144
+
145
+ // Iterate the live sets, as the per-key paths do: a subscriber that unsubscribes a
146
+ // not-yet-visited sibling removes it from the iteration, whereas a copied array would
147
+ // still invoke it after its unsubscribe() returned.
148
+ for (const subs of this.entitySubscribers.values()) {
149
+ for (const sub of subs) sub()
150
+ }
151
+ for (const subs of this.relationSubscribers.values()) {
152
+ for (const sub of subs) sub()
153
+ }
154
+ for (const sub of this.globalSubscribers) sub()
155
+ }
156
+
119
157
  // ==================== Parent-Child Relationships ====================
120
158
 
121
159
  /**
122
- * Registers a parent-child relationship for change propagation.
123
- * When the child entity changes, parent entity subscribers will be notified.
160
+ * Wires the live-edge parent lookup. Parent re-render propagation is derived
161
+ * from the relation store's edges rather than a separate registry, so there is
162
+ * one source of truth for the parent-child graph.
124
163
  */
125
- registerParentChild(parentKey: string, childKey: string): void {
126
- let parents = this.childToParents.get(childKey)
127
- if (!parents) {
128
- parents = new Set()
129
- this.childToParents.set(childKey, parents)
130
- }
131
- parents.add(parentKey)
164
+ setParentKeyLookup(lookup: ParentKeyLookup): void {
165
+ this.parentKeyLookup = lookup
132
166
  }
133
167
 
134
168
  /**
135
- * Unregisters a parent-child relationship.
169
+ * Derives the parent entity keys for a child entity key by reading the live
170
+ * relation edges via the injected lookup. The child's bare id is the part of
171
+ * the key after the first ':' ("entityType:id").
172
+ *
173
+ * The child's TYPE is intentionally dropped — {@link ParentKeyLookup} matches on
174
+ * the bare id, relying on the store-wide global-id-uniqueness invariant (see
175
+ * {@link RelationStore.getParentKeysForChild}).
136
176
  */
137
- unregisterParentChild(parentKey: string, childKey: string): void {
138
- const parents = this.childToParents.get(childKey)
139
- if (parents) {
140
- parents.delete(parentKey)
141
- if (parents.size === 0) {
142
- this.childToParents.delete(childKey)
143
- }
144
- }
177
+ private getParentKeys(childKey: string): Set<string> {
178
+ if (!this.parentKeyLookup) return new Set()
179
+ const separator = childKey.indexOf(':')
180
+ if (separator === -1) return new Set()
181
+ const childId = childKey.slice(separator + 1)
182
+ return this.parentKeyLookup.getParentKeysForChild(childId)
145
183
  }
146
184
 
147
185
  // ==================== Notification ====================
@@ -153,7 +191,25 @@ export class SubscriptionManager {
153
191
  notifyEntitySubscribers(
154
192
  key: string,
155
193
  bumper: SnapshotVersionBumper,
156
- notifiedKeys: Set<string> = new Set(),
194
+ ): void {
195
+ this.globalVersion++
196
+ this.notifyEntityAndParentSubscribers(key, bumper, new Set())
197
+ }
198
+
199
+ /**
200
+ * Walks the entity and its live ancestors. The global version is bumped once by the
201
+ * caller, before the first subscriber runs — a subscriber reading getVersion() from
202
+ * inside its callback must already see the new value.
203
+ *
204
+ * The walk is a transitive closure over live edges, so an entity many rows point at
205
+ * (a shared lookup entity whose own has-many is loaded) acts as a hub: a write in one
206
+ * row reaches every other row through it. That is the price of not knowing which part
207
+ * of the hub each row presents; a selection-aware edge index would be the fix.
208
+ */
209
+ private notifyEntityAndParentSubscribers(
210
+ key: string,
211
+ bumper: SnapshotVersionBumper,
212
+ notifiedKeys: Set<string>,
157
213
  ): void {
158
214
  // Prevent infinite recursion
159
215
  if (notifiedKeys.has(key)) return
@@ -164,8 +220,6 @@ export class SubscriptionManager {
164
220
  const isRoot = notifiedKeys.size === 0
165
221
  notifiedKeys.add(key)
166
222
 
167
- this.globalVersion++
168
-
169
223
  // Notify entity-specific subscribers
170
224
  const entitySubs = this.entitySubscribers.get(key)
171
225
  if (entitySubs) {
@@ -174,14 +228,16 @@ export class SubscriptionManager {
174
228
  }
175
229
  }
176
230
 
177
- // Notify parent entity subscribers (propagate change up the tree)
178
- const parents = this.childToParents.get(key)
179
- if (parents) {
180
- for (const parentKey of parents) {
181
- // Bump parent snapshot version so useSyncExternalStore detects a change
182
- bumper.bumpEntitySnapshotVersion(parentKey)
183
- this.notifyEntitySubscribers(parentKey, bumper, notifiedKeys)
184
- }
231
+ // Notify parent entity subscribers (propagate change up the tree).
232
+ // Parents are derived from the relation store's LIVE edges, so a
233
+ // disconnected child no longer reaches its former parent.
234
+ const parents = this.getParentKeys(key)
235
+ for (const parentKey of parents) {
236
+ // An ancestor reachable through several edges is bumped and walked once.
237
+ if (notifiedKeys.has(parentKey)) continue
238
+ // Bump parent snapshot version so useSyncExternalStore detects a change
239
+ bumper.bumpEntitySnapshotVersion(parentKey)
240
+ this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys)
185
241
  }
186
242
 
187
243
  // Notify global subscribers (only once, from the root invocation — not
@@ -194,8 +250,8 @@ export class SubscriptionManager {
194
250
  }
195
251
 
196
252
  /**
197
- * Notifies relation subscribers and the parent entity's subscribers.
198
- * The bumper callback is used to bump the parent entity snapshot version.
253
+ * Notifies relation subscribers, the owning entity, and its ancestors.
254
+ * The bumper callback is used to bump entity snapshot versions.
199
255
  * The entityKey is the parent entity key derived from the relation key.
200
256
  */
201
257
  notifyRelationSubscribers(
@@ -216,18 +272,7 @@ export class SubscriptionManager {
216
272
  // Bump entity snapshot version so isEqual detects a change
217
273
  bumper.bumpEntitySnapshotVersion(entityKey)
218
274
 
219
- // Notify entity subscribers
220
- const entitySubs = this.entitySubscribers.get(entityKey)
221
- if (entitySubs) {
222
- for (const sub of entitySubs) {
223
- sub()
224
- }
225
- }
226
-
227
- // Notify global subscribers
228
- for (const sub of this.globalSubscribers) {
229
- sub()
230
- }
275
+ this.notifyEntityAndParentSubscribers(entityKey, bumper, new Set())
231
276
  }
232
277
 
233
278
  /**
@@ -267,11 +312,15 @@ export class SubscriptionManager {
267
312
  }
268
313
 
269
314
  /**
270
- * Moves subscriptions and parent-child relationships from oldKey to newKey.
315
+ * Moves entity and relation subscriptions from oldKey to newKey.
271
316
  * Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix.
272
317
  * Registers redirects so unsubscribe closures can find migrated callbacks.
318
+ *
319
+ * Parent-child links are NOT migrated here — they are derived from the
320
+ * relation store's live edges, which migrate their own id references on rekey.
273
321
  */
274
- rekey(oldKey: string, newKey: string, oldKeyPrefix: string, newKeyPrefix: string): void {
322
+ rekey(ctx: RekeyContext): void {
323
+ const { oldKey, newKey, oldKeyPrefix, newKeyPrefix } = ctx
275
324
  // Register redirect for entity key (update existing chains first)
276
325
  for (const [fromKey, toKey] of this.rekeyedKeys) {
277
326
  if (toKey === oldKey) {
@@ -280,21 +329,17 @@ export class SubscriptionManager {
280
329
  }
281
330
  this.rekeyedKeys.set(oldKey, newKey)
282
331
 
283
- // Move entity subscribers
284
- const entitySubs = this.entitySubscribers.get(oldKey)
285
- if (entitySubs) {
286
- this.entitySubscribers.delete(oldKey)
287
- this.entitySubscribers.set(newKey, entitySubs)
288
- }
332
+ // Move entity subscribers, merging into anything already subscribed under the new key
333
+ this.moveSubscribers(this.entitySubscribers, oldKey, newKey)
289
334
 
290
335
  // Move relation subscribers by prefix (e.g. "Entity:tempId:" → "Entity:persistedId:")
291
- const toMoveRelations: [string, Set<Subscriber>][] = []
292
- for (const [key, subs] of this.relationSubscribers) {
336
+ const toMoveRelations: string[] = []
337
+ for (const key of this.relationSubscribers.keys()) {
293
338
  if (key.startsWith(oldKeyPrefix)) {
294
- toMoveRelations.push([key, subs])
339
+ toMoveRelations.push(key)
295
340
  }
296
341
  }
297
- for (const [oldRelKey, subs] of toMoveRelations) {
342
+ for (const oldRelKey of toMoveRelations) {
298
343
  const newRelKey = newKeyPrefix + oldRelKey.slice(oldKeyPrefix.length)
299
344
 
300
345
  // Register redirect for relation key (update existing chains first)
@@ -305,23 +350,25 @@ export class SubscriptionManager {
305
350
  }
306
351
  this.rekeyedKeys.set(oldRelKey, newRelKey)
307
352
 
308
- this.relationSubscribers.delete(oldRelKey)
309
- this.relationSubscribers.set(newRelKey, subs)
310
- }
311
-
312
- // Move parent-child: update child→parent mappings
313
- const parents = this.childToParents.get(oldKey)
314
- if (parents) {
315
- this.childToParents.delete(oldKey)
316
- this.childToParents.set(newKey, parents)
353
+ this.moveSubscribers(this.relationSubscribers, oldRelKey, newRelKey)
317
354
  }
355
+ }
318
356
 
319
- // Update parent-child: replace oldKey in any parent sets that reference it
320
- for (const parentSet of this.childToParents.values()) {
321
- if (parentSet.has(oldKey)) {
322
- parentSet.delete(oldKey)
323
- parentSet.add(newKey)
324
- }
357
+ /**
358
+ * Re-homes a subscriber set under a new key. The destination may already hold
359
+ * subscribers (a component mounted on the persisted id before the draft was rekeyed
360
+ * onto it); replacing the set would silently orphan them, so the two are merged.
361
+ */
362
+ private moveSubscribers(map: Map<string, Set<Subscriber>>, oldKey: string, newKey: string): void {
363
+ const moved = map.get(oldKey)
364
+ if (!moved) return
365
+ map.delete(oldKey)
366
+
367
+ const existing = map.get(newKey)
368
+ if (!existing) {
369
+ map.set(newKey, moved)
370
+ return
325
371
  }
372
+ for (const sub of moved) existing.add(sub)
326
373
  }
327
374
  }
@@ -1,3 +1,5 @@
1
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
2
+
1
3
  /**
2
4
  * Manages touched state for entity fields.
3
5
  *
@@ -6,7 +8,7 @@
6
8
  *
7
9
  * Keys are pre-computed composite strings (e.g., "entityType:id:fieldName").
8
10
  */
9
- export class TouchedStore {
11
+ export class TouchedStore implements Rekeyable {
10
12
  /** Touched state keyed by "entityType:id:fieldName" */
11
13
  private readonly touchedFields = new Map<string, boolean>()
12
14
 
@@ -39,7 +41,8 @@ export class TouchedStore {
39
41
  /**
40
42
  * Rekeys all touched fields from oldKeyPrefix to newKeyPrefix.
41
43
  */
42
- rekey(oldKeyPrefix: string, newKeyPrefix: string): void {
44
+ rekey(ctx: RekeyContext): void {
45
+ const { oldKeyPrefix, newKeyPrefix } = ctx
43
46
  const toMove: [string, boolean][] = []
44
47
  for (const [key, value] of this.touchedFields) {
45
48
  if (key.startsWith(oldKeyPrefix)) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Derives the parent composite key ("parentType:parentId") from a relation key
3
+ * ("parentType:parentId:fieldName") by dropping the trailing field segment.
4
+ * Entity ids and field names never contain ':', so the parent key is everything
5
+ * before the last separator.
6
+ */
7
+ export function parentKeyFromRelationKey(relationKey: string): string {
8
+ const lastSeparator = relationKey.lastIndexOf(':')
9
+ return relationKey.slice(0, lastSeparator)
10
+ }
11
+
12
+ /**
13
+ * Derives the parent composite key ("parentType:parentId") from an owner key
14
+ * prefix ("parentType:parentId:") by dropping the trailing separator. Callers
15
+ * pass the owner prefix used for relation-key lookups; this maps it to the key
16
+ * the {@link RelationEdgeIndex} stores edges under.
17
+ */
18
+ export function parentKeyFromOwnerPrefix(ownerPrefix: string): string {
19
+ return ownerPrefix.endsWith(':') ? ownerPrefix.slice(0, -1) : ownerPrefix
20
+ }
@@ -0,0 +1,258 @@
1
+ import type { EntitySnapshot } from '../store/snapshots.js'
2
+ import type { StoredRelationState, StoredHasManyState } from '../store/RelationStore.js'
3
+ import type { RekeyContext } from '../store/RekeyOrchestrator.js'
4
+ import { UnrecordedWriteError } from './UnrecordedWriteError.js'
5
+
6
+ /**
7
+ * Write-journal for undo/redo.
8
+ *
9
+ * A gesture (one dispatch, or one handle operation) opens a transaction; every
10
+ * primary-store write made inside it records the EDITABLE-LAYER pre-image of the
11
+ * cell it touches (first-writer-wins per cell). On commit, the accumulated cells
12
+ * become one {@link JournalEntry} — exactly the keys the gesture actually wrote,
13
+ * each with the state to restore. Undo restores those pre-images through the
14
+ * store's write paths (so the edge index and reachability cache rebuild
15
+ * themselves); derived state is never journaled.
16
+ *
17
+ * The journal captures only the editable layer (and, for re-creates, the full
18
+ * snapshot). The server baseline (serverData / serverId / serverState / serverIds
19
+ * / existsOnServer) is spliced back from the LIVE state on restore, so undoing an
20
+ * already-persisted edit re-dirties it against the current server view instead of
21
+ * resurrecting a stale baseline.
22
+ *
23
+ * Entry-closure invariant: on commit each entry is closed over the created,
24
+ * currently-unreachable subgraph its relation/has-many pre-images detach, so undo
25
+ * works no matter what memory sweep reclaims that subgraph in between (see
26
+ * {@link JournalTarget.exportUnreachableCreatedSubgraph}).
27
+ */
28
+
29
+ /** A captured cell, keyed by kind + composite store key. */
30
+ export interface EntityCellImage {
31
+ readonly kind: 'entity'
32
+ readonly key: string
33
+ /** Whether the entity existed at capture time. false ⇒ undo removes it (un-create). */
34
+ readonly present: boolean
35
+ readonly snapshot?: EntitySnapshot
36
+ readonly existsOnServer?: boolean
37
+ readonly isScheduledForDeletion?: boolean
38
+ readonly isRoot?: boolean
39
+ }
40
+
41
+ export interface RelationCellImage {
42
+ readonly kind: 'relation'
43
+ readonly key: string
44
+ readonly present: boolean
45
+ readonly state?: StoredRelationState
46
+ }
47
+
48
+ export interface HasManyCellImage {
49
+ readonly kind: 'hasMany'
50
+ readonly key: string
51
+ readonly present: boolean
52
+ readonly state?: StoredHasManyState
53
+ }
54
+
55
+ export type JournalCellImage = EntityCellImage | RelationCellImage | HasManyCellImage
56
+
57
+ /** The kind axis shared by cell images, editable-write counters, and the guard. */
58
+ export type JournalCellKind = JournalCellImage['kind']
59
+
60
+ /**
61
+ * Monotonic editable-layer write counters, one per journal kind. Provided by the
62
+ * store so the journal can prove — at transaction close — that every editable
63
+ * write was preceded by a record call (see {@link UndoJournal.commit}). Counters
64
+ * only ever increase, so a per-transaction delta is `now - base`.
65
+ */
66
+ export interface EditableWriteCounters {
67
+ readonly entity: number
68
+ readonly relation: number
69
+ readonly hasMany: number
70
+ }
71
+
72
+ /** One undoable unit: the editable-layer pre-images of every cell a gesture touched. */
73
+ export interface JournalEntry {
74
+ readonly cells: JournalCellImage[]
75
+ }
76
+
77
+ /**
78
+ * The store side of the journal: produces editable-layer pre-images for a cell and
79
+ * applies a set of pre-images back (through the write paths). Implemented by
80
+ * SnapshotStore, which alone knows the sub-stores.
81
+ */
82
+ export interface JournalTarget {
83
+ exportEntityCell(key: string): EntityCellImage
84
+ exportRelationCell(key: string): RelationCellImage
85
+ exportHasManyCell(key: string): HasManyCellImage
86
+ /**
87
+ * Images of every created, currently-unreachable entity (and its owned relation
88
+ * cells) reachable from the seed ids — the subgraph a memory sweep may reclaim
89
+ * before an undo needs it. Backs the journal's entry-closure invariant.
90
+ */
91
+ exportUnreachableCreatedSubgraph(seedIds: ReadonlySet<string>): JournalCellImage[]
92
+ applyJournalImages(images: JournalCellImage[]): void
93
+ /**
94
+ * Current editable-layer write counters across the sub-stores. Read once when a
95
+ * transaction opens and again when it closes to detect writes that skipped their
96
+ * record call. Must be O(1) — it is on the write hot path.
97
+ */
98
+ getEditableWriteCounters(): EditableWriteCounters
99
+ }
100
+
101
+ function cellRefKey(image: JournalCellImage): string {
102
+ return `${image.kind}:${image.key}`
103
+ }
104
+
105
+ /**
106
+ * Ids referenced by an entry's relation/has-many PRE-images — the roots of any
107
+ * created subgraph the gesture may have detached (has-one currentId; has-many
108
+ * members / planned add/remove). The unreachable-created gate filters the rest.
109
+ */
110
+ function collectDetachedSeedIds(active: Map<string, JournalCellImage>): Set<string> {
111
+ const ids = new Set<string>()
112
+ for (const image of active.values()) {
113
+ if (image.kind === 'relation') {
114
+ if (image.state?.currentId) ids.add(image.state.currentId)
115
+ } else if (image.kind === 'hasMany' && image.state) {
116
+ if (image.state.orderedIds) for (const id of image.state.orderedIds) ids.add(id)
117
+ for (const id of image.state.plannedAdditions.keys()) ids.add(id)
118
+ for (const id of image.state.plannedRemovals.keys()) ids.add(id)
119
+ }
120
+ }
121
+ return ids
122
+ }
123
+
124
+ export class UndoJournal {
125
+ private depth = 0
126
+ /** Cells recorded in the currently-open transaction, deduped first-writer-wins. */
127
+ private active: Map<string, JournalCellImage> | null = null
128
+ /** Editable-write counters snapshotted when the outermost transaction opened. */
129
+ private baseCounters: EditableWriteCounters | null = null
130
+
131
+ constructor(
132
+ private readonly target: JournalTarget,
133
+ private readonly onCommit: (entry: JournalEntry) => void,
134
+ private readonly onRekey?: (ctx: RekeyContext) => void,
135
+ private readonly onClear?: () => void,
136
+ ) {}
137
+
138
+ get isRecording(): boolean {
139
+ return this.active !== null
140
+ }
141
+
142
+ begin(): void {
143
+ if (this.depth === 0) {
144
+ this.active = new Map()
145
+ this.baseCounters = this.target.getEditableWriteCounters()
146
+ }
147
+ this.depth++
148
+ }
149
+
150
+ commit(): void {
151
+ if (this.depth === 0) return
152
+ this.depth--
153
+ if (this.depth > 0) return
154
+ const active = this.active
155
+ this.active = null
156
+ // Guard runs even for an empty entry: a write that skipped its record call
157
+ // leaves zero cells yet still advanced the counters — that is the bug to catch.
158
+ this.assertAllWritesRecorded(active)
159
+ this.baseCounters = null
160
+ if (active && active.size > 0) {
161
+ this.closeOverUnreachableCreated(active)
162
+ this.onCommit({ cells: [...active.values()] })
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Coarse per-kind invariant check at transaction close: for each journal kind
168
+ * whose editable-write counter advanced during the transaction, at least one
169
+ * cell of that kind must have been recorded. A positive delta with no recorded
170
+ * cell means a mutating method wrote without recording — undo would restore
171
+ * stale/partial state — so fail loudly naming the offending kind(s).
172
+ */
173
+ private assertAllWritesRecorded(active: Map<string, JournalCellImage> | null): void {
174
+ const base = this.baseCounters
175
+ if (base === null) return
176
+ const now = this.target.getEditableWriteCounters()
177
+
178
+ const recorded: Record<JournalCellKind, boolean> = { entity: false, relation: false, hasMany: false }
179
+ if (active) {
180
+ for (const image of active.values()) recorded[image.kind] = true
181
+ }
182
+
183
+ const unrecorded: JournalCellKind[] = []
184
+ if (now.entity > base.entity && !recorded.entity) unrecorded.push('entity')
185
+ if (now.relation > base.relation && !recorded.relation) unrecorded.push('relation')
186
+ if (now.hasMany > base.hasMany && !recorded.hasMany) unrecorded.push('hasMany')
187
+
188
+ if (unrecorded.length > 0) throw new UnrecordedWriteError(unrecorded)
189
+ }
190
+
191
+ /**
192
+ * Folds the detached created subgraph into the entry (first-writer-wins) so undo
193
+ * survives a later sweep of that subgraph. Runs before onCommit so the manager's
194
+ * debounce/group merge already sees the enriched cells.
195
+ */
196
+ private closeOverUnreachableCreated(active: Map<string, JournalCellImage>): void {
197
+ const seedIds = collectDetachedSeedIds(active)
198
+ if (seedIds.size === 0) return
199
+ for (const image of this.target.exportUnreachableCreatedSubgraph(seedIds)) {
200
+ const refKey = cellRefKey(image)
201
+ if (!active.has(refKey)) active.set(refKey, image)
202
+ }
203
+ }
204
+
205
+ /** Records the pre-image of an entity cell (snapshot + meta + root membership). */
206
+ recordEntity(key: string): void {
207
+ this.record(`entity:${key}`, () => this.target.exportEntityCell(key))
208
+ }
209
+
210
+ recordRelation(key: string): void {
211
+ this.record(`relation:${key}`, () => this.target.exportRelationCell(key))
212
+ }
213
+
214
+ recordHasMany(key: string): void {
215
+ this.record(`hasMany:${key}`, () => this.target.exportHasManyCell(key))
216
+ }
217
+
218
+ private record(refKey: string, make: () => JournalCellImage): void {
219
+ if (!this.active) return // no transaction open ⇒ not part of any gesture
220
+ if (this.active.has(refKey)) return // first-writer-wins: keep the pre-gesture state
221
+ this.active.set(refKey, make())
222
+ }
223
+
224
+ /** Captures the CURRENT image of each given cell — used to build the inverse entry. */
225
+ captureCurrent(cells: JournalCellImage[]): JournalCellImage[] {
226
+ return cells.map(cell => {
227
+ switch (cell.kind) {
228
+ case 'entity':
229
+ return this.target.exportEntityCell(cell.key)
230
+ case 'relation':
231
+ return this.target.exportRelationCell(cell.key)
232
+ case 'hasMany':
233
+ return this.target.exportHasManyCell(cell.key)
234
+ }
235
+ })
236
+ }
237
+
238
+ apply(cells: JournalCellImage[]): void {
239
+ this.target.applyJournalImages(cells)
240
+ }
241
+
242
+ /** Forwarded from the store when a temp id is rekeyed; lets the owner rewrite stacks. */
243
+ rekey(ctx: RekeyContext): void {
244
+ this.onRekey?.(ctx)
245
+ }
246
+
247
+ /** Forwarded from the store on a full clear; drops all owner history and any open gesture. */
248
+ clear(): void {
249
+ // A full store clear wipes the world these pre-images describe. Drop the open
250
+ // gesture's cells but keep `active` non-null so begin/commit depth stays paired.
251
+ // Null the baseline too: the wiped transaction can't be meaningfully guarded.
252
+ if (this.active !== null) this.active = new Map()
253
+ this.baseCounters = null
254
+ this.onClear?.()
255
+ }
256
+ }
257
+
258
+ export { cellRefKey }