@contember/bindx 0.1.30 → 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 +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 +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 +15 -1
- 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 +137 -0
- package/src/store/SnapshotStore.ts +62 -5
- package/src/store/SubscriptionManager.ts +82 -2
- package/src/store/TouchedStore.ts +16 -0
|
@@ -25,8 +25,17 @@ export declare class SubscriptionManager {
|
|
|
25
25
|
private readonly globalSubscribers;
|
|
26
26
|
/** Parent-child relationships: childKey -> Set of parentKeys */
|
|
27
27
|
private readonly childToParents;
|
|
28
|
+
/** Maps old keys → new keys after rekey, so unsubscribe closures can find migrated callbacks */
|
|
29
|
+
private readonly rekeyedKeys;
|
|
28
30
|
/** Global version number for change detection */
|
|
29
31
|
private globalVersion;
|
|
32
|
+
/**
|
|
33
|
+
* Resolves a key through the rekey redirect chain.
|
|
34
|
+
* The rekey() method collapses chains (A→C instead of A→B→C), so this should
|
|
35
|
+
* always resolve in one hop. The loop with depth limit is a safety net in case
|
|
36
|
+
* chain collapsing has a bug — prevents infinite loops instead of silently hanging.
|
|
37
|
+
*/
|
|
38
|
+
private resolveKey;
|
|
30
39
|
/**
|
|
31
40
|
* Subscribe to changes on a specific entity.
|
|
32
41
|
* Returns unsubscribe function.
|
|
@@ -85,6 +94,12 @@ export declare class SubscriptionManager {
|
|
|
85
94
|
* Bumps global version and notifies global subscribers.
|
|
86
95
|
*/
|
|
87
96
|
notifyGlobal(): void;
|
|
97
|
+
/**
|
|
98
|
+
* Moves subscriptions and parent-child relationships from oldKey to newKey.
|
|
99
|
+
* Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix.
|
|
100
|
+
* Registers redirects so unsubscribe closures can find migrated callbacks.
|
|
101
|
+
*/
|
|
102
|
+
rekey(oldKey: string, newKey: string, oldKeyPrefix: string, newKeyPrefix: string): void;
|
|
88
103
|
}
|
|
89
104
|
export {};
|
|
90
105
|
//# sourceMappingURL=SubscriptionManager.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubscriptionManager.d.ts","sourceRoot":"","sources":["../../src/store/SubscriptionManager.ts"],"names":[],"mappings":"AAAA,KAAK,UAAU,GAAG,MAAM,IAAI,CAAA;AAE5B;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACrC,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5C;AAED;;;;;;;;;GASG;AACH,qBAAa,mBAAmB;IAC/B,iCAAiC;IACjC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqC;IAEvE,mCAAmC;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqC;IAEzE,kDAAkD;IAClD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAwB;IAE1D,gEAAgE;IAChE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAiC;IAEhE,iDAAiD;IACjD,OAAO,CAAC,aAAa,CAAI;IAEzB;;;OAGG;IACH,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,MAAM,IAAI;
|
|
1
|
+
{"version":3,"file":"SubscriptionManager.d.ts","sourceRoot":"","sources":["../../src/store/SubscriptionManager.ts"],"names":[],"mappings":"AAAA,KAAK,UAAU,GAAG,MAAM,IAAI,CAAA;AAE5B;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACrC,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5C;AAED;;;;;;;;;GASG;AACH,qBAAa,mBAAmB;IAC/B,iCAAiC;IACjC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqC;IAEvE,mCAAmC;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqC;IAEzE,kDAAkD;IAClD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAwB;IAE1D,gEAAgE;IAChE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAiC;IAEhE,gGAAgG;IAChG,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;IAExD,iDAAiD;IACjD,OAAO,CAAC,aAAa,CAAI;IAEzB;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAUlB;;;OAGG;IACH,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,MAAM,IAAI;IAahE;;;OAGG;IACH,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,MAAM,IAAI;IAalE;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,UAAU,GAAG,MAAM,IAAI;IAO3C;;OAEG;IACH,UAAU,IAAI,MAAM;IAIpB;;;OAGG;IACH,MAAM,IAAI,IAAI;IASd;;;OAGG;IACH,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAS9D;;OAEG;IACH,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAYhE;;;OAGG;IACH,uBAAuB,CACtB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,qBAAqB,EAC7B,YAAY,GAAE,GAAG,CAAC,MAAM,CAAa,GACnC,IAAI;IAiCP;;;;OAIG;IACH,yBAAyB,CACxB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,qBAAqB,GAC3B,IAAI;IA4BP;;;OAGG;IACH,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IASrC;;;OAGG;IACH,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IASvC;;OAEG;IACH,YAAY,IAAI,IAAI;IAOpB;;;;OAIG;IACH,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;CAqDvF"}
|
|
@@ -19,6 +19,10 @@ export declare class TouchedStore {
|
|
|
19
19
|
* Clears all touched state for an entity.
|
|
20
20
|
*/
|
|
21
21
|
clearForEntity(keyPrefix: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Rekeys all touched fields from oldKeyPrefix to newKeyPrefix.
|
|
24
|
+
*/
|
|
25
|
+
rekey(oldKeyPrefix: string, newKeyPrefix: string): void;
|
|
22
26
|
clear(): void;
|
|
23
27
|
}
|
|
24
28
|
//# sourceMappingURL=TouchedStore.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TouchedStore.d.ts","sourceRoot":"","sources":["../../src/store/TouchedStore.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,qBAAa,YAAY;IACxB,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAE3D,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIpC;;;OAGG;IACH,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IAOvD;;OAEG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAQvC,KAAK,IAAI,IAAI;CAGb"}
|
|
1
|
+
{"version":3,"file":"TouchedStore.d.ts","sourceRoot":"","sources":["../../src/store/TouchedStore.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,qBAAa,YAAY;IACxB,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAE3D,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIpC;;;OAGG;IACH,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IAOvD;;OAEG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAQvC;;OAEG;IACH,KAAK,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAavD,KAAK,IAAI,IAAI;CAGb"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contember/bindx",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Type-safe data binding framework (core)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@contember/bindx-client": "0.1.
|
|
20
|
+
"@contember/bindx-client": "0.1.31",
|
|
21
21
|
"@contember/graphql-builder": "^2.1.0-beta.1",
|
|
22
22
|
"@contember/graphql-client": "^2.1.0-beta.1"
|
|
23
23
|
},
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildUpdateArgs,
|
|
12
12
|
buildDeleteArgs,
|
|
13
13
|
buildMutationSelection,
|
|
14
|
+
buildNodeSelectionFromMutationData,
|
|
14
15
|
mutationFragments,
|
|
15
16
|
unwrapPaginateResult,
|
|
16
17
|
} from '@contember/bindx-client'
|
|
@@ -119,19 +120,24 @@ export class ContemberAdapter implements BackendAdapter {
|
|
|
119
120
|
changes: Record<string, unknown>,
|
|
120
121
|
): Promise<PersistResult> {
|
|
121
122
|
const args = buildUpdateArgs(entityType, { by: { id }, data: changes })
|
|
122
|
-
const
|
|
123
|
+
const nodeSelection = buildNodeSelectionFromMutationData(changes)
|
|
124
|
+
const selectionSet = buildMutationSelection('update', nodeSelection)
|
|
123
125
|
|
|
124
126
|
const mutation = new ContentOperation(
|
|
125
127
|
'mutation',
|
|
126
128
|
`update${entityType}`,
|
|
127
129
|
args,
|
|
128
130
|
selectionSet,
|
|
129
|
-
value => value as { ok: boolean; errorMessage: string | null; errors: unknown[]; validation: unknown },
|
|
131
|
+
value => value as { ok: boolean; errorMessage: string | null; errors: unknown[]; validation: unknown; node: unknown },
|
|
130
132
|
)
|
|
131
133
|
|
|
132
134
|
try {
|
|
133
135
|
const result = await this.contentClient.mutate(mutation)
|
|
134
|
-
|
|
136
|
+
const typedResult = result as { node: Record<string, unknown> | null }
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
data: typedResult.node as Record<string, unknown> | undefined,
|
|
140
|
+
}
|
|
135
141
|
} catch (e) {
|
|
136
142
|
if (e instanceof Error && 'result' in e) {
|
|
137
143
|
const mutResult = (e as { result: Record<string, unknown> }).result
|
|
@@ -150,8 +156,8 @@ export class ContemberAdapter implements BackendAdapter {
|
|
|
150
156
|
data: Record<string, unknown>,
|
|
151
157
|
): Promise<CreateResult> {
|
|
152
158
|
const args = buildCreateArgs(entityType, { data })
|
|
153
|
-
// Select id on created node
|
|
154
|
-
const nodeSelection =
|
|
159
|
+
// Select id on created node + nested created entity IDs
|
|
160
|
+
const nodeSelection = buildNodeSelectionFromMutationData(data)
|
|
155
161
|
const selectionSet = buildMutationSelection('create', nodeSelection)
|
|
156
162
|
|
|
157
163
|
const mutation = new ContentOperation(
|
package/src/adapter/types.ts
CHANGED
|
@@ -80,6 +80,8 @@ export type QueryResult = GetQueryResult | ListQueryResult
|
|
|
80
80
|
export interface PersistResult {
|
|
81
81
|
readonly ok: boolean
|
|
82
82
|
readonly errorMessage?: string
|
|
83
|
+
/** The entity node data after mutation (when ok is true). Contains nested entity IDs for inline creates. */
|
|
84
|
+
readonly data?: Record<string, unknown>
|
|
83
85
|
/** Detailed mutation result for error mapping (Contember-specific) */
|
|
84
86
|
readonly mutationResult?: ContemberMutationResult
|
|
85
87
|
}
|
|
@@ -128,6 +130,8 @@ export interface TransactionMutationResult {
|
|
|
128
130
|
readonly persistedId?: string
|
|
129
131
|
readonly errorMessage?: string
|
|
130
132
|
readonly mutationResult?: ContemberMutationResult
|
|
133
|
+
/** Results for entities created inline within this mutation's data. */
|
|
134
|
+
readonly nestedResults?: readonly TransactionMutationResult[]
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/**
|
|
@@ -218,6 +218,7 @@ export class ActionDispatcher {
|
|
|
218
218
|
state: 'connected',
|
|
219
219
|
},
|
|
220
220
|
)
|
|
221
|
+
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.targetId)
|
|
221
222
|
break
|
|
222
223
|
|
|
223
224
|
case 'DISCONNECT_RELATION':
|
|
@@ -293,6 +294,7 @@ export class ActionDispatcher {
|
|
|
293
294
|
action.itemId,
|
|
294
295
|
action.alias,
|
|
295
296
|
)
|
|
297
|
+
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, action.itemId)
|
|
296
298
|
break
|
|
297
299
|
|
|
298
300
|
case 'ADD_TO_LIST': {
|
|
@@ -306,6 +308,7 @@ export class ActionDispatcher {
|
|
|
306
308
|
itemId,
|
|
307
309
|
action.alias,
|
|
308
310
|
)
|
|
311
|
+
this.store.registerParentChild(action.entityType, action.entityId, action.targetType, itemId)
|
|
309
312
|
break
|
|
310
313
|
}
|
|
311
314
|
|
package/src/core/actions.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface ConnectRelationAction {
|
|
|
61
61
|
readonly entityId: string
|
|
62
62
|
readonly fieldName: string
|
|
63
63
|
readonly targetId: string
|
|
64
|
+
readonly targetType: string
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
@@ -126,6 +127,7 @@ export interface ConnectToListAction {
|
|
|
126
127
|
readonly entityId: string
|
|
127
128
|
readonly fieldName: string
|
|
128
129
|
readonly itemId: string
|
|
130
|
+
readonly targetType: string
|
|
129
131
|
readonly alias?: string
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -355,8 +357,9 @@ export function connectRelation(
|
|
|
355
357
|
entityId: string,
|
|
356
358
|
fieldName: string,
|
|
357
359
|
targetId: string,
|
|
360
|
+
targetType: string,
|
|
358
361
|
): ConnectRelationAction {
|
|
359
|
-
return { type: 'CONNECT_RELATION', entityType, entityId, fieldName, targetId }
|
|
362
|
+
return { type: 'CONNECT_RELATION', entityType, entityId, fieldName, targetId, targetType }
|
|
360
363
|
}
|
|
361
364
|
|
|
362
365
|
/**
|
|
@@ -517,9 +520,10 @@ export function connectToList(
|
|
|
517
520
|
entityId: string,
|
|
518
521
|
fieldName: string,
|
|
519
522
|
itemId: string,
|
|
523
|
+
targetType: string,
|
|
520
524
|
alias?: string,
|
|
521
525
|
): ConnectToListAction {
|
|
522
|
-
return { type: 'CONNECT_TO_LIST', entityType, entityId, fieldName, itemId, alias }
|
|
526
|
+
return { type: 'CONNECT_TO_LIST', entityType, entityId, fieldName, itemId, targetType, alias }
|
|
523
527
|
}
|
|
524
528
|
|
|
525
529
|
/**
|
|
@@ -304,10 +304,8 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
304
304
|
connect(itemId: string): void {
|
|
305
305
|
this.assertNotDisposed()
|
|
306
306
|
this.dispatcher.dispatch(
|
|
307
|
-
connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.alias),
|
|
307
|
+
connectToList(this.entityType, this.entityId, this.fieldName, itemId, this.itemType, this.alias),
|
|
308
308
|
)
|
|
309
|
-
// Register parent-child so that changes to the connected entity propagate to the parent
|
|
310
|
-
this.store.registerParentChild(this.entityType, this.entityId, this.itemType, itemId)
|
|
311
309
|
}
|
|
312
310
|
|
|
313
311
|
/**
|
|
@@ -350,9 +348,6 @@ export class HasManyListHandle<TEntity extends object = object, TSelected = TEnt
|
|
|
350
348
|
addToList(this.entityType, this.entityId, this.fieldName, this.itemType, tempId, this.alias),
|
|
351
349
|
)
|
|
352
350
|
|
|
353
|
-
// Register parent-child so that changes to the new entity propagate to the parent
|
|
354
|
-
this.store.registerParentChild(this.entityType, this.entityId, this.itemType, tempId)
|
|
355
|
-
|
|
356
351
|
return tempId
|
|
357
352
|
}
|
|
358
353
|
|
|
@@ -353,13 +353,27 @@ export class HasOneHandle<TEntity extends object = object, TSelected = TEntity>
|
|
|
353
353
|
)
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Creates a new entity of the target type and connects it to this relation.
|
|
358
|
+
* Returns the temp ID of the created entity.
|
|
359
|
+
* Accessible via proxy as `$create()`.
|
|
360
|
+
*/
|
|
361
|
+
create(data?: Partial<TEntity>): string {
|
|
362
|
+
this.assertNotDisposed()
|
|
363
|
+
const tempId = this.store.createEntity(this.targetType, data as Record<string, unknown>)
|
|
364
|
+
this.dispatcher.dispatch(
|
|
365
|
+
connectRelation(this.entityType, this.entityId, this.fieldName, tempId, this.targetType),
|
|
366
|
+
)
|
|
367
|
+
return tempId
|
|
368
|
+
}
|
|
369
|
+
|
|
356
370
|
/**
|
|
357
371
|
* Connects the relation to an entity.
|
|
358
372
|
*/
|
|
359
373
|
connect(targetId: string): void {
|
|
360
374
|
this.assertNotDisposed()
|
|
361
375
|
this.dispatcher.dispatch(
|
|
362
|
-
connectRelation(this.entityType, this.entityId, this.fieldName, targetId),
|
|
376
|
+
connectRelation(this.entityType, this.entityId, this.fieldName, targetId, this.targetType),
|
|
363
377
|
)
|
|
364
378
|
}
|
|
365
379
|
|
package/src/handles/types.ts
CHANGED
|
@@ -228,6 +228,7 @@ export interface HasOneRefInterface<
|
|
|
228
228
|
readonly __entityType: TEntity
|
|
229
229
|
readonly __selected?: TSelected
|
|
230
230
|
readonly __brands?: Set<symbol>
|
|
231
|
+
$create(data?: Partial<TEntity>): string
|
|
231
232
|
$connect(id: string): void
|
|
232
233
|
$disconnect(): void
|
|
233
234
|
$delete(): void
|
|
@@ -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
|
*/
|