@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.
Files changed (49) hide show
  1. package/dist/adapter/ContemberAdapter.d.ts.map +1 -1
  2. package/dist/adapter/types.d.ts +4 -0
  3. package/dist/adapter/types.d.ts.map +1 -1
  4. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  5. package/dist/core/actions.d.ts +4 -2
  6. package/dist/core/actions.d.ts.map +1 -1
  7. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  8. package/dist/handles/HasOneHandle.d.ts +10 -0
  9. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  10. package/dist/handles/types.d.ts +2 -0
  11. package/dist/handles/types.d.ts.map +1 -1
  12. package/dist/persistence/BatchPersister.d.ts +30 -0
  13. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  14. package/dist/persistence/MutationCollector.d.ts +28 -0
  15. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  16. package/dist/persistence/types.d.ts +6 -0
  17. package/dist/persistence/types.d.ts.map +1 -1
  18. package/dist/store/EntityMetaStore.d.ts +4 -0
  19. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  20. package/dist/store/EntitySnapshotStore.d.ts +4 -0
  21. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  22. package/dist/store/ErrorStore.d.ts +6 -0
  23. package/dist/store/ErrorStore.d.ts.map +1 -1
  24. package/dist/store/RelationStore.d.ts +15 -0
  25. package/dist/store/RelationStore.d.ts.map +1 -1
  26. package/dist/store/SnapshotStore.d.ts +7 -0
  27. package/dist/store/SnapshotStore.d.ts.map +1 -1
  28. package/dist/store/SubscriptionManager.d.ts +15 -0
  29. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  30. package/dist/store/TouchedStore.d.ts +4 -0
  31. package/dist/store/TouchedStore.d.ts.map +1 -1
  32. package/package.json +2 -2
  33. package/src/adapter/ContemberAdapter.ts +11 -5
  34. package/src/adapter/types.ts +4 -0
  35. package/src/core/ActionDispatcher.ts +3 -0
  36. package/src/core/actions.ts +6 -2
  37. package/src/handles/HasManyListHandle.ts +1 -6
  38. package/src/handles/HasOneHandle.ts +22 -1
  39. package/src/handles/types.ts +2 -0
  40. package/src/persistence/BatchPersister.ts +237 -0
  41. package/src/persistence/MutationCollector.ts +167 -19
  42. package/src/persistence/types.ts +6 -0
  43. package/src/store/EntityMetaStore.ts +29 -0
  44. package/src/store/EntitySnapshotStore.ts +23 -0
  45. package/src/store/ErrorStore.ts +30 -0
  46. package/src/store/RelationStore.ts +137 -0
  47. package/src/store/SnapshotStore.ts +62 -5
  48. package/src/store/SubscriptionManager.ts +82 -2
  49. 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
- // Collect relation values
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
- // Check embedded data first, then fall back to RelationStore
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
- // Try embedded data first, then fall back to RelationStore
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 entity snapshot data)
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
- const itemSnapshot = this.store.getEntitySnapshot(targetType, tempId)
445
- if (itemSnapshot) {
446
- const createData = { ...itemSnapshot.data as Record<string, unknown> }
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
  }
@@ -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
  }
@@ -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
- return `${entityType}:${id}`
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
- return `${parentType}:${parentId}:${fieldName}`
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
- const key = this.getEntityKey(entityType, tempId)
222
- this.meta.mapTempIdToPersistedId(key, persistedId)
223
- this.notifyEntitySubscribers(key)
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,