@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.
Files changed (53) 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/BaseHandle.d.ts +5 -0
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +1 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/HasOneHandle.d.ts +6 -0
  12. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  13. package/dist/handles/types.d.ts +1 -0
  14. package/dist/handles/types.d.ts.map +1 -1
  15. package/dist/persistence/BatchPersister.d.ts +30 -0
  16. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  17. package/dist/persistence/MutationCollector.d.ts +28 -0
  18. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  19. package/dist/persistence/types.d.ts +6 -0
  20. package/dist/persistence/types.d.ts.map +1 -1
  21. package/dist/store/EntityMetaStore.d.ts +4 -0
  22. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  23. package/dist/store/EntitySnapshotStore.d.ts +4 -0
  24. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  25. package/dist/store/ErrorStore.d.ts +6 -0
  26. package/dist/store/ErrorStore.d.ts.map +1 -1
  27. package/dist/store/RelationStore.d.ts +15 -0
  28. package/dist/store/RelationStore.d.ts.map +1 -1
  29. package/dist/store/SnapshotStore.d.ts +31 -0
  30. package/dist/store/SnapshotStore.d.ts.map +1 -1
  31. package/dist/store/SubscriptionManager.d.ts +15 -0
  32. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  33. package/dist/store/TouchedStore.d.ts +4 -0
  34. package/dist/store/TouchedStore.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/adapter/ContemberAdapter.ts +11 -5
  37. package/src/adapter/types.ts +4 -0
  38. package/src/core/ActionDispatcher.ts +3 -0
  39. package/src/core/actions.ts +6 -2
  40. package/src/handles/BaseHandle.ts +16 -0
  41. package/src/handles/HasManyListHandle.ts +35 -25
  42. package/src/handles/HasOneHandle.ts +35 -8
  43. package/src/handles/types.ts +1 -0
  44. package/src/persistence/BatchPersister.ts +237 -0
  45. package/src/persistence/MutationCollector.ts +167 -19
  46. package/src/persistence/types.ts +6 -0
  47. package/src/store/EntityMetaStore.ts +29 -0
  48. package/src/store/EntitySnapshotStore.ts +23 -0
  49. package/src/store/ErrorStore.ts +30 -0
  50. package/src/store/RelationStore.ts +157 -1
  51. package/src/store/SnapshotStore.ts +116 -5
  52. package/src/store/SubscriptionManager.ts +82 -2
  53. package/src/store/TouchedStore.ts +16 -0
@@ -12,6 +12,7 @@ import type {
12
12
  PersistenceResult,
13
13
  PersistScope,
14
14
  TransactionMutation,
15
+ TransactionMutationResult,
15
16
  TransactionResult,
16
17
  UpdateMode,
17
18
  } from './types.js'
@@ -603,11 +604,15 @@ export class BatchPersister {
603
604
  if (this.adapter.create && mutation.data) {
604
605
  const result = await this.adapter.create(mutation.entityType, mutation.data)
605
606
  const persistedId = result.data?.['id'] as string | undefined
607
+ const nestedResults = result.ok && result.data && mutation.data
608
+ ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId)
609
+ : undefined
606
610
  results.push({
607
611
  entityType: mutation.entityType,
608
612
  entityId: mutation.entityId,
609
613
  ok: result.ok,
610
614
  persistedId,
615
+ nestedResults,
611
616
  errorMessage: result.errorMessage,
612
617
  mutationResult: result.mutationResult,
613
618
  })
@@ -621,10 +626,14 @@ export class BatchPersister {
621
626
  mutation.entityId,
622
627
  mutation.data,
623
628
  )
629
+ const nestedResults = result.ok && result.data && mutation.data
630
+ ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId)
631
+ : undefined
624
632
  results.push({
625
633
  entityType: mutation.entityType,
626
634
  entityId: mutation.entityId,
627
635
  ok: result.ok,
636
+ nestedResults,
628
637
  errorMessage: result.errorMessage,
629
638
  mutationResult: result.mutationResult,
630
639
  })
