@contember/bindx 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/MockAdapter.d.ts +4 -1
- package/dist/adapter/MockAdapter.d.ts.map +1 -1
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/adapter/MockAdapter.ts +15 -5
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +120 -16
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- 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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
198
|
-
* The bumper callback is used to bump
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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:
|
|
292
|
-
for (const
|
|
336
|
+
const toMoveRelations: string[] = []
|
|
337
|
+
for (const key of this.relationSubscribers.keys()) {
|
|
293
338
|
if (key.startsWith(oldKeyPrefix)) {
|
|
294
|
-
toMoveRelations.push(
|
|
339
|
+
toMoveRelations.push(key)
|
|
295
340
|
}
|
|
296
341
|
}
|
|
297
|
-
for (const
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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(
|
|
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 }
|