@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
|
@@ -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
|
-
//
|
|
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
|
}
|