@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
@@ -0,0 +1,454 @@
1
+ import type { HasOneRelationState } from '../handles/types.js'
2
+ import type { EntitySnapshot } from './snapshots.js'
3
+ import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
4
+ import { RelationEdgeIndex } from './RelationEdgeIndex.js'
5
+
6
+ /**
7
+ * Relation state stored in SnapshotStore
8
+ */
9
+ export interface StoredRelationState {
10
+ currentId: string | null
11
+ serverId: string | null
12
+ state: HasOneRelationState
13
+ serverState: HasOneRelationState
14
+ placeholderData: Record<string, unknown>
15
+ version: number
16
+ }
17
+
18
+ type ReconciliationResult = 'applied' | 'conflict'
19
+
20
+ export type SentHasOneTransition =
21
+ | { operation: 'connect'; targetId: string }
22
+ | { operation: 'create'; targetId: string }
23
+ | { operation: 'disconnect'; targetId: string }
24
+ | { operation: 'delete'; targetId: string }
25
+
26
+ function cloneRelationState(state: StoredRelationState): StoredRelationState {
27
+ return {
28
+ ...state,
29
+ placeholderData: { ...state.placeholderData },
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Owns has-one relation state ("parentType:parentId:fieldName" → {@link StoredRelationState}).
35
+ *
36
+ * Has its own monotonic {@link mutationVersion} bumped on every actual write
37
+ * (funnelled through {@link writeRelation} plus the delete/clear paths); the
38
+ * facade sums this with the has-many counter for {@link ReachabilityAnalyzer}.
39
+ */
40
+ export class HasOneStore {
41
+ /** Relation states keyed by "parentType:parentId:fieldName" */
42
+ private readonly relationStates = new Map<string, StoredRelationState>()
43
+
44
+ /**
45
+ * Bidirectional live-edge index, maintained by {@link writeRelation} /
46
+ * {@link deleteRelation} so the parent↔child queries are O(degree) and the two
47
+ * directions stay consistent by construction.
48
+ */
49
+ private readonly edges = new RelationEdgeIndex()
50
+
51
+ private mutationVersion = 0
52
+
53
+ /**
54
+ * Monotonic counter bumped on EDITABLE-layer has-one writes (setRelation /
55
+ * resetRelation) — the writes an undo gesture must first record. Materialization
56
+ * (getOrCreateRelation), post-persist commit, import, replaceEntityId, rekey and
57
+ * delete deliberately do NOT bump it. Read by the undo write-guard (see UndoJournal).
58
+ */
59
+ private editableWriteVersion = 0
60
+
61
+ getMutationVersion(): number {
62
+ return this.mutationVersion
63
+ }
64
+
65
+ getEditableWriteVersion(): number {
66
+ return this.editableWriteVersion
67
+ }
68
+
69
+ /**
70
+ * The single write chokepoint. Reconciles the edge index by diffing the live
71
+ * child of the previous state against the next, so every state-changing path
72
+ * (set/commit/reset/import/replaceEntityId/...) keeps the index correct without
73
+ * tracking the reverse direction itself.
74
+ */
75
+ private writeRelation(key: string, state: StoredRelationState): void {
76
+ const oldChild = liveHasOneChildId(this.relationStates.get(key))
77
+ const newChild = liveHasOneChildId(state)
78
+ this.relationStates.set(key, state)
79
+ if (oldChild !== newChild) {
80
+ const parentKey = parentKeyFromRelationKey(key)
81
+ if (oldChild !== null) this.edges.removeEdge(parentKey, oldChild)
82
+ if (newChild !== null) this.edges.addEdge(parentKey, newChild)
83
+ }
84
+ this.mutationVersion++
85
+ }
86
+
87
+ /**
88
+ * The single delete chokepoint — removes an entry and its live edge. Used by
89
+ * the bulk remove and rekey-owner paths so they don't leak edges.
90
+ */
91
+ private deleteRelation(key: string): void {
92
+ const existing = this.relationStates.get(key)
93
+ if (!existing) return
94
+ const child = liveHasOneChildId(existing)
95
+ if (child !== null) this.edges.removeEdge(parentKeyFromRelationKey(key), child)
96
+ this.relationStates.delete(key)
97
+ this.mutationVersion++
98
+ }
99
+
100
+ /**
101
+ * Gets or creates relation state.
102
+ */
103
+ getOrCreateRelation(
104
+ key: string,
105
+ initial: Omit<StoredRelationState, 'version'>,
106
+ ): StoredRelationState {
107
+ if (!this.relationStates.has(key)) {
108
+ this.writeRelation(key, { ...initial, version: 0 })
109
+ }
110
+
111
+ return cloneRelationState(this.relationStates.get(key)!)
112
+ }
113
+
114
+ /**
115
+ * Gets relation state.
116
+ */
117
+ getRelation(key: string): StoredRelationState | undefined {
118
+ const state = this.relationStates.get(key)
119
+ return state ? cloneRelationState(state) : undefined
120
+ }
121
+
122
+ /**
123
+ * Updates relation state.
124
+ * If the relation state doesn't exist, creates it using entity snapshot for server data.
125
+ */
126
+ setRelation(
127
+ key: string,
128
+ updates: Partial<Omit<StoredRelationState, 'version'>>,
129
+ entitySnapshot: EntitySnapshot | undefined,
130
+ fieldName: string,
131
+ ): void {
132
+ const existing = this.relationStates.get(key)
133
+
134
+ if (!existing) {
135
+ let serverId: string | null = null
136
+ let serverState: HasOneRelationState = 'disconnected'
137
+
138
+ if (entitySnapshot?.serverData) {
139
+ const relatedData = (entitySnapshot.serverData as Record<string, unknown>)[fieldName]
140
+ if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
141
+ serverId = (relatedData as { id: string }).id
142
+ serverState = 'connected'
143
+ }
144
+ }
145
+
146
+ this.writeRelation(key, {
147
+ currentId: 'currentId' in updates ? updates.currentId! : serverId,
148
+ serverId,
149
+ state: 'state' in updates ? updates.state! : serverState,
150
+ serverState,
151
+ placeholderData: updates.placeholderData ?? {},
152
+ version: 0,
153
+ })
154
+ } else {
155
+ this.writeRelation(key, {
156
+ ...existing,
157
+ ...updates,
158
+ version: existing.version + 1,
159
+ })
160
+ }
161
+ this.editableWriteVersion++
162
+ }
163
+
164
+ /**
165
+ * Commits relation state (server = current).
166
+ */
167
+ commitRelation(key: string): void {
168
+ const existing = this.relationStates.get(key)
169
+ if (!existing) return
170
+
171
+ this.writeRelation(key, {
172
+ ...existing,
173
+ serverId: existing.currentId,
174
+ serverState: existing.state === 'creating' ? 'connected' : existing.state,
175
+ placeholderData: {},
176
+ version: existing.version + 1,
177
+ })
178
+ }
179
+
180
+ /**
181
+ * Advances the server baseline by one confirmed sent transition while keeping
182
+ * edits made after the request started as pending local state.
183
+ */
184
+ reconcileSentTransition(
185
+ key: string,
186
+ transition: SentHasOneTransition,
187
+ ): ReconciliationResult {
188
+ const existing = this.relationStates.get(key)
189
+ if (!existing) return 'conflict'
190
+
191
+ const next = reconcileHasOneState(existing, transition)
192
+ this.writeRelation(key, next.state)
193
+ return next.result
194
+ }
195
+
196
+ /**
197
+ * Resets relation to server state.
198
+ */
199
+ resetRelation(key: string): void {
200
+ const existing = this.relationStates.get(key)
201
+ if (!existing) return
202
+
203
+ this.writeRelation(key, {
204
+ ...existing,
205
+ currentId: existing.serverId,
206
+ state: existing.serverState,
207
+ placeholderData: {},
208
+ version: existing.version + 1,
209
+ })
210
+ this.editableWriteVersion++
211
+ }
212
+
213
+ /**
214
+ * Collects the ids of child entities reachable through LIVE has-one edges
215
+ * (key prefix "parentType:parentId:") — an O(degree) read of the edge index.
216
+ */
217
+ collectLiveChildIds(keyPrefix: string, ids: Set<string>): void {
218
+ this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids)
219
+ }
220
+
221
+ /**
222
+ * Adds the composite parent keys of every LIVE has-one edge pointing at
223
+ * {@link childId} — an O(degree) read of the edge index, the exact reverse of
224
+ * {@link collectLiveChildIds}.
225
+ */
226
+ collectParentKeysForChild(childId: string, parents: Set<string>): void {
227
+ this.edges.collectParents(childId, parents)
228
+ }
229
+
230
+ /**
231
+ * Collects the keys of every has-one relation owned by an entity (keys under the
232
+ * given owner prefix). Mirrors {@link removeOwnedRelations} but only enumerates.
233
+ */
234
+ collectOwnedKeys(keyPrefix: string, keys: string[]): void {
235
+ for (const key of this.relationStates.keys()) {
236
+ if (key.startsWith(keyPrefix)) keys.push(key)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Removes a single has-one relation state (and its live edge). Used by undo
242
+ * restore to drop a relation that did not exist before the gesture.
243
+ */
244
+ removeRelation(key: string): void {
245
+ this.deleteRelation(key)
246
+ }
247
+
248
+ /**
249
+ * Removes all has-one state owned by an entity (keys under the given owner
250
+ * prefix), dropping each entry's edge through {@link deleteRelation}.
251
+ */
252
+ removeOwnedRelations(keyPrefix: string): void {
253
+ for (const key of [...this.relationStates.keys()]) {
254
+ if (key.startsWith(keyPrefix)) {
255
+ this.deleteRelation(key)
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Commits all has-one relations for an entity.
262
+ *
263
+ * A relation whose planned op targets an item in `pendingItems` (by relation key)
264
+ * was not sent and is left uncommitted, so the op is retried on the next persist.
265
+ */
266
+ commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap<string, ReadonlySet<string>>): void {
267
+ for (const [key, state] of this.relationStates) {
268
+ if (!key.startsWith(keyPrefix)) continue
269
+ const pending = pendingItems?.get(key)
270
+ if (pending && ((state.currentId !== null && pending.has(state.currentId)) || (state.serverId !== null && pending.has(state.serverId)))) {
271
+ continue
272
+ }
273
+ this.commitRelation(key)
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Resets all has-one relations for an entity to server state.
279
+ */
280
+ resetAllRelations(keyPrefix: string): void {
281
+ for (const key of this.relationStates.keys()) {
282
+ if (key.startsWith(keyPrefix)) {
283
+ this.resetRelation(key)
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Collects the field names of dirty has-one relations for an entity.
290
+ */
291
+ collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void {
292
+ for (const [key, state] of this.relationStates) {
293
+ if (!key.startsWith(keyPrefix)) continue
294
+ const fieldName = key.slice(keyPrefix.length)
295
+
296
+ if (
297
+ state.currentId !== state.serverId ||
298
+ state.state !== state.serverState ||
299
+ Object.keys(state.placeholderData).length > 0
300
+ ) {
301
+ dirtyRelations.push(fieldName)
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Exports relation states for given keys.
308
+ */
309
+ exportRelationStates(keys: string[]): Map<string, StoredRelationState> {
310
+ const result = new Map<string, StoredRelationState>()
311
+ for (const key of keys) {
312
+ const state = this.relationStates.get(key)
313
+ if (state) {
314
+ result.set(key, cloneRelationState(state))
315
+ }
316
+ }
317
+ return result
318
+ }
319
+
320
+ /**
321
+ * Imports relation states from a snapshot.
322
+ * Returns the keys that were imported for notification.
323
+ */
324
+ importRelationStates(states: Map<string, StoredRelationState>): string[] {
325
+ const keys: string[] = []
326
+ for (const [key, state] of states) {
327
+ this.writeRelation(key, {
328
+ ...state,
329
+ placeholderData: { ...state.placeholderData },
330
+ })
331
+ keys.push(key)
332
+ }
333
+ return keys
334
+ }
335
+
336
+ /**
337
+ * Replaces all occurrences of oldId with newId across has-one relation states
338
+ * (currentId, serverId).
339
+ */
340
+ replaceEntityId(oldId: string, newId: string): void {
341
+ for (const [key, state] of this.relationStates) {
342
+ let changed = false
343
+ let currentId = state.currentId
344
+ let serverId = state.serverId
345
+
346
+ if (currentId === oldId) { currentId = newId; changed = true }
347
+ if (serverId === oldId) { serverId = newId; changed = true }
348
+
349
+ if (changed) {
350
+ this.writeRelation(key, { ...state, currentId, serverId, version: state.version + 1 })
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Rekeys has-one entries owned by an entity (changes the parent ID in the key).
357
+ * Routing through {@link deleteRelation} + {@link writeRelation} migrates the
358
+ * edge index from the old parent key to the new one for free.
359
+ */
360
+ rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
361
+ const toMove: [string, StoredRelationState][] = []
362
+ for (const [key, value] of this.relationStates) {
363
+ if (key.startsWith(oldKeyPrefix)) {
364
+ toMove.push([key, value])
365
+ }
366
+ }
367
+ for (const [oldKey, value] of toMove) {
368
+ this.deleteRelation(oldKey)
369
+ this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Clears all has-one relation data.
375
+ */
376
+ clear(): void {
377
+ this.relationStates.clear()
378
+ this.edges.clear()
379
+ this.mutationVersion++
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The single liveness predicate for a has-one edge: the related id is live when
385
+ * the relation is connected to it and not deleted. Defined once and consumed by
386
+ * the write chokepoint so the forward/reverse index can never drift from it.
387
+ */
388
+ function liveHasOneChildId(state: StoredRelationState | undefined): string | null {
389
+ if (!state) return null
390
+ return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null
391
+ }
392
+
393
+ interface HasOneReconciliation {
394
+ state: StoredRelationState
395
+ result: ReconciliationResult
396
+ }
397
+
398
+ function reconcileHasOneState(
399
+ existing: StoredRelationState,
400
+ transition: SentHasOneTransition,
401
+ ): HasOneReconciliation {
402
+ if (transition.operation === 'connect' || transition.operation === 'create') {
403
+ return reconcileHasOneAddition(existing, transition)
404
+ }
405
+ return reconcileHasOneRemoval(existing, transition)
406
+ }
407
+
408
+ function reconcileHasOneAddition(
409
+ existing: StoredRelationState,
410
+ transition: Extract<SentHasOneTransition, { operation: 'connect' | 'create' }>,
411
+ ): HasOneReconciliation {
412
+ const wasRemoved = existing.currentId !== transition.targetId || existing.state !== 'connected'
413
+ const removedCreatedTarget = transition.operation === 'create'
414
+ && existing.currentId === null
415
+ && existing.state === 'disconnected'
416
+
417
+ return {
418
+ state: {
419
+ ...existing,
420
+ currentId: removedCreatedTarget ? transition.targetId : existing.currentId,
421
+ serverId: transition.targetId,
422
+ state: removedCreatedTarget ? 'deleted' : existing.state,
423
+ serverState: 'connected',
424
+ placeholderData: wasRemoved ? existing.placeholderData : {},
425
+ version: existing.version + 1,
426
+ },
427
+ result: 'applied',
428
+ }
429
+ }
430
+
431
+ function reconcileHasOneRemoval(
432
+ existing: StoredRelationState,
433
+ transition: Extract<SentHasOneTransition, { operation: 'disconnect' | 'delete' }>,
434
+ ): HasOneReconciliation {
435
+ const isCompletedDelete = transition.operation === 'delete'
436
+ && existing.currentId === transition.targetId
437
+ && existing.state === 'deleted'
438
+ const isDeleteReversal = transition.operation === 'delete'
439
+ && existing.currentId === transition.targetId
440
+ && existing.state !== 'deleted'
441
+
442
+ return {
443
+ state: {
444
+ ...existing,
445
+ currentId: isCompletedDelete ? null : existing.currentId,
446
+ serverId: null,
447
+ state: isCompletedDelete ? 'disconnected' : existing.state,
448
+ serverState: 'disconnected',
449
+ placeholderData: isCompletedDelete ? {} : existing.placeholderData,
450
+ version: existing.version + 1,
451
+ },
452
+ result: isDeleteReversal ? 'conflict' : 'applied',
453
+ }
454
+ }
@@ -18,6 +18,14 @@ import type { RootRegistry } from './RootRegistry.js'
18
18
  * The graph is read from {@link RelationStore} (the source of truth for live
19
19
  * relation membership), never from the subscription parent-child registry,
20
20
  * which is append-mostly for notifications and does not reflect disconnects.
21
+ *
22
+ * The walk is O(E+R) and runs on every dirty check and post-persist sweep, so
23
+ * its result is memoized. Each sub-store the walk reads exposes a monotonic
24
+ * `getMutationVersion()` that bumps only when graph-relevant state changes
25
+ * (entity key set, `existsOnServer`/`isPersisting`, roots, relation edges).
26
+ * Their sum is a cache key: it is strictly increasing, so an unchanged sum
27
+ * proves nothing relevant changed and the cached set can be returned. Pure
28
+ * field edits do not bump any counter, keeping the cache warm on the hot path.
21
29
  */
22
30
  export class ReachabilityAnalyzer {
23
31
  constructor(
@@ -27,11 +35,40 @@ export class ReachabilityAnalyzer {
27
35
  private readonly roots: RootRegistry,
28
36
  ) {}
29
37
 
38
+ private cache: { version: number; result: Set<string> } | null = null
39
+
30
40
  /**
31
41
  * Returns the set of entity keys ("entityType:id") for created
32
42
  * (never-persisted) entities reachable from a root through live relations.
43
+ *
44
+ * The returned set is the cached instance and is owned by the analyzer —
45
+ * callers must treat it as read-only (the current consumers only call
46
+ * `.has(...)` on it).
33
47
  */
34
48
  computeReachableCreated(): Set<string> {
49
+ const version = this.graphVersion()
50
+ const cached = this.cache
51
+ if (cached !== null && cached.version === version) {
52
+ return cached.result
53
+ }
54
+
55
+ const result = this.walk()
56
+ this.cache = { version, result }
57
+ return result
58
+ }
59
+
60
+ /**
61
+ * Sum of the graph-relevant mutation counters across the sub-stores the walk
62
+ * reads. Monotonic, so an unchanged value means no relevant mutation happened.
63
+ */
64
+ private graphVersion(): number {
65
+ return this.entitySnapshots.getMutationVersion()
66
+ + this.meta.getMutationVersion()
67
+ + this.relations.getMutationVersion()
68
+ + this.roots.getMutationVersion()
69
+ }
70
+
71
+ private walk(): Set<string> {
35
72
  const reachableCreated = new Set<string>()
36
73
  const visited = new Set<string>()
37
74
  const stack: string[] = []
@@ -0,0 +1,123 @@
1
+ import { isPersistedId, isPlaceholderId } from './entityId.js'
2
+
3
+ /**
4
+ * Describes a single temp→persisted rekey in every shape the participating
5
+ * sub-stores need (full key, owner prefix, bare id).
6
+ */
7
+ export interface RekeyContext {
8
+ /** Old composite key, "entityType:tempId". */
9
+ readonly oldKey: string
10
+ /** New composite key, "entityType:persistedId". */
11
+ readonly newKey: string
12
+ /** Old owner prefix, "entityType:tempId:". */
13
+ readonly oldKeyPrefix: string
14
+ /** New owner prefix, "entityType:persistedId:". */
15
+ readonly newKeyPrefix: string
16
+ /** The temp id being replaced. */
17
+ readonly oldId: string
18
+ /** The server-assigned id. */
19
+ readonly newId: string
20
+ }
21
+
22
+ /**
23
+ * A store that owns per-entity state keyed (directly or by prefix) on an entity
24
+ * id and can migrate it from a temp id to its persisted id.
25
+ */
26
+ export interface Rekeyable {
27
+ rekey(ctx: RekeyContext): void
28
+ }
29
+
30
+ /**
31
+ * Single source of temp→persisted identity, and the one place the rekey fan-out
32
+ * is sequenced.
33
+ *
34
+ * Owns the only id-redirect map: "entityType:tempId" → persistedId. Both key
35
+ * resolution ({@link resolveKey}/{@link resolveId}) and persisted-id queries
36
+ * ({@link getPersistedId}/{@link isNewEntity}) derive from it, so there is no
37
+ * second copy to keep in sync — the former `SnapshotStore.rekeyedEntities` and
38
+ * `EntityMetaStore.tempToPersistedId` are both gone. `SubscriptionManager` keeps
39
+ * its own closure-redirect chain, which tracks relation-key prefixes and stale
40
+ * unsubscribe closures rather than entity identity, so it stays internal there.
41
+ */
42
+ export class RekeyOrchestrator {
43
+ /** "entityType:tempId" → persistedId. The single identity-redirect map. */
44
+ private readonly tempToPersisted = new Map<string, string>()
45
+ private readonly participants: Rekeyable[] = []
46
+ private readonly participantSet = new Set<Rekeyable>()
47
+
48
+ /**
49
+ * @param participants the sub-stores to migrate, in the exact order
50
+ * {@link rekey} must visit them (the order is load-bearing — see rekey()).
51
+ */
52
+ constructor(participants: readonly Rekeyable[]) {
53
+ for (const participant of participants) this.registerParticipant(participant)
54
+ }
55
+
56
+ /** Adds a participant once, preserving registration order. */
57
+ registerParticipant(participant: Rekeyable): void {
58
+ if (this.participantSet.has(participant)) return
59
+ this.participantSet.add(participant)
60
+ this.participants.push(participant)
61
+ }
62
+
63
+ /** Resolves an entity key, following a temp→persisted redirect if present. */
64
+ resolveKey(entityType: string, id: string): string {
65
+ const persisted = this.tempToPersisted.get(`${entityType}:${id}`)
66
+ return persisted !== undefined ? `${entityType}:${persisted}` : `${entityType}:${id}`
67
+ }
68
+
69
+ /** Resolves an entity id to its persisted id if it has been rekeyed. */
70
+ resolveId(entityType: string, id: string): string {
71
+ return this.tempToPersisted.get(`${entityType}:${id}`) ?? id
72
+ }
73
+
74
+ /** The persisted id for an entity, or null if it has none yet. */
75
+ getPersistedId(entityType: string, id: string): string | null {
76
+ if (isPlaceholderId(id)) return null
77
+ if (isPersistedId(id)) return id
78
+ return this.tempToPersisted.get(`${entityType}:${id}`) ?? null
79
+ }
80
+
81
+ /** Whether an entity has never been persisted (no server-assigned id yet). */
82
+ isNewEntity(entityType: string, id: string): boolean {
83
+ if (isPlaceholderId(id)) return true
84
+ if (isPersistedId(id)) return false
85
+ return !this.tempToPersisted.has(`${entityType}:${id}`)
86
+ }
87
+
88
+ /**
89
+ * Migrates an entity from its temp id to its server-assigned id across every
90
+ * participating store.
91
+ *
92
+ * Ordering contract (do not reorder — each step relies on the previous):
93
+ * 0. register the redirect, so any resolveKey/resolveId during the fan-out
94
+ * already sees the persisted key;
95
+ * 1. roots — keep the root registry aligned;
96
+ * 2. entity snapshot — move data, rewrite the id field and id index;
97
+ * 3. meta — move metadata/load/persisting, then mark exists-on-server;
98
+ * 4. subscriptions — move entity + relation subscribers and parent links;
99
+ * 5. relations — rekey owned relation keys, then replace value references;
100
+ * 6. errors / touched / propagation — move the remaining per-entity state.
101
+ * The caller performs the final notification on the new key.
102
+ */
103
+ rekey(entityType: string, tempId: string, persistedId: string): void {
104
+ const ctx: RekeyContext = {
105
+ oldKey: `${entityType}:${tempId}`,
106
+ newKey: `${entityType}:${persistedId}`,
107
+ oldKeyPrefix: `${entityType}:${tempId}:`,
108
+ newKeyPrefix: `${entityType}:${persistedId}:`,
109
+ oldId: tempId,
110
+ newId: persistedId,
111
+ }
112
+
113
+ this.tempToPersisted.set(ctx.oldKey, persistedId)
114
+
115
+ for (const participant of this.participants) {
116
+ participant.rekey(ctx)
117
+ }
118
+ }
119
+
120
+ clear(): void {
121
+ this.tempToPersisted.clear()
122
+ }
123
+ }