@contember/bindx 0.1.30 → 0.1.32
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/ContemberAdapter.d.ts.map +1 -1
- package/dist/adapter/types.d.ts +4 -0
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +4 -2
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +5 -0
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +1 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +6 -0
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/types.d.ts +1 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +30 -0
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +28 -0
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/types.d.ts +6 -0
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +4 -0
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +4 -0
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +6 -0
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/RelationStore.d.ts +15 -0
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +31 -0
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +15 -0
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +4 -0
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/adapter/ContemberAdapter.ts +11 -5
- package/src/adapter/types.ts +4 -0
- package/src/core/ActionDispatcher.ts +3 -0
- package/src/core/actions.ts +6 -2
- package/src/handles/BaseHandle.ts +16 -0
- package/src/handles/HasManyListHandle.ts +35 -25
- package/src/handles/HasOneHandle.ts +35 -8
- package/src/handles/types.ts +1 -0
- package/src/persistence/BatchPersister.ts +237 -0
- package/src/persistence/MutationCollector.ts +167 -19
- package/src/persistence/types.ts +6 -0
- package/src/store/EntityMetaStore.ts +29 -0
- package/src/store/EntitySnapshotStore.ts +23 -0
- package/src/store/ErrorStore.ts +30 -0
- package/src/store/RelationStore.ts +157 -1
- package/src/store/SnapshotStore.ts +116 -5
- package/src/store/SubscriptionManager.ts +82 -2
- package/src/store/TouchedStore.ts +16 -0
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { HasOneRelationState } from '../handles/types.js'
|
|
2
2
|
import type { EntitySnapshot } from './snapshots.js'
|
|
3
3
|
|
|
4
|
+
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
|
5
|
+
if (a.size !== b.size) return false
|
|
6
|
+
for (const item of a) {
|
|
7
|
+
if (!b.has(item)) return false
|
|
8
|
+
}
|
|
9
|
+
return true
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* Removal type for has-many items
|
|
6
14
|
*/
|
|
@@ -150,7 +158,8 @@ export class RelationStore {
|
|
|
150
158
|
* Gets or creates has-many list state.
|
|
151
159
|
*/
|
|
152
160
|
getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
|
|
153
|
-
|
|
161
|
+
const existing = this.hasManyStates.get(key)
|
|
162
|
+
if (!existing) {
|
|
154
163
|
this.hasManyStates.set(key, {
|
|
155
164
|
serverIds: new Set(serverIds ?? []),
|
|
156
165
|
orderedIds: null,
|
|
@@ -159,6 +168,16 @@ export class RelationStore {
|
|
|
159
168
|
createdEntities: new Set(),
|
|
160
169
|
version: 0,
|
|
161
170
|
})
|
|
171
|
+
} else if (serverIds !== undefined) {
|
|
172
|
+
const newServerIds = new Set(serverIds)
|
|
173
|
+
if (!setsEqual(existing.serverIds, newServerIds)) {
|
|
174
|
+
this.hasManyStates.set(key, {
|
|
175
|
+
...existing,
|
|
176
|
+
serverIds: newServerIds,
|
|
177
|
+
orderedIds: null,
|
|
178
|
+
version: existing.version + 1,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
162
181
|
}
|
|
163
182
|
|
|
164
183
|
return this.hasManyStates.get(key)!
|
|
@@ -365,6 +384,37 @@ export class RelationStore {
|
|
|
365
384
|
}
|
|
366
385
|
}
|
|
367
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Connects an existing (persisted) entity to a has-many relation.
|
|
389
|
+
* Unlike addToHasMany, does NOT add to createdEntities — used for
|
|
390
|
+
* materializing embedded connect references to existing entities.
|
|
391
|
+
*/
|
|
392
|
+
connectExistingToHasMany(key: string, itemId: string): void {
|
|
393
|
+
const existing = this.hasManyStates.get(key)
|
|
394
|
+
|
|
395
|
+
if (!existing) {
|
|
396
|
+
this.hasManyStates.set(key, {
|
|
397
|
+
serverIds: new Set(),
|
|
398
|
+
orderedIds: [itemId],
|
|
399
|
+
plannedRemovals: new Map(),
|
|
400
|
+
plannedConnections: new Set([itemId]),
|
|
401
|
+
createdEntities: new Set(),
|
|
402
|
+
version: 0,
|
|
403
|
+
})
|
|
404
|
+
} else {
|
|
405
|
+
const newPlannedConnections = new Set(existing.plannedConnections)
|
|
406
|
+
newPlannedConnections.add(itemId)
|
|
407
|
+
const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
|
|
408
|
+
const newOrderedIds = [...currentOrderedIds, itemId]
|
|
409
|
+
this.hasManyStates.set(key, {
|
|
410
|
+
...existing,
|
|
411
|
+
orderedIds: newOrderedIds,
|
|
412
|
+
plannedConnections: newPlannedConnections,
|
|
413
|
+
version: existing.version + 1,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
368
418
|
/**
|
|
369
419
|
* Removes an entity from a has-many relation.
|
|
370
420
|
* For newly created entities (via add()), cancels the connection.
|
|
@@ -654,6 +704,112 @@ export class RelationStore {
|
|
|
654
704
|
return keys
|
|
655
705
|
}
|
|
656
706
|
|
|
707
|
+
/**
|
|
708
|
+
* Replaces all occurrences of oldId with newId across relation and hasMany states.
|
|
709
|
+
* Used after persist to rekey temp IDs to server-assigned IDs.
|
|
710
|
+
*/
|
|
711
|
+
replaceEntityId(oldId: string, newId: string): void {
|
|
712
|
+
// Replace in has-one relation states: currentId, serverId
|
|
713
|
+
for (const [key, state] of this.relationStates) {
|
|
714
|
+
let changed = false
|
|
715
|
+
let currentId = state.currentId
|
|
716
|
+
let serverId = state.serverId
|
|
717
|
+
|
|
718
|
+
if (currentId === oldId) { currentId = newId; changed = true }
|
|
719
|
+
if (serverId === oldId) { serverId = newId; changed = true }
|
|
720
|
+
|
|
721
|
+
if (changed) {
|
|
722
|
+
this.relationStates.set(key, { ...state, currentId, serverId, version: state.version + 1 })
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Replace in has-many states: serverIds, orderedIds, plannedConnections, createdEntities, plannedRemovals
|
|
727
|
+
for (const [key, state] of this.hasManyStates) {
|
|
728
|
+
let changed = false
|
|
729
|
+
|
|
730
|
+
let serverIds = state.serverIds
|
|
731
|
+
if (serverIds.has(oldId)) {
|
|
732
|
+
serverIds = new Set(serverIds)
|
|
733
|
+
serverIds.delete(oldId)
|
|
734
|
+
serverIds.add(newId)
|
|
735
|
+
changed = true
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let orderedIds = state.orderedIds
|
|
739
|
+
if (orderedIds) {
|
|
740
|
+
const idx = orderedIds.indexOf(oldId)
|
|
741
|
+
if (idx !== -1) {
|
|
742
|
+
orderedIds = [...orderedIds]
|
|
743
|
+
orderedIds[idx] = newId
|
|
744
|
+
changed = true
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let plannedConnections = state.plannedConnections
|
|
749
|
+
if (plannedConnections.has(oldId)) {
|
|
750
|
+
plannedConnections = new Set(plannedConnections)
|
|
751
|
+
plannedConnections.delete(oldId)
|
|
752
|
+
plannedConnections.add(newId)
|
|
753
|
+
changed = true
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let createdEntities = state.createdEntities
|
|
757
|
+
if (createdEntities.has(oldId)) {
|
|
758
|
+
createdEntities = new Set(createdEntities)
|
|
759
|
+
createdEntities.delete(oldId)
|
|
760
|
+
createdEntities.add(newId)
|
|
761
|
+
changed = true
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let plannedRemovals = state.plannedRemovals
|
|
765
|
+
if (plannedRemovals.has(oldId)) {
|
|
766
|
+
const removalType = plannedRemovals.get(oldId)!
|
|
767
|
+
plannedRemovals = new Map(plannedRemovals)
|
|
768
|
+
plannedRemovals.delete(oldId)
|
|
769
|
+
plannedRemovals.set(newId, removalType)
|
|
770
|
+
changed = true
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (changed) {
|
|
774
|
+
this.hasManyStates.set(key, {
|
|
775
|
+
serverIds,
|
|
776
|
+
orderedIds,
|
|
777
|
+
plannedRemovals,
|
|
778
|
+
plannedConnections,
|
|
779
|
+
createdEntities,
|
|
780
|
+
version: state.version + 1,
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Rekeys relation/hasMany entries owned by an entity (changes the parent ID in the key).
|
|
788
|
+
*/
|
|
789
|
+
rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
790
|
+
const toMoveRelations: [string, StoredRelationState][] = []
|
|
791
|
+
for (const [key, value] of this.relationStates) {
|
|
792
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
793
|
+
toMoveRelations.push([key, value])
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
for (const [oldKey, value] of toMoveRelations) {
|
|
797
|
+
this.relationStates.delete(oldKey)
|
|
798
|
+
this.relationStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const toMoveHasMany: [string, StoredHasManyState][] = []
|
|
802
|
+
for (const [key, value] of this.hasManyStates) {
|
|
803
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
804
|
+
toMoveHasMany.push([key, value])
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const [oldKey, value] of toMoveHasMany) {
|
|
808
|
+
this.hasManyStates.delete(oldKey)
|
|
809
|
+
this.hasManyStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
657
813
|
/**
|
|
658
814
|
* Clears all relation data.
|
|
659
815
|
*/
|
|
@@ -46,18 +46,44 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
46
46
|
private readonly touched = new TouchedStore()
|
|
47
47
|
private readonly dirtyTracker: DirtyTracker
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Tracks the last embedded data reference propagated from parent to child.
|
|
51
|
+
* Used to detect whether the parent was re-fetched (new reference) vs. stale
|
|
52
|
+
* embedded data that should not overwrite committed child state.
|
|
53
|
+
* Keyed by "parentType:parentId:fieldName".
|
|
54
|
+
*/
|
|
55
|
+
private readonly lastPropagatedData = new Map<string, unknown>()
|
|
56
|
+
|
|
49
57
|
constructor() {
|
|
50
58
|
this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations)
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
// ==================== Key Generation ====================
|
|
54
62
|
|
|
63
|
+
/** Maps temp ID entity keys to their persisted ID entity keys for transparent resolution */
|
|
64
|
+
private readonly rekeyedEntities = new Map<string, string>()
|
|
65
|
+
|
|
55
66
|
private getEntityKey(entityType: string, id: string): string {
|
|
56
|
-
|
|
67
|
+
const key = `${entityType}:${id}`
|
|
68
|
+
return this.rekeyedEntities.get(key) ?? key
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
private getRelationKey(parentType: string, parentId: string, fieldName: string): string {
|
|
60
|
-
|
|
72
|
+
const resolvedParentId = this.resolveId(parentType, parentId)
|
|
73
|
+
return `${parentType}:${resolvedParentId}:${fieldName}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves an ID to its persisted ID if it has been rekeyed.
|
|
78
|
+
*/
|
|
79
|
+
private resolveId(entityType: string, id: string): string {
|
|
80
|
+
const key = `${entityType}:${id}`
|
|
81
|
+
const rekeyed = this.rekeyedEntities.get(key)
|
|
82
|
+
if (rekeyed) {
|
|
83
|
+
// Extract the ID from the rekeyed key (format: "EntityType:id")
|
|
84
|
+
return rekeyed.slice(entityType.length + 1)
|
|
85
|
+
}
|
|
86
|
+
return id
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
// ==================== SnapshotVersionBumper ====================
|
|
@@ -80,6 +106,49 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
80
106
|
}
|
|
81
107
|
}
|
|
82
108
|
|
|
109
|
+
// ==================== Embedded Data Propagation Tracking ====================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns true if the embedded data reference differs from what was last propagated.
|
|
113
|
+
* Uses reference identity — a new reference means the parent was re-fetched.
|
|
114
|
+
*/
|
|
115
|
+
hasEmbeddedDataChanged(parentType: string, parentId: string, fieldName: string, currentData: unknown): boolean {
|
|
116
|
+
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
117
|
+
return this.lastPropagatedData.get(key) !== currentData
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Records the embedded data reference that was propagated to the child.
|
|
122
|
+
*/
|
|
123
|
+
markEmbeddedDataPropagated(parentType: string, parentId: string, fieldName: string, data: unknown): void {
|
|
124
|
+
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
125
|
+
this.lastPropagatedData.set(key, data)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Removes all propagation tracking entries for a given parent entity.
|
|
130
|
+
*/
|
|
131
|
+
clearPropagatedDataForEntity(parentType: string, parentId: string): void {
|
|
132
|
+
const prefix = `${parentType}:${this.resolveId(parentType, parentId)}:`
|
|
133
|
+
for (const key of this.lastPropagatedData.keys()) {
|
|
134
|
+
if (key.startsWith(prefix)) {
|
|
135
|
+
this.lastPropagatedData.delete(key)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Rekeys propagation tracking entries when a temp ID is replaced by a persisted ID.
|
|
142
|
+
*/
|
|
143
|
+
private rekeyPropagatedData(oldPrefix: string, newPrefix: string): void {
|
|
144
|
+
for (const [key, value] of this.lastPropagatedData) {
|
|
145
|
+
if (key.startsWith(oldPrefix)) {
|
|
146
|
+
this.lastPropagatedData.delete(key)
|
|
147
|
+
this.lastPropagatedData.set(newPrefix + key.slice(oldPrefix.length), value)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
83
152
|
// ==================== Entity Snapshots ====================
|
|
84
153
|
|
|
85
154
|
getEntitySnapshot<T extends object>(entityType: string, id: string): EntitySnapshot<T> | undefined {
|
|
@@ -154,6 +223,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
154
223
|
const key = this.getEntityKey(entityType, id)
|
|
155
224
|
this.entitySnapshots.remove(key)
|
|
156
225
|
this.meta.clearLoadState(key)
|
|
226
|
+
this.clearPropagatedDataForEntity(entityType, id)
|
|
157
227
|
this.notifyEntitySubscribers(key)
|
|
158
228
|
}
|
|
159
229
|
|
|
@@ -218,9 +288,38 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
218
288
|
}
|
|
219
289
|
|
|
220
290
|
mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
291
|
+
// Use raw keys (bypass resolveId) since we're the ones creating the mapping
|
|
292
|
+
const oldKey = `${entityType}:${tempId}`
|
|
293
|
+
const newKey = `${entityType}:${persistedId}`
|
|
294
|
+
const oldKeyPrefix = `${entityType}:${tempId}:`
|
|
295
|
+
const newKeyPrefix = `${entityType}:${persistedId}:`
|
|
296
|
+
|
|
297
|
+
// Register redirect so future lookups by temp ID resolve to persisted key
|
|
298
|
+
this.rekeyedEntities.set(oldKey, newKey)
|
|
299
|
+
|
|
300
|
+
// Rekey entity snapshot (moves data, updates id field)
|
|
301
|
+
this.entitySnapshots.rekey(oldKey, newKey, persistedId)
|
|
302
|
+
|
|
303
|
+
// Rekey metadata FIRST, then set persisted mapping (which sets existsOnServer)
|
|
304
|
+
this.meta.rekey(oldKey, newKey)
|
|
305
|
+
this.meta.mapTempIdToPersistedId(newKey, persistedId)
|
|
306
|
+
|
|
307
|
+
// Rekey subscriptions, parent-child relationships, and relation subscribers
|
|
308
|
+
this.subscriptions.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
309
|
+
|
|
310
|
+
// Rekey relations/hasMany owned by this entity (parent key changes)
|
|
311
|
+
this.relations.rekeyOwner(oldKeyPrefix, newKeyPrefix)
|
|
312
|
+
|
|
313
|
+
// Replace tempId with persistedId in all relation/hasMany VALUE references
|
|
314
|
+
this.relations.replaceEntityId(tempId, persistedId)
|
|
315
|
+
|
|
316
|
+
// Rekey errors, touched state, and propagation tracking
|
|
317
|
+
this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
318
|
+
this.touched.rekey(oldKeyPrefix, newKeyPrefix)
|
|
319
|
+
this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix)
|
|
320
|
+
|
|
321
|
+
// Notify on the NEW key so React picks up the change
|
|
322
|
+
this.notifyEntitySubscribers(newKey)
|
|
224
323
|
}
|
|
225
324
|
|
|
226
325
|
getPersistedId(entityType: string, id: string): string | null {
|
|
@@ -372,6 +471,17 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
372
471
|
this.notifyRelationSubscribers(key)
|
|
373
472
|
}
|
|
374
473
|
|
|
474
|
+
connectExistingToHasMany(
|
|
475
|
+
parentType: string,
|
|
476
|
+
parentId: string,
|
|
477
|
+
fieldName: string,
|
|
478
|
+
itemId: string,
|
|
479
|
+
): void {
|
|
480
|
+
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
481
|
+
this.relations.connectExistingToHasMany(key, itemId)
|
|
482
|
+
this.notifyRelationSubscribers(key)
|
|
483
|
+
}
|
|
484
|
+
|
|
375
485
|
removeFromHasMany(
|
|
376
486
|
parentType: string,
|
|
377
487
|
parentId: string,
|
|
@@ -760,6 +870,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
760
870
|
this.relations.clear()
|
|
761
871
|
this.errors.clear()
|
|
762
872
|
this.touched.clear()
|
|
873
|
+
this.lastPropagatedData.clear()
|
|
763
874
|
|
|
764
875
|
this.subscriptions.notify()
|
|
765
876
|
}
|
|
@@ -31,9 +31,28 @@ export class SubscriptionManager {
|
|
|
31
31
|
/** Parent-child relationships: childKey -> Set of parentKeys */
|
|
32
32
|
private readonly childToParents = new Map<string, Set<string>>()
|
|
33
33
|
|
|
34
|
+
/** Maps old keys → new keys after rekey, so unsubscribe closures can find migrated callbacks */
|
|
35
|
+
private readonly rekeyedKeys = new Map<string, string>()
|
|
36
|
+
|
|
34
37
|
/** Global version number for change detection */
|
|
35
38
|
private globalVersion = 0
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Resolves a key through the rekey redirect chain.
|
|
42
|
+
* The rekey() method collapses chains (A→C instead of A→B→C), so this should
|
|
43
|
+
* always resolve in one hop. The loop with depth limit is a safety net in case
|
|
44
|
+
* chain collapsing has a bug — prevents infinite loops instead of silently hanging.
|
|
45
|
+
*/
|
|
46
|
+
private resolveKey(key: string): string {
|
|
47
|
+
let resolved = key
|
|
48
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
49
|
+
const next = this.rekeyedKeys.get(resolved)
|
|
50
|
+
if (!next || next === resolved) return resolved
|
|
51
|
+
resolved = next
|
|
52
|
+
}
|
|
53
|
+
return resolved
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/**
|
|
38
57
|
* Subscribe to changes on a specific entity.
|
|
39
58
|
* Returns unsubscribe function.
|
|
@@ -46,7 +65,8 @@ export class SubscriptionManager {
|
|
|
46
65
|
this.entitySubscribers.get(key)!.add(callback)
|
|
47
66
|
|
|
48
67
|
return () => {
|
|
49
|
-
this.
|
|
68
|
+
const resolvedKey = this.resolveKey(key)
|
|
69
|
+
this.entitySubscribers.get(resolvedKey)?.delete(callback)
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
|
|
@@ -62,7 +82,8 @@ export class SubscriptionManager {
|
|
|
62
82
|
this.relationSubscribers.get(key)!.add(callback)
|
|
63
83
|
|
|
64
84
|
return () => {
|
|
65
|
-
this.
|
|
85
|
+
const resolvedKey = this.resolveKey(key)
|
|
86
|
+
this.relationSubscribers.get(resolvedKey)?.delete(callback)
|
|
66
87
|
}
|
|
67
88
|
}
|
|
68
89
|
|
|
@@ -238,4 +259,63 @@ export class SubscriptionManager {
|
|
|
238
259
|
sub()
|
|
239
260
|
}
|
|
240
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Moves subscriptions and parent-child relationships from oldKey to newKey.
|
|
265
|
+
* Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix.
|
|
266
|
+
* Registers redirects so unsubscribe closures can find migrated callbacks.
|
|
267
|
+
*/
|
|
268
|
+
rekey(oldKey: string, newKey: string, oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
269
|
+
// Register redirect for entity key (update existing chains first)
|
|
270
|
+
for (const [fromKey, toKey] of this.rekeyedKeys) {
|
|
271
|
+
if (toKey === oldKey) {
|
|
272
|
+
this.rekeyedKeys.set(fromKey, newKey)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
this.rekeyedKeys.set(oldKey, newKey)
|
|
276
|
+
|
|
277
|
+
// Move entity subscribers
|
|
278
|
+
const entitySubs = this.entitySubscribers.get(oldKey)
|
|
279
|
+
if (entitySubs) {
|
|
280
|
+
this.entitySubscribers.delete(oldKey)
|
|
281
|
+
this.entitySubscribers.set(newKey, entitySubs)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Move relation subscribers by prefix (e.g. "Entity:tempId:" → "Entity:persistedId:")
|
|
285
|
+
const toMoveRelations: [string, Set<Subscriber>][] = []
|
|
286
|
+
for (const [key, subs] of this.relationSubscribers) {
|
|
287
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
288
|
+
toMoveRelations.push([key, subs])
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const [oldRelKey, subs] of toMoveRelations) {
|
|
292
|
+
const newRelKey = newKeyPrefix + oldRelKey.slice(oldKeyPrefix.length)
|
|
293
|
+
|
|
294
|
+
// Register redirect for relation key (update existing chains first)
|
|
295
|
+
for (const [fromKey, toKey] of this.rekeyedKeys) {
|
|
296
|
+
if (toKey === oldRelKey) {
|
|
297
|
+
this.rekeyedKeys.set(fromKey, newRelKey)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this.rekeyedKeys.set(oldRelKey, newRelKey)
|
|
301
|
+
|
|
302
|
+
this.relationSubscribers.delete(oldRelKey)
|
|
303
|
+
this.relationSubscribers.set(newRelKey, subs)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Move parent-child: update child→parent mappings
|
|
307
|
+
const parents = this.childToParents.get(oldKey)
|
|
308
|
+
if (parents) {
|
|
309
|
+
this.childToParents.delete(oldKey)
|
|
310
|
+
this.childToParents.set(newKey, parents)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Update parent-child: replace oldKey in any parent sets that reference it
|
|
314
|
+
for (const parentSet of this.childToParents.values()) {
|
|
315
|
+
if (parentSet.has(oldKey)) {
|
|
316
|
+
parentSet.delete(oldKey)
|
|
317
|
+
parentSet.add(newKey)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
241
321
|
}
|
|
@@ -36,6 +36,22 @@ export class TouchedStore {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Rekeys all touched fields from oldKeyPrefix to newKeyPrefix.
|
|
41
|
+
*/
|
|
42
|
+
rekey(oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
43
|
+
const toMove: [string, boolean][] = []
|
|
44
|
+
for (const [key, value] of this.touchedFields) {
|
|
45
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
46
|
+
toMove.push([key, value])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const [oldKey, value] of toMove) {
|
|
50
|
+
this.touchedFields.delete(oldKey)
|
|
51
|
+
this.touchedFields.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
clear(): void {
|
|
40
56
|
this.touchedFields.clear()
|
|
41
57
|
}
|