@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.
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 +6 -0
  9. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  10. package/dist/handles/types.d.ts +1 -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 +15 -1
  39. package/src/handles/types.ts +1 -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
@@ -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;IAYhE;;;OAGG;IACH,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,MAAM,IAAI;IAYlE;;;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;CAMpB"}
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.30",
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.30",
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 selectionSet = buildMutationSelection('update')
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
- return { ok: true }
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 = [new GraphQlField(null, 'id')]
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(
@@ -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
 
@@ -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
 
@@ -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
  */