@contember/bindx 0.1.29 → 0.1.31
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/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +10 -0
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/types.d.ts +2 -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 +7 -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/HasManyListHandle.ts +1 -6
- package/src/handles/HasOneHandle.ts +22 -1
- package/src/handles/types.ts +2 -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 +137 -0
- package/src/store/SnapshotStore.ts +62 -5
- package/src/store/SubscriptionManager.ts +82 -2
- package/src/store/TouchedStore.ts +16 -0
|
@@ -32,6 +32,8 @@ export interface EntityMutationResult {
|
|
|
32
32
|
export class MutationCollector implements MutationDataCollector {
|
|
33
33
|
private excludedEntityIds: ReadonlySet<string> = new Set()
|
|
34
34
|
private readonly _nestedEntityIds: Set<string> = new Set()
|
|
35
|
+
/** Maps nested entity temp IDs to their entity types for post-persist processing */
|
|
36
|
+
private readonly _nestedEntityTypes: Map<string, string> = new Map()
|
|
35
37
|
|
|
36
38
|
constructor(
|
|
37
39
|
private readonly store: SnapshotStore,
|
|
@@ -46,6 +48,7 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
46
48
|
setExcludedEntities(ids: ReadonlySet<string>): void {
|
|
47
49
|
this.excludedEntityIds = ids
|
|
48
50
|
this._nestedEntityIds.clear()
|
|
51
|
+
this._nestedEntityTypes.clear()
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -57,6 +60,14 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
57
60
|
return this._nestedEntityIds
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Returns a map of nested entity temp IDs to their entity types.
|
|
65
|
+
* Used by BatchPersister to commit and map IDs for nested entities after persist.
|
|
66
|
+
*/
|
|
67
|
+
getNestedEntityTypes(): ReadonlyMap<string, string> {
|
|
68
|
+
return this._nestedEntityTypes
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
// ==================== Main Collection Methods ====================
|
|
61
72
|
|
|
62
73
|
/**
|
|
@@ -175,23 +186,22 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
|
|
178
|
-
//
|
|
189
|
+
// Materialize embedded relation data into store entities,
|
|
190
|
+
// then collect from store for consistent tracking and post-persist ID mapping
|
|
191
|
+
this.materializeEntityRelations(entityType, entityId)
|
|
192
|
+
|
|
193
|
+
// Collect relation values from store
|
|
179
194
|
const relationFields = this.schemaProvider.getRelationFields(entityType)
|
|
180
195
|
for (const fieldName of relationFields) {
|
|
181
|
-
const value = data[fieldName]
|
|
182
196
|
const relationType = this.schemaProvider.getRelationType(entityType, fieldName)
|
|
183
197
|
|
|
184
198
|
if (relationType === 'hasOne') {
|
|
185
|
-
|
|
186
|
-
const relationOp = this.collectCreateOneRelation(value)
|
|
187
|
-
?? this.collectHasOneOperation(entityType, entityId, fieldName)
|
|
199
|
+
const relationOp = this.collectHasOneOperation(entityType, entityId, fieldName)
|
|
188
200
|
if (relationOp !== null) {
|
|
189
201
|
createData[fieldName] = relationOp
|
|
190
202
|
}
|
|
191
203
|
} else if (relationType === 'hasMany') {
|
|
192
|
-
|
|
193
|
-
const relationOps = this.collectCreateManyRelation(value)
|
|
194
|
-
?? this.collectHasManyOperations(entityType, entityId, fieldName)
|
|
204
|
+
const relationOps = this.collectHasManyOperations(entityType, entityId, fieldName)
|
|
195
205
|
if (relationOps !== null && relationOps.length > 0) {
|
|
196
206
|
createData[fieldName] = relationOps
|
|
197
207
|
}
|
|
@@ -332,6 +342,7 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
332
342
|
this._nestedEntityIds.add(currentId)
|
|
333
343
|
const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
|
|
334
344
|
if (targetType) {
|
|
345
|
+
this._nestedEntityTypes.set(currentId, targetType)
|
|
335
346
|
const createData = this.collectCreateData(targetType, currentId)
|
|
336
347
|
return { create: createData ?? {} }
|
|
337
348
|
}
|
|
@@ -437,20 +448,13 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
437
448
|
}
|
|
438
449
|
}
|
|
439
450
|
|
|
440
|
-
// Created entities -> create (using
|
|
451
|
+
// Created entities -> create (using collectCreateData for full recursive collection)
|
|
441
452
|
if (targetType) {
|
|
442
453
|
for (const tempId of hasManyState.createdEntities) {
|
|
443
454
|
this._nestedEntityIds.add(tempId)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
delete createData['id']
|
|
448
|
-
if (Object.keys(createData).length > 0) {
|
|
449
|
-
operations.push({ create: this.processNestedData(createData), alias: tempId })
|
|
450
|
-
} else {
|
|
451
|
-
operations.push({ create: {}, alias: tempId })
|
|
452
|
-
}
|
|
453
|
-
}
|
|
455
|
+
this._nestedEntityTypes.set(tempId, targetType)
|
|
456
|
+
const createData = this.collectCreateData(targetType, tempId)
|
|
457
|
+
operations.push({ create: createData ?? {}, alias: tempId })
|
|
454
458
|
}
|
|
455
459
|
}
|
|
456
460
|
|
|
@@ -587,4 +591,148 @@ export class MutationCollector implements MutationDataCollector {
|
|
|
587
591
|
private isExistingEntity(id: string): boolean {
|
|
588
592
|
return isPersistedId(id)
|
|
589
593
|
}
|
|
594
|
+
|
|
595
|
+
// ==================== Embedded Data Materialization ====================
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Materializes embedded relation data in an entity's snapshot into proper
|
|
599
|
+
* store entities and relations. This normalizes inline objects (e.g.
|
|
600
|
+
* `reviews: [{ reviewType: 'expert' }]`) into tracked entities with temp IDs,
|
|
601
|
+
* enabling proper post-persist ID mapping and commit.
|
|
602
|
+
*/
|
|
603
|
+
private materializeEntityRelations(entityType: string, entityId: string): void {
|
|
604
|
+
if (!this.schemaProvider.hasEntity(entityType)) return
|
|
605
|
+
|
|
606
|
+
const snapshot = this.store.getEntitySnapshot(entityType, entityId)
|
|
607
|
+
if (!snapshot) return
|
|
608
|
+
|
|
609
|
+
const data = snapshot.data as Record<string, unknown>
|
|
610
|
+
const relationFields = this.schemaProvider.getRelationFields(entityType)
|
|
611
|
+
|
|
612
|
+
for (const fieldName of relationFields) {
|
|
613
|
+
const value = data[fieldName]
|
|
614
|
+
if (value === null || value === undefined) continue
|
|
615
|
+
|
|
616
|
+
const relationType = this.schemaProvider.getRelationType(entityType, fieldName)
|
|
617
|
+
|
|
618
|
+
if (relationType === 'hasOne' && typeof value === 'object' && !Array.isArray(value)) {
|
|
619
|
+
this.materializeEmbeddedHasOne(entityType, entityId, fieldName, value as Record<string, unknown>)
|
|
620
|
+
} else if (relationType === 'hasMany' && Array.isArray(value) && value.length > 0) {
|
|
621
|
+
this.materializeEmbeddedHasMany(entityType, entityId, fieldName, value)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Materializes an embedded hasMany array into store entities.
|
|
628
|
+
* Skips if the hasMany state already has store-managed entities.
|
|
629
|
+
*/
|
|
630
|
+
private materializeEmbeddedHasMany(
|
|
631
|
+
entityType: string,
|
|
632
|
+
entityId: string,
|
|
633
|
+
fieldName: string,
|
|
634
|
+
items: unknown[],
|
|
635
|
+
): void {
|
|
636
|
+
const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
|
|
637
|
+
if (!targetType) return
|
|
638
|
+
|
|
639
|
+
// Skip if store already has managed entities for this relation
|
|
640
|
+
const existing = this.store.getHasMany(entityType, entityId, fieldName)
|
|
641
|
+
if (existing && (existing.createdEntities.size > 0 || existing.plannedConnections.size > 0)) {
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
this.store.getOrCreateHasMany(entityType, entityId, fieldName, [])
|
|
646
|
+
|
|
647
|
+
for (const item of items) {
|
|
648
|
+
if (typeof item !== 'object' || item === null) continue
|
|
649
|
+
const obj = item as Record<string, unknown>
|
|
650
|
+
const id = obj['id'] as string | undefined
|
|
651
|
+
|
|
652
|
+
if (id && !isTempId(id) && isPersistedId(id)) {
|
|
653
|
+
// Existing entity — add as a planned connection (not created)
|
|
654
|
+
this.store.connectExistingToHasMany(entityType, entityId, fieldName, id)
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Create store entity from embedded data
|
|
659
|
+
const entityData = { ...obj }
|
|
660
|
+
delete entityData['id']
|
|
661
|
+
const tempId = this.store.createEntity(targetType, entityData)
|
|
662
|
+
this.store.addToHasMany(entityType, entityId, fieldName, tempId)
|
|
663
|
+
|
|
664
|
+
// Recursively materialize nested relations in the new entity
|
|
665
|
+
this.materializeEntityRelations(targetType, tempId)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Clear embedded array from parent snapshot
|
|
669
|
+
this.clearEmbeddedField(entityType, entityId, fieldName, [])
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Materializes an embedded hasOne object into a store entity and relation.
|
|
674
|
+
* Skips if a relation already exists in the store for this field.
|
|
675
|
+
*/
|
|
676
|
+
private materializeEmbeddedHasOne(
|
|
677
|
+
entityType: string,
|
|
678
|
+
entityId: string,
|
|
679
|
+
fieldName: string,
|
|
680
|
+
obj: Record<string, unknown>,
|
|
681
|
+
): void {
|
|
682
|
+
// Skip if relation already exists in store
|
|
683
|
+
const existingRelation = this.store.getRelation(entityType, entityId, fieldName)
|
|
684
|
+
if (existingRelation) return
|
|
685
|
+
|
|
686
|
+
const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
|
|
687
|
+
if (!targetType) return
|
|
688
|
+
|
|
689
|
+
const id = obj['id'] as string | undefined
|
|
690
|
+
|
|
691
|
+
if (id && !isTempId(id) && isPersistedId(id)) {
|
|
692
|
+
// Existing entity — create a connect relation
|
|
693
|
+
this.store.getOrCreateRelation(entityType, entityId, fieldName, {
|
|
694
|
+
currentId: id,
|
|
695
|
+
serverId: null,
|
|
696
|
+
state: 'connected',
|
|
697
|
+
serverState: 'disconnected',
|
|
698
|
+
placeholderData: {},
|
|
699
|
+
})
|
|
700
|
+
} else {
|
|
701
|
+
// New entity — create store entity and connect
|
|
702
|
+
const entityData = { ...obj }
|
|
703
|
+
delete entityData['id']
|
|
704
|
+
const tempId = this.store.createEntity(targetType, entityData)
|
|
705
|
+
|
|
706
|
+
this.store.getOrCreateRelation(entityType, entityId, fieldName, {
|
|
707
|
+
currentId: tempId,
|
|
708
|
+
serverId: null,
|
|
709
|
+
state: 'connected',
|
|
710
|
+
serverState: 'disconnected',
|
|
711
|
+
placeholderData: {},
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
// Recursively materialize nested relations
|
|
715
|
+
this.materializeEntityRelations(targetType, tempId)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Clear embedded data from parent snapshot
|
|
719
|
+
this.clearEmbeddedField(entityType, entityId, fieldName, null)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Clears an embedded field value from an entity's snapshot data.
|
|
724
|
+
*/
|
|
725
|
+
private clearEmbeddedField(
|
|
726
|
+
entityType: string,
|
|
727
|
+
entityId: string,
|
|
728
|
+
fieldName: string,
|
|
729
|
+
emptyValue: unknown,
|
|
730
|
+
): void {
|
|
731
|
+
const snapshot = this.store.getEntitySnapshot(entityType, entityId)
|
|
732
|
+
if (!snapshot) return
|
|
733
|
+
|
|
734
|
+
const data = { ...(snapshot.data as Record<string, unknown>) }
|
|
735
|
+
data[fieldName] = emptyValue
|
|
736
|
+
this.store.setEntityData(entityType, entityId, data, false)
|
|
737
|
+
}
|
|
590
738
|
}
|
package/src/persistence/types.ts
CHANGED
|
@@ -150,6 +150,12 @@ export interface TransactionMutationResult {
|
|
|
150
150
|
readonly persistedId?: string
|
|
151
151
|
readonly errorMessage?: string
|
|
152
152
|
readonly mutationResult?: ContemberMutationResult
|
|
153
|
+
/**
|
|
154
|
+
* Results for entities created inline within this mutation's data.
|
|
155
|
+
* Each entry maps a nested entity's temp ID (used as alias in the mutation)
|
|
156
|
+
* to its server-assigned ID. Recursive for deeply nested creates.
|
|
157
|
+
*/
|
|
158
|
+
readonly nestedResults?: readonly TransactionMutationResult[]
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
/**
|
|
@@ -115,6 +115,35 @@ export class EntityMetaStore {
|
|
|
115
115
|
return !this.tempToPersistedId.has(key)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Moves all metadata from oldKey to newKey.
|
|
120
|
+
*/
|
|
121
|
+
rekey(oldKey: string, newKey: string): void {
|
|
122
|
+
const meta = this.entityMetas.get(oldKey)
|
|
123
|
+
if (meta) {
|
|
124
|
+
this.entityMetas.delete(oldKey)
|
|
125
|
+
this.entityMetas.set(newKey, meta)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const loadState = this.loadStates.get(oldKey)
|
|
129
|
+
if (loadState) {
|
|
130
|
+
this.loadStates.delete(oldKey)
|
|
131
|
+
this.loadStates.set(newKey, loadState)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.persistingEntities.has(oldKey)) {
|
|
135
|
+
this.persistingEntities.delete(oldKey)
|
|
136
|
+
this.persistingEntities.add(newKey)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Move temp ID mapping
|
|
140
|
+
const persistedId = this.tempToPersistedId.get(oldKey)
|
|
141
|
+
if (persistedId !== undefined) {
|
|
142
|
+
this.tempToPersistedId.delete(oldKey)
|
|
143
|
+
this.tempToPersistedId.set(newKey, persistedId)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
// ==================== Bulk Operations ====================
|
|
119
148
|
|
|
120
149
|
exportMetas(keys: string[]): Map<string, EntityMeta> {
|
|
@@ -218,6 +218,29 @@ export class EntitySnapshotStore {
|
|
|
218
218
|
return keys
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Moves a snapshot from oldKey to newKey, updating the id in the snapshot data.
|
|
223
|
+
*/
|
|
224
|
+
rekey(oldKey: string, newKey: string, newId: string): void {
|
|
225
|
+
const snapshot = this.snapshots.get(oldKey)
|
|
226
|
+
if (!snapshot) return
|
|
227
|
+
|
|
228
|
+
// Update id field in data and serverData
|
|
229
|
+
const data = { ...snapshot.data as Record<string, unknown>, id: newId }
|
|
230
|
+
const serverData = { ...snapshot.serverData as Record<string, unknown>, id: newId }
|
|
231
|
+
|
|
232
|
+
const newSnapshot = createEntitySnapshot(
|
|
233
|
+
newId,
|
|
234
|
+
snapshot.entityType,
|
|
235
|
+
data,
|
|
236
|
+
serverData,
|
|
237
|
+
snapshot.version + 1,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
this.snapshots.delete(oldKey)
|
|
241
|
+
this.snapshots.set(newKey, newSnapshot)
|
|
242
|
+
}
|
|
243
|
+
|
|
221
244
|
keys(): IterableIterator<string> {
|
|
222
245
|
return this.snapshots.keys()
|
|
223
246
|
}
|
package/src/store/ErrorStore.ts
CHANGED
|
@@ -248,4 +248,34 @@ export class ErrorStore {
|
|
|
248
248
|
this.entityErrors.clear()
|
|
249
249
|
this.relationErrors.clear()
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Rekeys all errors from oldKeyPrefix to newKeyPrefix.
|
|
254
|
+
*/
|
|
255
|
+
rekey(oldEntityKey: string, newEntityKey: string, oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
256
|
+
this.rekeyMap(this.entityErrors, oldEntityKey, newEntityKey)
|
|
257
|
+
this.rekeyByPrefix(this.fieldErrors, oldKeyPrefix, newKeyPrefix)
|
|
258
|
+
this.rekeyByPrefix(this.relationErrors, oldKeyPrefix, newKeyPrefix)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private rekeyMap(map: Map<string, ErrorState>, oldKey: string, newKey: string): void {
|
|
262
|
+
const value = map.get(oldKey)
|
|
263
|
+
if (value) {
|
|
264
|
+
map.delete(oldKey)
|
|
265
|
+
map.set(newKey, value)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private rekeyByPrefix(map: Map<string, ErrorState>, oldPrefix: string, newPrefix: string): void {
|
|
270
|
+
const toMove: [string, ErrorState][] = []
|
|
271
|
+
for (const [key, value] of map) {
|
|
272
|
+
if (key.startsWith(oldPrefix)) {
|
|
273
|
+
toMove.push([key, value])
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const [oldKey, value] of toMove) {
|
|
277
|
+
map.delete(oldKey)
|
|
278
|
+
map.set(newPrefix + oldKey.slice(oldPrefix.length), value)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
251
281
|
}
|
|
@@ -365,6 +365,37 @@ export class RelationStore {
|
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Connects an existing (persisted) entity to a has-many relation.
|
|
370
|
+
* Unlike addToHasMany, does NOT add to createdEntities — used for
|
|
371
|
+
* materializing embedded connect references to existing entities.
|
|
372
|
+
*/
|
|
373
|
+
connectExistingToHasMany(key: string, itemId: string): void {
|
|
374
|
+
const existing = this.hasManyStates.get(key)
|
|
375
|
+
|
|
376
|
+
if (!existing) {
|
|
377
|
+
this.hasManyStates.set(key, {
|
|
378
|
+
serverIds: new Set(),
|
|
379
|
+
orderedIds: [itemId],
|
|
380
|
+
plannedRemovals: new Map(),
|
|
381
|
+
plannedConnections: new Set([itemId]),
|
|
382
|
+
createdEntities: new Set(),
|
|
383
|
+
version: 0,
|
|
384
|
+
})
|
|
385
|
+
} else {
|
|
386
|
+
const newPlannedConnections = new Set(existing.plannedConnections)
|
|
387
|
+
newPlannedConnections.add(itemId)
|
|
388
|
+
const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
|
|
389
|
+
const newOrderedIds = [...currentOrderedIds, itemId]
|
|
390
|
+
this.hasManyStates.set(key, {
|
|
391
|
+
...existing,
|
|
392
|
+
orderedIds: newOrderedIds,
|
|
393
|
+
plannedConnections: newPlannedConnections,
|
|
394
|
+
version: existing.version + 1,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
368
399
|
/**
|
|
369
400
|
* Removes an entity from a has-many relation.
|
|
370
401
|
* For newly created entities (via add()), cancels the connection.
|
|
@@ -654,6 +685,112 @@ export class RelationStore {
|
|
|
654
685
|
return keys
|
|
655
686
|
}
|
|
656
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Replaces all occurrences of oldId with newId across relation and hasMany states.
|
|
690
|
+
* Used after persist to rekey temp IDs to server-assigned IDs.
|
|
691
|
+
*/
|
|
692
|
+
replaceEntityId(oldId: string, newId: string): void {
|
|
693
|
+
// Replace in has-one relation states: currentId, serverId
|
|
694
|
+
for (const [key, state] of this.relationStates) {
|
|
695
|
+
let changed = false
|
|
696
|
+
let currentId = state.currentId
|
|
697
|
+
let serverId = state.serverId
|
|
698
|
+
|
|
699
|
+
if (currentId === oldId) { currentId = newId; changed = true }
|
|
700
|
+
if (serverId === oldId) { serverId = newId; changed = true }
|
|
701
|
+
|
|
702
|
+
if (changed) {
|
|
703
|
+
this.relationStates.set(key, { ...state, currentId, serverId, version: state.version + 1 })
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Replace in has-many states: serverIds, orderedIds, plannedConnections, createdEntities, plannedRemovals
|
|
708
|
+
for (const [key, state] of this.hasManyStates) {
|
|
709
|
+
let changed = false
|
|
710
|
+
|
|
711
|
+
let serverIds = state.serverIds
|
|
712
|
+
if (serverIds.has(oldId)) {
|
|
713
|
+
serverIds = new Set(serverIds)
|
|
714
|
+
serverIds.delete(oldId)
|
|
715
|
+
serverIds.add(newId)
|
|
716
|
+
changed = true
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let orderedIds = state.orderedIds
|
|
720
|
+
if (orderedIds) {
|
|
721
|
+
const idx = orderedIds.indexOf(oldId)
|
|
722
|
+
if (idx !== -1) {
|
|
723
|
+
orderedIds = [...orderedIds]
|
|
724
|
+
orderedIds[idx] = newId
|
|
725
|
+
changed = true
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let plannedConnections = state.plannedConnections
|
|
730
|
+
if (plannedConnections.has(oldId)) {
|
|
731
|
+
plannedConnections = new Set(plannedConnections)
|
|
732
|
+
plannedConnections.delete(oldId)
|
|
733
|
+
plannedConnections.add(newId)
|
|
734
|
+
changed = true
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let createdEntities = state.createdEntities
|
|
738
|
+
if (createdEntities.has(oldId)) {
|
|
739
|
+
createdEntities = new Set(createdEntities)
|
|
740
|
+
createdEntities.delete(oldId)
|
|
741
|
+
createdEntities.add(newId)
|
|
742
|
+
changed = true
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let plannedRemovals = state.plannedRemovals
|
|
746
|
+
if (plannedRemovals.has(oldId)) {
|
|
747
|
+
const removalType = plannedRemovals.get(oldId)!
|
|
748
|
+
plannedRemovals = new Map(plannedRemovals)
|
|
749
|
+
plannedRemovals.delete(oldId)
|
|
750
|
+
plannedRemovals.set(newId, removalType)
|
|
751
|
+
changed = true
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (changed) {
|
|
755
|
+
this.hasManyStates.set(key, {
|
|
756
|
+
serverIds,
|
|
757
|
+
orderedIds,
|
|
758
|
+
plannedRemovals,
|
|
759
|
+
plannedConnections,
|
|
760
|
+
createdEntities,
|
|
761
|
+
version: state.version + 1,
|
|
762
|
+
})
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Rekeys relation/hasMany entries owned by an entity (changes the parent ID in the key).
|
|
769
|
+
*/
|
|
770
|
+
rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
771
|
+
const toMoveRelations: [string, StoredRelationState][] = []
|
|
772
|
+
for (const [key, value] of this.relationStates) {
|
|
773
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
774
|
+
toMoveRelations.push([key, value])
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (const [oldKey, value] of toMoveRelations) {
|
|
778
|
+
this.relationStates.delete(oldKey)
|
|
779
|
+
this.relationStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const toMoveHasMany: [string, StoredHasManyState][] = []
|
|
783
|
+
for (const [key, value] of this.hasManyStates) {
|
|
784
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
785
|
+
toMoveHasMany.push([key, value])
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
for (const [oldKey, value] of toMoveHasMany) {
|
|
789
|
+
this.hasManyStates.delete(oldKey)
|
|
790
|
+
this.hasManyStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
657
794
|
/**
|
|
658
795
|
* Clears all relation data.
|
|
659
796
|
*/
|
|
@@ -52,12 +52,30 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
52
52
|
|
|
53
53
|
// ==================== Key Generation ====================
|
|
54
54
|
|
|
55
|
+
/** Maps temp ID entity keys to their persisted ID entity keys for transparent resolution */
|
|
56
|
+
private readonly rekeyedEntities = new Map<string, string>()
|
|
57
|
+
|
|
55
58
|
private getEntityKey(entityType: string, id: string): string {
|
|
56
|
-
|
|
59
|
+
const key = `${entityType}:${id}`
|
|
60
|
+
return this.rekeyedEntities.get(key) ?? key
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
private getRelationKey(parentType: string, parentId: string, fieldName: string): string {
|
|
60
|
-
|
|
64
|
+
const resolvedParentId = this.resolveId(parentType, parentId)
|
|
65
|
+
return `${parentType}:${resolvedParentId}:${fieldName}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves an ID to its persisted ID if it has been rekeyed.
|
|
70
|
+
*/
|
|
71
|
+
private resolveId(entityType: string, id: string): string {
|
|
72
|
+
const key = `${entityType}:${id}`
|
|
73
|
+
const rekeyed = this.rekeyedEntities.get(key)
|
|
74
|
+
if (rekeyed) {
|
|
75
|
+
// Extract the ID from the rekeyed key (format: "EntityType:id")
|
|
76
|
+
return rekeyed.slice(entityType.length + 1)
|
|
77
|
+
}
|
|
78
|
+
return id
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
// ==================== SnapshotVersionBumper ====================
|
|
@@ -218,9 +236,37 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
239
|
+
// Use raw keys (bypass resolveId) since we're the ones creating the mapping
|
|
240
|
+
const oldKey = `${entityType}:${tempId}`
|
|
241
|
+
const newKey = `${entityType}:${persistedId}`
|
|
242
|
+
const oldKeyPrefix = `${entityType}:${tempId}:`
|
|
243
|
+
const newKeyPrefix = `${entityType}:${persistedId}:`
|
|
244
|
+
|
|
245
|
+
// Register redirect so future lookups by temp ID resolve to persisted key
|
|
246
|
+
this.rekeyedEntities.set(oldKey, newKey)
|
|
247
|
+
|
|
248
|
+
// Rekey entity snapshot (moves data, updates id field)
|
|
249
|
+
this.entitySnapshots.rekey(oldKey, newKey, persistedId)
|
|
250
|
+
|
|
251
|
+
// Rekey metadata FIRST, then set persisted mapping (which sets existsOnServer)
|
|
252
|
+
this.meta.rekey(oldKey, newKey)
|
|
253
|
+
this.meta.mapTempIdToPersistedId(newKey, persistedId)
|
|
254
|
+
|
|
255
|
+
// Rekey subscriptions, parent-child relationships, and relation subscribers
|
|
256
|
+
this.subscriptions.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
257
|
+
|
|
258
|
+
// Rekey relations/hasMany owned by this entity (parent key changes)
|
|
259
|
+
this.relations.rekeyOwner(oldKeyPrefix, newKeyPrefix)
|
|
260
|
+
|
|
261
|
+
// Replace tempId with persistedId in all relation/hasMany VALUE references
|
|
262
|
+
this.relations.replaceEntityId(tempId, persistedId)
|
|
263
|
+
|
|
264
|
+
// Rekey errors and touched state
|
|
265
|
+
this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
266
|
+
this.touched.rekey(oldKeyPrefix, newKeyPrefix)
|
|
267
|
+
|
|
268
|
+
// Notify on the NEW key so React picks up the change
|
|
269
|
+
this.notifyEntitySubscribers(newKey)
|
|
224
270
|
}
|
|
225
271
|
|
|
226
272
|
getPersistedId(entityType: string, id: string): string | null {
|
|
@@ -372,6 +418,17 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
372
418
|
this.notifyRelationSubscribers(key)
|
|
373
419
|
}
|
|
374
420
|
|
|
421
|
+
connectExistingToHasMany(
|
|
422
|
+
parentType: string,
|
|
423
|
+
parentId: string,
|
|
424
|
+
fieldName: string,
|
|
425
|
+
itemId: string,
|
|
426
|
+
): void {
|
|
427
|
+
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
428
|
+
this.relations.connectExistingToHasMany(key, itemId)
|
|
429
|
+
this.notifyRelationSubscribers(key)
|
|
430
|
+
}
|
|
431
|
+
|
|
375
432
|
removeFromHasMany(
|
|
376
433
|
parentType: string,
|
|
377
434
|
parentId: string,
|