@@ -700,6 +709,11 @@ export class BatchPersister {
700
709
  )
701
710
  }
702
711
 
712
+ // Process nested results (inline-created entities within this mutation)
713
+ if (mutationResult.nestedResults) {
714
+ this.commitNestedResults(mutationResult.nestedResults)
715
+ }
716
+
703
717
  results.push({
704
718
  entityType: entity.entityType,
705
719
  entityId: entity.entityId,
@@ -710,6 +724,11 @@ export class BatchPersister {
710
724
  successCount++
711
725
  }
712
726
  }
727
+
728
+ // Commit nested entities that don't have explicit results from the adapter.
729
+ // These entities were nested inside a parent mutation's data and exist on the server,
730
+ // but the adapter may not provide individual results for them.
731
+ this.commitUnresolvedNestedEntities(entities)
713
732
  } else {
714
733
  // Transaction failed - map errors and optionally rollback
715
734
  // For pessimistic mode, entities are already at server state, no rollback needed
@@ -919,6 +938,224 @@ export class BatchPersister {
919
938
  }
920
939
  }
921
940
 
941
+ /**
942
+ * Recursively commits nested entity results and maps their server IDs.
943
+ * Called when the adapter provides nestedResults in the transaction response.
944
+ * Uses the MutationCollector's nestedEntityTypes map to resolve entity types,
945
+ * since the adapter may not know the correct entity type for nested entities.
946
+ */
947
+ private commitNestedResults(nestedResults: readonly TransactionMutationResult[]): void {
948
+ const nestedTypes = this.mutationCollector instanceof MutationCollector
949
+ ? this.mutationCollector.getNestedEntityTypes()
950
+ : null
951
+
952
+ for (const nested of nestedResults) {
953
+ if (!nested.ok) continue
954
+
955
+ // Resolve entity type from collector's tracking (adapter may report 'Unknown')
956
+ const entityType = nestedTypes?.get(nested.entityId) ?? nested.entityType
957
+ const snapshot = this.store.getEntitySnapshot(entityType, nested.entityId)
958
+ if (!snapshot) continue
959
+
960
+ // Commit the nested entity
961
+ this.dispatcher.dispatch(commitEntity(entityType, nested.entityId))
962
+ this.store.commitAllRelations(entityType, nested.entityId)
963
+ this.store.setExistsOnServer(entityType, nested.entityId, true)
964
+
965
+ // Map temp ID to server-assigned ID
966
+ if (nested.persistedId) {
967
+ this.store.mapTempIdToPersistedId(
968
+ entityType,
969
+ nested.entityId,
970
+ nested.persistedId,
971
+ )
972
+ }
973
+
974
+ // Recurse for deeper nesting
975
+ if (nested.nestedResults) {
976
+ this.commitNestedResults(nested.nestedResults)
977
+ }
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Commits nested entities that were part of the transaction but don't have
983
+ * explicit results from the adapter. These entities were created inline
984
+ * inside a parent mutation and exist on the server after a successful persist.
985
+ */
986
+ private commitUnresolvedNestedEntities(entities: DirtyEntity[]): void {
987
+ if (!(this.mutationCollector instanceof MutationCollector)) return
988
+
989
+ const nestedTypes = this.mutationCollector.getNestedEntityTypes()
990
+ if (nestedTypes.size === 0) return
991
+
992
+ // Find nested entities that are in the entities list but weren't committed
993
+ // by the main result processing (they had no matching result from the adapter)
994
+ for (const entity of entities) {
995
+ if (entity.changeType !== 'create') continue
996
+ if (!nestedTypes.has(entity.entityId)) continue
997
+
998
+ // Check if this entity was already committed (has server data)
999
+ if (this.store.existsOnServer(entity.entityType, entity.entityId)) continue
1000
+
1001
+ // Commit it — the parent mutation succeeded, so this entity exists on the server
1002
+ this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId))
1003
+ this.store.commitAllRelations(entity.entityType, entity.entityId)
1004
+ this.store.setExistsOnServer(entity.entityType, entity.entityId, true)
1005
+ }
1006
+
1007
+ // Also commit materialized entities that may not be in the entities list
1008
+ // (they were created during mutation building by materializeEmbeddedHasMany/HasOne)
1009
+ for (const [tempId, entityType] of nestedTypes) {
1010
+ if (this.store.existsOnServer(entityType, tempId)) continue
1011
+
1012
+ const snapshot = this.store.getEntitySnapshot(entityType, tempId)
1013
+ if (!snapshot) continue
1014
+
1015
+ this.dispatcher.dispatch(commitEntity(entityType, tempId))
1016
+ this.store.commitAllRelations(entityType, tempId)
1017
+ this.store.setExistsOnServer(entityType, tempId, true)
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * Extracts nested entity results from a mutation's node response data.
1023
+ * Walks the mutation data structure to find inline create operations,
1024
+ * then matches them against the node response to extract server-assigned IDs.
1025
+ *
1026
+ * For hasOne creates: looks up the temp ID from the store's relation state
1027
+ * for the parent entity, then gets the server ID from the response node field.
1028
+ * For hasMany creates: filters new IDs from the response array by excluding
1029
+ * known pre-existing IDs, matching to temp IDs (aliases) by position.
1030
+ */
1031
+ private extractNestedResultsFromNode(
1032
+ mutationData: Record<string, unknown>,
1033
+ nodeData: Record<string, unknown>,
1034
+ parentEntityType: string,
1035
+ parentEntityId: string,
1036
+ ): TransactionMutationResult[] {
1037
+ const results: TransactionMutationResult[] = []
1038
+ const nestedTypes = this.mutationCollector instanceof MutationCollector
1039
+ ? this.mutationCollector.getNestedEntityTypes()
1040
+ : null
1041
+
1042
+ const makeResult = (
1043
+ tempId: string,
1044
+ createData: Record<string, unknown>,
1045
+ nodeItem: Record<string, unknown>,
1046
+ ): TransactionMutationResult => {
1047
+ const childResults = this.extractNestedResultsFromNode(
1048
+ createData, nodeItem,
1049
+ nestedTypes?.get(tempId) ?? 'Unknown', tempId,
1050
+ )
1051
+ return {
1052
+ entityType: nestedTypes?.get(tempId) ?? 'Unknown',
1053
+ entityId: tempId,
1054
+ ok: true,
1055
+ persistedId: nodeItem['id'] as string,
1056
+ nestedResults: childResults.length > 0 ? childResults : undefined,
1057
+ }
1058
+ }
1059
+
1060
+ for (const [fieldName, fieldValue] of Object.entries(mutationData)) {
1061
+ if (fieldValue === null || fieldValue === undefined) continue
1062
+
1063
+ if (Array.isArray(fieldValue)) {
1064
+ const nodeItems = nodeData[fieldName]
1065
+ if (!Array.isArray(nodeItems)) continue
1066
+
1067
+ // Separate create ops from known IDs (connect/update)
1068
+ const knownIds = new Set<string>()
1069
+ const createOps: Array<{ alias: string; createData: Record<string, unknown> }> = []
1070
+
1071
+ for (const op of fieldValue) {
1072
+ if (typeof op !== 'object' || op === null) continue
1073
+ const opObj = op as Record<string, unknown>
1074
+
1075
+ if ('connect' in opObj) {
1076
+ const connectBy = opObj['connect'] as Record<string, unknown>
1077
+ if (connectBy['id']) knownIds.add(connectBy['id'] as string)
1078
+ } else if ('update' in opObj) {
1079
+ const by = (opObj['update'] as Record<string, unknown>)['by'] as Record<string, unknown> | undefined
1080
+ if (by?.['id']) knownIds.add(by['id'] as string)
1081
+ } else if ('create' in opObj && opObj['alias']) {
1082
+ createOps.push({
1083
+ alias: opObj['alias'] as string,
1084
+ createData: opObj['create'] as Record<string, unknown>,
1085
+ })
1086
+ }
1087
+ }
1088
+
1089
+ // Include pre-existing server IDs
1090
+ const hasManyState = this.store.getHasMany(parentEntityType, parentEntityId, fieldName)
1091
+ if (hasManyState) {
1092
+ for (const id of hasManyState.serverIds) knownIds.add(id)
1093
+ }
1094
+
1095
+ // Filter response to new items only, then content-match to create ops.
1096
+ // Contember API does not guarantee hasMany ordering in node response,
1097
+ // so we match by scalar field values instead of position.
1098
+ // Unmatched creates keep their temp IDs until the next fetch.
1099
+ const unmatchedItems = (nodeItems as Record<string, unknown>[])
1100
+ .filter(it => typeof it === 'object' && it !== null && !knownIds.has(it['id'] as string))
1101
+
1102
+ for (const createOp of createOps) {
1103
+ const idx = unmatchedItems.findIndex(it => this.isCreateDataMatchingNode(createOp.createData, it))
1104
+ if (idx < 0) continue
1105
+ results.push(makeResult(createOp.alias, createOp.createData, unmatchedItems.splice(idx, 1)[0]!))
1106
+ }
1107
+ } else if (typeof fieldValue === 'object') {
1108
+ const opObj = fieldValue as Record<string, unknown>
1109
+ const nodeField = nodeData[fieldName]
1110
+
1111
+ if ('create' in opObj && typeof nodeField === 'object' && nodeField !== null) {
1112
+ const serverId = (nodeField as Record<string, unknown>)['id'] as string | undefined
1113
+ if (!serverId) continue
1114
+
1115
+ const relationState = this.store.getRelation(parentEntityType, parentEntityId, fieldName)
1116
+ if (!relationState?.currentId) continue
1117
+
1118
+ results.push(makeResult(
1119
+ relationState.currentId,
1120
+ opObj['create'] as Record<string, unknown>,
1121
+ nodeField as Record<string, unknown>,
1122
+ ))
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ return results
1128
+ }
1129
+
1130
+ /**
1131
+ * Content-based matching: checks whether create data matches a response node
1132
+ * by comparing scalars and hasOne relation IDs. Same approach as the legacy
1133
+ * binding's TreeAugmenter.isEntityMatching.
1134
+ */
1135
+ private isCreateDataMatchingNode(
1136
+ createData: Record<string, unknown>,
1137
+ nodeItem: Record<string, unknown>,
1138
+ ): boolean {
1139
+ for (const [key, value] of Object.entries(createData)) {
1140
+ if (value === null || value === undefined) continue
1141
+
1142
+ if (typeof value !== 'object') {
1143
+ if (nodeItem[key] !== value) return false
1144
+ } else if (!Array.isArray(value)) {
1145
+ const op = value as Record<string, unknown>
1146
+ const nodeField = nodeItem[key]
1147
+ if (!nodeField || typeof nodeField !== 'object') continue
1148
+
1149
+ if ('connect' in op) {
1150
+ if ((op['connect'] as Record<string, unknown>)['id'] !== (nodeField as Record<string, unknown>)['id']) return false
1151
+ } else if ('create' in op) {
1152
+ if (!this.isCreateDataMatchingNode(op['create'] as Record<string, unknown>, nodeField as Record<string, unknown>)) return false
1153
+ }
1154
+ }
1155
+ }
1156
+ return true
1157
+ }
1158
+
922
1159
  /**
923
1160
  * Cancels all in-flight operations.
924
1161
  */
@@ -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
  }