@contember/bindx 0.1.46 → 0.1.48

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 (121) hide show
  1. package/dist/adapter/MockAdapter.d.ts +4 -1
  2. package/dist/adapter/MockAdapter.d.ts.map +1 -1
  3. package/dist/core/ActionDispatcher.d.ts +4 -1
  4. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  5. package/dist/core/actions.d.ts +7 -1
  6. package/dist/core/actions.d.ts.map +1 -1
  7. package/dist/events/EventEmitter.d.ts +13 -1
  8. package/dist/events/EventEmitter.d.ts.map +1 -1
  9. package/dist/handles/BaseHandle.d.ts +14 -3
  10. package/dist/handles/BaseHandle.d.ts.map +1 -1
  11. package/dist/handles/EntityHandle.d.ts +14 -5
  12. package/dist/handles/EntityHandle.d.ts.map +1 -1
  13. package/dist/handles/FieldHandle.d.ts +11 -4
  14. package/dist/handles/FieldHandle.d.ts.map +1 -1
  15. package/dist/handles/HasManyListHandle.d.ts +36 -2
  16. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  17. package/dist/handles/HasOneHandle.d.ts +52 -9
  18. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  19. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  20. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  21. package/dist/handles/index.d.ts +1 -1
  22. package/dist/handles/index.d.ts.map +1 -1
  23. package/dist/handles/proxyFactory.d.ts +7 -1
  24. package/dist/handles/proxyFactory.d.ts.map +1 -1
  25. package/dist/handles/types.d.ts +33 -0
  26. package/dist/handles/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/persistence/BatchPersister.d.ts +53 -37
  30. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  31. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  32. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  33. package/dist/persistence/MutationCollector.d.ts +106 -3
  34. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  35. package/dist/persistence/PersistExecution.d.ts +26 -0
  36. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  37. package/dist/persistence/types.d.ts +8 -2
  38. package/dist/persistence/types.d.ts.map +1 -1
  39. package/dist/store/EntityMetaStore.d.ts +32 -10
  40. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  41. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  42. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  43. package/dist/store/ErrorStore.d.ts +3 -2
  44. package/dist/store/ErrorStore.d.ts.map +1 -1
  45. package/dist/store/HasManyStore.d.ts +214 -0
  46. package/dist/store/HasManyStore.d.ts.map +1 -0
  47. package/dist/store/HasOneStore.d.ts +159 -0
  48. package/dist/store/HasOneStore.d.ts.map +1 -0
  49. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  50. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  51. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  52. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  53. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  54. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  55. package/dist/store/RelationStore.d.ts +74 -137
  56. package/dist/store/RelationStore.d.ts.map +1 -1
  57. package/dist/store/RootRegistry.d.ts +25 -4
  58. package/dist/store/RootRegistry.d.ts.map +1 -1
  59. package/dist/store/SnapshotStore.d.ts +137 -13
  60. package/dist/store/SnapshotStore.d.ts.map +1 -1
  61. package/dist/store/SubscriptionManager.d.ts +63 -14
  62. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  63. package/dist/store/TouchedStore.d.ts +3 -2
  64. package/dist/store/TouchedStore.d.ts.map +1 -1
  65. package/dist/store/relationKey.d.ts +15 -0
  66. package/dist/store/relationKey.d.ts.map +1 -0
  67. package/dist/undo/UndoJournal.d.ts +133 -0
  68. package/dist/undo/UndoJournal.d.ts.map +1 -0
  69. package/dist/undo/UndoManager.d.ts +25 -82
  70. package/dist/undo/UndoManager.d.ts.map +1 -1
  71. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  72. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  73. package/dist/undo/index.d.ts +3 -1
  74. package/dist/undo/index.d.ts.map +1 -1
  75. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  76. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  77. package/dist/undo/types.d.ts +0 -48
  78. package/dist/undo/types.d.ts.map +1 -1
  79. package/package.json +2 -2
  80. package/src/adapter/MockAdapter.ts +15 -5
  81. package/src/core/ActionDispatcher.ts +56 -36
  82. package/src/core/actions.ts +8 -1
  83. package/src/events/EventEmitter.ts +78 -24
  84. package/src/handles/BaseHandle.ts +19 -2
  85. package/src/handles/EntityHandle.ts +48 -17
  86. package/src/handles/FieldHandle.ts +36 -10
  87. package/src/handles/HasManyListHandle.ts +120 -16
  88. package/src/handles/HasOneHandle.ts +177 -44
  89. package/src/handles/PlaceholderHandle.ts +184 -36
  90. package/src/handles/index.ts +3 -0
  91. package/src/handles/proxyFactory.ts +27 -0
  92. package/src/handles/types.ts +39 -0
  93. package/src/index.ts +4 -4
  94. package/src/persistence/BatchPersister.ts +810 -446
  95. package/src/persistence/ChangeRegistry.ts +27 -3
  96. package/src/persistence/MutationCollector.ts +265 -31
  97. package/src/persistence/PersistExecution.ts +217 -0
  98. package/src/persistence/types.ts +8 -2
  99. package/src/store/EntityMetaStore.ts +89 -45
  100. package/src/store/EntitySnapshotStore.ts +102 -19
  101. package/src/store/ErrorStore.ts +6 -5
  102. package/src/store/HasManyStore.ts +810 -0
  103. package/src/store/HasOneStore.ts +454 -0
  104. package/src/store/ReachabilityAnalyzer.ts +37 -0
  105. package/src/store/RekeyOrchestrator.ts +123 -0
  106. package/src/store/RelationEdgeIndex.ts +79 -0
  107. package/src/store/RelationStore.ts +176 -725
  108. package/src/store/RootRegistry.ts +50 -8
  109. package/src/store/SnapshotStore.ts +572 -101
  110. package/src/store/SubscriptionManager.ts +122 -75
  111. package/src/store/TouchedStore.ts +5 -2
  112. package/src/store/relationKey.ts +20 -0
  113. package/src/undo/UndoJournal.ts +258 -0
  114. package/src/undo/UndoManager.ts +151 -281
  115. package/src/undo/UnrecordedWriteError.ts +23 -0
  116. package/src/undo/index.ts +8 -7
  117. package/src/undo/rekeyJournalEntry.ts +145 -0
  118. package/src/undo/types.ts +0 -58
  119. package/dist/core/actionClassification.d.ts +0 -48
  120. package/dist/core/actionClassification.d.ts.map +0 -1
  121. package/src/core/actionClassification.ts +0 -199
@@ -13,30 +13,21 @@ import type {
13
13
  PersistScope,
14
14
  TransactionMutation,
15
15
  TransactionMutationResult,
16
- TransactionResult,
17
16
  UpdateMode,
18
17
  } from './types.js'
19
- import { setPersisting, commitEntity, resetEntity, addFieldError, addEntityError, addRelationError, clearAllServerErrors } from '../core/actions.js'
18
+ import { setPersisting, resetEntity, addFieldError, addEntityError, addRelationError, clearAllServerErrors } from '../core/actions.js'
20
19
  import { type ContemberMutationResult } from '../errors/pathMapper.js'
21
20
  import { resolveAllErrors } from '../errors/errorPathResolver.js'
22
21
  import { createServerError } from '../errors/types.js'
23
22
  import { MutationCollector } from './MutationCollector.js'
24
- import type { EntitySnapshot } from '../store/snapshots.js'
25
- import type { StoredHasManyState, StoredRelationState } from '../store/SnapshotStore.js'
23
+ import type { EntityPersistedEvent, EntityPersistFailedEvent, EntityPersistingEvent } from '../events/types.js'
26
24
  import { deepEqual } from '../utils/deepEqual.js'
27
-
28
- /**
29
- * Captured state for pessimistic mode restoration.
30
- * Contains all the data needed to restore an entity's dirty state after
31
- * temporarily resetting it to server state during pessimistic persistence.
32
- */
33
- interface CapturedEntityState {
34
- entityType: string
35
- entityId: string
36
- snapshot: EntitySnapshot | undefined
37
- relations: Map<string, StoredRelationState>
38
- hasManyStates: Map<string, StoredHasManyState>
39
- }
25
+ import {
26
+ createPersistExecution,
27
+ entityIdentityKey,
28
+ type ExecutionEntity,
29
+ type PersistExecution,
30
+ } from './PersistExecution.js'
40
31
 
41
32
  /**
42
33
  * Options for BatchPersister
@@ -65,6 +56,35 @@ export interface BatchPersisterOptions {
65
56
  defaultUpdateMode?: UpdateMode
66
57
  }
67
58
 
59
+ /** One inline create operation inside a hasMany mutation, keyed by its alias (a temp ID). */
60
+ interface NodeCreateOp {
61
+ readonly alias: string
62
+ readonly entityType: string
63
+ readonly createData: Record<string, unknown>
64
+ }
65
+
66
+ /** A create operation and the response row it produced. */
67
+ interface NodeCreatePair {
68
+ readonly op: NodeCreateOp
69
+ readonly nodeItem: Record<string, unknown>
70
+ }
71
+
72
+ interface ExecutedMutationResult extends TransactionMutationResult {
73
+ readonly nodeData?: Record<string, unknown>
74
+ }
75
+
76
+ type ExecutedPersist =
77
+ | {
78
+ readonly mode: 'atomic'
79
+ readonly ok: boolean
80
+ readonly results: readonly ExecutedMutationResult[]
81
+ }
82
+ | {
83
+ readonly mode: 'sequential'
84
+ readonly ok: boolean
85
+ readonly results: readonly ExecutedMutationResult[]
86
+ }
87
+
68
88
  /**
69
89
  * BatchPersister orchestrates multi-entity persistence with:
70
90
  * - Deduplication (same entity referenced multiple times → single mutation)
@@ -78,6 +98,8 @@ export class BatchPersister {
78
98
  private readonly undoManager?: UndoManager
79
99
  private readonly schema?: SchemaRegistry
80
100
  private readonly defaultUpdateMode: UpdateMode
101
+ private readonly nestedOutcomes = new WeakMap<PersistenceResult, readonly EntityPersistResult[]>()
102
+ private readonly eventOutcomes = new WeakMap<PersistenceResult, readonly EntityPersistResult[]>()
81
103
 
82
104
  constructor(
83
105
  private readonly adapter: BackendAdapter,
@@ -202,83 +224,243 @@ export class BatchPersister {
202
224
 
203
225
  // Sort by dependencies (creates first)
204
226
  const sortedEntities = this.sortByDependencies(entitiesToPersist)
205
-
206
- // Mark all as in-flight
207
- this.changeRegistry.markInFlight(sortedEntities)
208
-
209
- // Set persisting state for all entities
210
- for (const entity of sortedEntities) {
211
- this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true))
212
- this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId))
227
+ const collector = this.mutationCollector instanceof MutationCollector
228
+ ? this.mutationCollector.forkSession()
229
+ : this.mutationCollector
230
+ try {
231
+ const result = await this.executePersist(sortedEntities, scope, options, updateMode, collector)
232
+ const callbackOutcomes = [...result.results, ...(this.nestedOutcomes.get(result) ?? [])]
233
+ this.emitPersistOutcome(this.eventOutcomes.get(result) ?? callbackOutcomes)
234
+ for (const entry of callbackOutcomes) options?.onEntityPersisted?.(entry)
235
+ return result
236
+ } catch (error) {
237
+ this.emitPersistFailed(sortedEntities, toError(error))
238
+ throw error
213
239
  }
240
+ }
214
241
 
215
- // Block undo during persist
242
+ /**
243
+ * Runs the persist lifecycle for entities that passed the before-persist hooks.
244
+ * They are already marked in-flight by the caller; this method releases them.
245
+ */
246
+ private async executePersist(
247
+ sortedEntities: DirtyEntity[],
248
+ scope: PersistScope,
249
+ options: BatchPersistOptions | undefined,
250
+ updateMode: UpdateMode,
251
+ collector: MutationDataCollector | undefined,
252
+ ): Promise<PersistenceResult> {
253
+ const claimed = new Map<string, DirtyEntity>()
254
+ const accepted = new Map<string, DirtyEntity>()
255
+ const vetoed = new Map<string, DirtyEntity>()
256
+ const offered = new Set<string>()
257
+ const initialKeys = new Set(sortedEntities.map(entity => entityIdentityKey(entity.entityType, entity.entityId)))
258
+ this.claimEntities(sortedEntities, claimed, updateMode)
216
259
  this.undoManager?.block()
217
260
 
218
- // For pessimistic mode, capture dirty state before building mutations
219
- // This allows us to restore and commit the state after server confirmation
220
- let capturedStates: CapturedEntityState[] | null = null
221
- if (updateMode === 'pessimistic') {
222
- capturedStates = this.captureEntityStates(sortedEntities)
223
- }
224
-
225
261
  let result: PersistenceResult | undefined
226
262
  try {
227
- // Build mutations BEFORE resetting state (for pessimistic mode)
228
- // The mutations need to contain the dirty data to send to server
229
- const mutations = this.buildMutations(sortedEntities, scope)
230
-
231
- if (mutations.length === 0) {
232
- // Nothing to persist
233
- result = {
234
- success: true,
235
- results: [],
236
- successCount: 0,
237
- failedCount: 0,
238
- skippedCount: 0,
263
+ let execution: PersistExecution
264
+ while (true) {
265
+ const pending = [...claimed.values()].filter(entity => !offered.has(entityIdentityKey(entity.entityType, entity.entityId)))
266
+ for (const entity of pending) {
267
+ const key = entityIdentityKey(entity.entityType, entity.entityId)
268
+ offered.add(key)
269
+ if (options?.onEntityPersisting) {
270
+ await options.onEntityPersisting(entity.entityType, entity.entityId)
271
+ }
272
+ const outcome = this.hasPersistingInterceptors([entity])
273
+ ? await this.runPersistingInterceptors([entity])
274
+ : { accepted: [entity], cancelled: [] }
275
+ if (outcome.cancelled.length > 0) {
276
+ vetoed.set(key, entity)
277
+ this.releaseEntities([entity])
278
+ } else {
279
+ accepted.set(key, entity)
280
+ }
239
281
  }
240
- return result
241
- }
242
282
 
243
- // For pessimistic mode, reset entities to server state after capturing
244
- // This makes the UI show the "old" (server) data while persisting
245
- if (updateMode === 'pessimistic') {
246
- for (const entity of sortedEntities) {
247
- // Only reset updates, not creates (creates don't have server state)
248
- if (entity.changeType === 'update') {
249
- this.dispatcher.dispatch(resetEntity(entity.entityType, entity.entityId))
250
- this.store.resetAllRelations(entity.entityType, entity.entityId)
283
+ const topLevel = sortedEntities.filter(entity => accepted.has(entityIdentityKey(entity.entityType, entity.entityId)))
284
+ const vetoedKeys = new Set(vetoed.keys())
285
+ const mutations = this.buildMutations(topLevel, vetoedKeys, scope, collector)
286
+ const nested = collector instanceof MutationCollector ? collector.getNestedEntities() : []
287
+ const discovered: DirtyEntity[] = []
288
+ for (const entity of nested) {
289
+ const key = entityIdentityKey(entity.entityType, entity.entityId)
290
+ if (claimed.has(key) || vetoed.has(key)) continue
291
+ const dirty: DirtyEntity = {
292
+ entityType: entity.entityType,
293
+ entityId: entity.entityId,
294
+ changeType: 'create',
295
+ dirtyFields: this.store.getDirtyFields(entity.entityType, entity.entityId),
296
+ dirtyRelations: this.store.getDirtyRelations(entity.entityType, entity.entityId),
251
297
  }
298
+ discovered.push(dirty)
252
299
  }
300
+ if (discovered.length > 0) {
301
+ this.claimEntities(discovered, claimed, updateMode)
302
+ continue
303
+ }
304
+
305
+ const executionEntities = [...accepted.values()].filter(entity => initialKeys.has(entityIdentityKey(entity.entityType, entity.entityId)))
306
+ execution = createPersistExecution(
307
+ this.store,
308
+ mutations,
309
+ executionEntities,
310
+ nested,
311
+ collector instanceof MutationCollector ? collector.getCollectedRelationFields() : [],
312
+ collector instanceof MutationCollector ? collector.getCollectedNestedCreates() : [],
313
+ collector instanceof MutationCollector ? collector.getCollectedNestedUpdates() : [],
314
+ collector instanceof MutationCollector ? collector.getCollectedHasOneChanges() : [],
315
+ collector instanceof MutationCollector ? collector.getCollectedHasManyChanges() : [],
316
+ [...vetoed.values()],
317
+ collector instanceof MutationCollector ? collector.getSuppressedRelationItems() : new Map(),
318
+ scope,
319
+ )
320
+ break
253
321
  }
254
322
 
255
- // Execute transaction
256
- const transactionResult = await this.executeTransaction(mutations, options?.signal)
323
+ this.assertCustomCollectorIsSafe(execution, collector)
324
+ if (execution.mutations.length === 0) {
325
+ result = this.mergeCancelled(emptyPersistenceResult(), execution.vetoed)
326
+ return result
327
+ }
257
328
 
258
- // Process results with captured state for pessimistic mode
259
- result = this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates)
329
+ const transactionResult = await this.executeTransaction(execution, options?.signal)
330
+ result = this.processExecutionResult(execution, transactionResult, options)
331
+ result = this.mergeCancelled(result, execution.vetoed)
260
332
  return result
261
333
 
262
334
  } finally {
263
- // Clear in-flight status
264
- this.changeRegistry.clearInFlight(sortedEntities)
335
+ this.releaseEntities([...claimed.values()])
336
+ this.undoManager?.unblock()
337
+ if (result?.success) {
338
+ this.store.sweepUnreachableCreated()
339
+ }
340
+ }
341
+
342
+ // Unreachable, but keeps the return type explicit when control-flow analysis changes.
343
+ return emptyPersistenceResult()
344
+ }
265
345
 
266
- // Clear persisting state
267
- for (const entity of sortedEntities) {
268
- this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, false))
346
+ /**
347
+ * Whether any entity in the batch has an `entity:persisting` interceptor.
348
+ */
349
+ private hasPersistingInterceptors(entities: readonly DirtyEntity[]): boolean {
350
+ const emitter = this.dispatcher.getEventEmitter()
351
+ return entities.some(
352
+ entity => emitter.hasInterceptors('entity:persisting', entity.entityType, entity.entityId),
353
+ )
354
+ }
355
+
356
+ /**
357
+ * Runs `entity:persisting` interceptors for every entity in the batch.
358
+ * A `null` result vetoes that one entity — mirroring how ActionDispatcher treats
359
+ * a cancelling interceptor — while the rest of the batch proceeds.
360
+ */
361
+ private async runPersistingInterceptors(
362
+ entities: readonly DirtyEntity[],
363
+ ): Promise<{ accepted: DirtyEntity[]; cancelled: DirtyEntity[] }> {
364
+ const emitter = this.dispatcher.getEventEmitter()
365
+ const accepted: DirtyEntity[] = []
366
+ const cancelled: DirtyEntity[] = []
367
+
368
+ for (const entity of entities) {
369
+ const event: EntityPersistingEvent = {
370
+ type: 'entity:persisting',
371
+ timestamp: Date.now(),
372
+ entityType: entity.entityType,
373
+ entityId: entity.entityId,
374
+ isNew: entity.changeType === 'create',
269
375
  }
376
+ const result = await emitter.runInterceptors(event)
377
+ if (result === null) {
378
+ cancelled.push(entity)
379
+ } else {
380
+ accepted.push(entity)
381
+ }
382
+ }
270
383
 
271
- // Unblock undo
272
- this.undoManager?.unblock()
384
+ return { accepted, cancelled }
385
+ }
273
386
 
274
- // Reclaim memory: drop snapshots of any created entities orphaned during
275
- // editing. Only after a FULLY successful persist — on failure the captured
276
- // creates/edits were restored for retry (see processTransactionResult), and
277
- // sweeping then could reclaim a create the user still intends to save.
278
- if (result?.success) {
279
- this.store.sweepUnreachableCreated()
387
+ /**
388
+ * Emits `entity:persisted` / `entity:persistFailed` for entities that were sent.
389
+ */
390
+ private emitPersistOutcome(results: readonly EntityPersistResult[]): void {
391
+ const emitter = this.dispatcher.getEventEmitter()
392
+
393
+ for (const entry of results) {
394
+ if (entry.skipped) continue
395
+ if (entry.success) {
396
+ emitter.emit({
397
+ type: 'entity:persisted',
398
+ timestamp: Date.now(),
399
+ entityType: entry.entityType,
400
+ entityId: entry.entityId,
401
+ isNew: entry.operation === 'create',
402
+ // Updates keep their id; creates carry the server-assigned one.
403
+ persistedId: entry.persistedId ?? entry.entityId,
404
+ } satisfies EntityPersistedEvent)
405
+ } else {
406
+ emitter.emit({
407
+ type: 'entity:persistFailed',
408
+ timestamp: Date.now(),
409
+ entityType: entry.entityType,
410
+ entityId: entry.entityId,
411
+ isNew: entry.operation === 'create',
412
+ error: new Error(entry.error?.message ?? 'Persist failed'),
413
+ } satisfies EntityPersistFailedEvent)
280
414
  }
281
415
  }
416
+
417
+ }
418
+
419
+ /**
420
+ * Emits `entity:persistFailed` for the whole batch when the persist itself threw.
421
+ */
422
+ private emitPersistFailed(entities: readonly DirtyEntity[], error: Error): void {
423
+ const emitter = this.dispatcher.getEventEmitter()
424
+
425
+ for (const entity of entities) {
426
+ emitter.emit({
427
+ type: 'entity:persistFailed',
428
+ timestamp: Date.now(),
429
+ entityType: entity.entityType,
430
+ entityId: entity.entityId,
431
+ isNew: entity.changeType === 'create',
432
+ error,
433
+ } satisfies EntityPersistFailedEvent)
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Folds vetoed entities into the result as skipped — never as successes, and never
439
+ * as server failures, so callers can tell a deliberate veto from a broken save.
440
+ * Their entries carry `skipped`, which keeps `failedCount` in step with `results`.
441
+ */
442
+ private mergeCancelled(
443
+ attempted: PersistenceResult,
444
+ cancelled: readonly DirtyEntity[],
445
+ ): PersistenceResult {
446
+ if (cancelled.length === 0) return attempted
447
+
448
+ const cancelledResults = cancelled.map((entity): EntityPersistResult => ({
449
+ entityType: entity.entityType,
450
+ entityId: entity.entityId,
451
+ operation: entity.changeType,
452
+ success: false,
453
+ skipped: true,
454
+ error: { message: `Persist of ${entity.entityType}:${entity.entityId} was cancelled by an entity:persisting interceptor` },
455
+ }))
456
+
457
+ return {
458
+ success: false,
459
+ results: [...attempted.results, ...cancelledResults],
460
+ successCount: attempted.successCount,
461
+ failedCount: attempted.failedCount,
462
+ skippedCount: attempted.skippedCount + cancelled.length,
463
+ }
282
464
  }
283
465
 
284
466
  /**
@@ -393,16 +575,22 @@ export class BatchPersister {
393
575
  */
394
576
  private buildMutations(
395
577
  entities: DirtyEntity[],
578
+ vetoedEntityKeys: ReadonlySet<string>,
396
579
  scope: PersistScope,
580
+ collector: MutationDataCollector | undefined,
397
581
  ): TransactionMutation[] {
398
582
  // Exclude only non-create entities from nesting —
399
583
  // new entities should be nested inside their parent's mutation
400
584
  // to maintain correct relation connections without transaction support.
401
- if (this.mutationCollector instanceof MutationCollector) {
402
- const excludedIds = new Set(
403
- entities.filter(e => e.changeType !== 'create').map(e => e.entityId),
404
- )
405
- this.mutationCollector.setExcludedEntities(excludedIds)
585
+ // Vetoed entities are kept apart: an excluded entity still has its parent-side
586
+ // delete emitted, a vetoed one must not be written at all.
587
+ if (collector instanceof MutationCollector) {
588
+ const excludedIds = new Set<string>()
589
+ for (const entity of entities) {
590
+ if (entity.changeType !== 'create') excludedIds.add(entityIdentityKey(entity.entityType, entity.entityId))
591
+ }
592
+ collector.setExcludedEntityKeys(excludedIds)
593
+ collector.setVetoedEntityKeys(vetoedEntityKeys)
406
594
  }
407
595
 
408
596
  const mutations: TransactionMutation[] = []
@@ -424,13 +612,13 @@ export class BatchPersister {
424
612
  // Field-specific collection
425
613
  data = this.collectFieldsData(entity.entityType, entity.entityId, scope.fields)
426
614
  } else if (entity.changeType === 'create') {
427
- const mc = this.mutationCollector
615
+ const mc = collector
428
616
  data = mc?.collectCreateData
429
617
  ? mc.collectCreateData(entity.entityType, entity.entityId)
430
618
  : this.collectCreateDataWithRelationCheck(entity)
431
619
  } else {
432
- data = this.mutationCollector
433
- ? this.mutationCollector.collectUpdateData(entity.entityType, entity.entityId)
620
+ data = collector
621
+ ? collector.collectUpdateData(entity.entityType, entity.entityId)
434
622
  : this.collectUpdateDataWithRelationCheck(entity)
435
623
  }
436
624
 
@@ -446,10 +634,10 @@ export class BatchPersister {
446
634
 
447
635
  // Remove standalone create mutations for entities that were included
448
636
  // as nested inline creates inside another entity's mutation.
449
- if (this.mutationCollector instanceof MutationCollector) {
450
- const nestedIds = this.mutationCollector.getNestedEntityIds()
451
- if (nestedIds.size > 0) {
452
- return mutations.filter(m => !(m.operation === 'create' && nestedIds.has(m.entityId)))
637
+ if (collector instanceof MutationCollector) {
638
+ const nestedKeys = new Set(collector.getNestedEntities().map(entity => entityIdentityKey(entity.entityType, entity.entityId)))
639
+ if (nestedKeys.size > 0) {
640
+ return mutations.filter(m => !(m.operation === 'create' && nestedKeys.has(entityIdentityKey(m.entityType, m.entityId))))
453
641
  }
454
642
  }
455
643
 
@@ -561,16 +749,18 @@ export class BatchPersister {
561
749
  * Executes mutations as a transaction.
562
750
  */
563
751
  private async executeTransaction(
564
- mutations: TransactionMutation[],
752
+ execution: PersistExecution,
565
753
  signal?: AbortSignal,
566
- ): Promise<TransactionResult> {
754
+ ): Promise<ExecutedPersist> {
755
+ const mutations = execution.mutations
567
756
  // Check if adapter supports transactions
568
757
  if ('persistTransaction' in this.adapter && typeof this.adapter.persistTransaction === 'function') {
569
758
  try {
570
- return await this.adapter.persistTransaction(mutations)
759
+ const result = await this.adapter.persistTransaction(mutations)
760
+ return { mode: 'atomic', ok: result.ok, results: result.results }
571
761
  } catch (error) {
572
- // Adapter threw an exception - mark all as failed
573
762
  return {
763
+ mode: 'atomic',
574
764
  ok: false,
575
765
  results: mutations.map(m => ({
576
766
  entityType: m.entityType,
@@ -582,8 +772,26 @@ export class BatchPersister {
582
772
  }
583
773
  }
584
774
 
585
- // Fallback: execute sequentially (not truly transactional)
586
- const results: TransactionResult['results'][number][] = []
775
+ const missingCreate = mutations.some(mutation => mutation.operation === 'create') && !this.adapter.create
776
+ const missingDelete = mutations.some(mutation => mutation.operation === 'delete') && !this.adapter.delete
777
+ if (missingCreate || missingDelete) {
778
+ const message = [
779
+ missingCreate ? 'Adapter does not implement create' : '',
780
+ missingDelete ? 'Adapter does not implement delete' : '',
781
+ ].filter(Boolean).join('; ')
782
+ return {
783
+ mode: 'sequential',
784
+ ok: false,
785
+ results: mutations.map(mutation => ({
786
+ entityType: mutation.entityType,
787
+ entityId: mutation.entityId,
788
+ ok: false,
789
+ errorMessage: message,
790
+ })),
791
+ }
792
+ }
793
+
794
+ const results: ExecutedMutationResult[] = []
587
795
  let allOk = true
588
796
 
589
797
  for (const mutation of mutations) {
@@ -614,16 +822,13 @@ export class BatchPersister {
614
822
  } else if (mutation.operation === 'create') {
615
823
  if (this.adapter.create && mutation.data) {
616
824
  const result = await this.adapter.create(mutation.entityType, mutation.data)
617
- const persistedId = result.data?.['id'] as string | undefined
618
- const nestedResults = result.ok && result.data && mutation.data
619
- ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId)
620
- : undefined
825
+ const persistedId = getStringProperty(result.data, 'id')
621
826
  results.push({
622
827
  entityType: mutation.entityType,
623
828
  entityId: mutation.entityId,
624
829
  ok: result.ok,
625
830
  persistedId,
626
- nestedResults,
831
+ nodeData: result.data,
627
832
  errorMessage: result.errorMessage,
628
833
  mutationResult: result.mutationResult,
629
834
  })
@@ -637,14 +842,11 @@ export class BatchPersister {
637
842
  mutation.entityId,
638
843
  mutation.data,
639
844
  )
640
- const nestedResults = result.ok && result.data && mutation.data
641
- ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId)
642
- : undefined
643
845
  results.push({
644
846
  entityType: mutation.entityType,
645
847
  entityId: mutation.entityId,
646
848
  ok: result.ok,
647
- nestedResults,
849
+ nodeData: result.data,
648
850
  errorMessage: result.errorMessage,
649
851
  mutationResult: result.mutationResult,
650
852
  })
@@ -662,229 +864,360 @@ export class BatchPersister {
662
864
  }
663
865
  }
664
866
 
665
- return { ok: allOk, results }
867
+ return { mode: 'sequential', ok: allOk, results }
666
868
  }
667
869
 
668
870
  /**
669
871
  * Processes transaction result and commits/rolls back as needed.
670
872
  * For pessimistic mode, restores captured state on success.
671
873
  */
672
- private processTransactionResult(
673
- entities: DirtyEntity[],
674
- transactionResult: TransactionResult,
675
- scope: PersistScope,
874
+ private processExecutionResult(
875
+ execution: PersistExecution,
876
+ transactionResult: ExecutedPersist,
676
877
  options?: BatchPersistOptions,
677
- capturedStates?: CapturedEntityState[] | null,
678
878
  ): PersistenceResult {
679
879
  const results: EntityPersistResult[] = []
680
- let successCount = 0
681
- let failedCount = 0
880
+ const nestedResults: EntityPersistResult[] = []
682
881
  const rollbackOnError = options?.rollbackOnError ?? false
683
- const isPessimistic = capturedStates != null
684
-
685
- if (transactionResult.ok) {
686
- // All succeeded - commit all
687
- for (let i = 0; i < transactionResult.results.length; i++) {
688
- const mutationResult = transactionResult.results[i]!
689
- const entity = entities.find(
690
- e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId,
691
- )
692
-
693
- if (entity) {
694
- // For pessimistic mode, restore captured state and commit
695
- if (isPessimistic && capturedStates) {
696
- const capturedState = capturedStates.find(
697
- s => s.entityType === entity.entityType && s.entityId === entity.entityId,
698
- )
699
- if (capturedState) {
700
- this.restoreEntityState(capturedState, true)
701
- }
702
- } else {
703
- // Commit based on scope
704
- if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) {
705
- // Partial commit for field scope
706
- this.store.commitFields(entity.entityType, entity.entityId, [...scope.fields])
707
- } else {
708
- // Full commit
709
- this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId))
710
- this.store.commitAllRelations(entity.entityType, entity.entityId)
711
- }
712
- }
713
-
714
- // Map temp ID if create
715
- if (entity.changeType === 'create' && mutationResult.persistedId) {
716
- this.store.mapTempIdToPersistedId(
717
- entity.entityType,
718
- entity.entityId,
719
- mutationResult.persistedId,
720
- )
721
- }
722
-
723
- // Process nested results (inline-created entities within this mutation)
724
- if (mutationResult.nestedResults) {
725
- this.commitNestedResults(mutationResult.nestedResults)
726
- }
882
+ const atomicFailure = transactionResult.mode === 'atomic' && (
883
+ !transactionResult.ok || transactionResult.results.some(entry => !entry.ok)
884
+ )
727
885
 
728
- results.push({
729
- entityType: entity.entityType,
730
- entityId: entity.entityId,
731
- operation: entity.changeType,
732
- success: true,
733
- persistedId: mutationResult.persistedId,
734
- })
735
- successCount++
886
+ for (const mutation of execution.mutations) {
887
+ const mutationResult = transactionResult.results.find(entry => (
888
+ entry.entityType === mutation.entityType && entry.entityId === mutation.entityId
889
+ ))
890
+ const entity = execution.entities.find(entry => (
891
+ entry.entityType === mutation.entityType && entry.entityId === mutation.entityId
892
+ ))
893
+ if (!entity) continue
894
+
895
+ const adapterSucceeded = mutationResult?.ok === true && !atomicFailure
896
+ if (!adapterSucceeded) {
897
+ const message = atomicFailure
898
+ ? mutationResult?.errorMessage ?? 'Atomic transaction failed'
899
+ : mutationResult?.errorMessage ?? 'Mutation result missing'
900
+ this.mapServerErrors(entity.entityType, entity.entityId, mutationResult?.mutationResult, message)
901
+ if (rollbackOnError) this.rollbackExecutionEntity(entity)
902
+ results.push(toFailedResult(entity, message, mutationResult?.mutationResult))
903
+ for (const nested of this.expectedNestedEntities(execution, mutation)) {
904
+ nestedResults.push(toFailedResult(nested, message))
736
905
  }
737
- }
738
-
739
- // Commit nested entities that don't have explicit results from the adapter.
740
- // These entities were nested inside a parent mutation's data and exist on the server,
741
- // but the adapter may not provide individual results for them.
742
- this.commitUnresolvedNestedEntities(entities)
743
- } else {
744
- // Transaction failed - map errors and optionally rollback
745
- // For pessimistic mode, entities are already at server state, no rollback needed
746
- for (const mutationResult of transactionResult.results) {
747
- const entity = entities.find(
748
- e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId,
749
- )
750
-
751
- if (entity) {
752
- if (!mutationResult.ok) {
753
- // Map errors to fields
754
- this.mapServerErrors(
755
- entity.entityType,
756
- entity.entityId,
757
- mutationResult.mutationResult,
758
- mutationResult.errorMessage,
759
- )
760
-
761
- // Optimistic mode rolls a FAILED entity back to server state only when
762
- // rollbackOnError is set. Pessimistic restore is handled below for ALL
763
- // captured entities, not just the failed ones.
764
- if (!isPessimistic && rollbackOnError) {
765
- this.rollbackEntity(entity)
766
- }
767
- }
768
-
769
- results.push({
770
- entityType: entity.entityType,
771
- entityId: entity.entityId,
772
- operation: entity.changeType,
773
- success: mutationResult.ok,
774
- error: mutationResult.ok ? undefined : {
775
- message: mutationResult.errorMessage ?? 'Unknown error',
776
- mutationResult: mutationResult.mutationResult,
777
- },
778
- persistedId: mutationResult.persistedId,
779
- })
780
-
781
- if (mutationResult.ok) {
782
- successCount++
783
- } else {
784
- failedCount++
785
- }
906
+ for (const nested of this.expectedNestedUpdates(execution, mutation)) {
907
+ nestedResults.push(toFailedResult(nested, message))
786
908
  }
909
+ continue
910
+ }
911
+ if (entity.operation === 'create' && isTempId(entity.entityId) && !mutationResult?.persistedId) {
912
+ const message = `Create of ${entity.entityType}:${entity.entityId} succeeded without a server ID`
913
+ this.mapServerErrors(entity.entityType, entity.entityId, undefined, message)
914
+ results.push(toFailedResult(entity, message))
915
+ continue
787
916
  }
788
917
 
789
- // Pessimistic mode: the transaction failed, so from the user's point of view
790
- // nothing was committed. Undo the pre-transaction server-view reset for EVERY
791
- // captured entity (not only the ones whose individual mutation failed) and keep
792
- // the restored data dirty (commit=false) so the user can retry. Restoring even
793
- // the entities whose mutation happened to succeed in the non-atomic sequential
794
- // fallback is what re-establishes their relation edges to inline-created
795
- // children — keeping those creates reachable so the post-persist sweep does not
796
- // reclaim a child the user still intends to save (P2).
797
- if (isPessimistic && capturedStates) {
798
- for (const capturedState of capturedStates) {
799
- this.restoreEntityState(capturedState, false)
800
- }
918
+ const resolvedNested = this.resolveNestedResults(execution, mutation, mutationResult)
919
+ const nestedUpdates = this.expectedNestedUpdates(execution, mutation)
920
+ const involved = [entity, ...resolvedNested.entities, ...nestedUpdates]
921
+ const conflicts = this.reconcileConfirmedEntities(execution, involved, new Set(resolvedNested.persistedIds.keys()))
922
+ this.mapConfirmedIds(entity, mutationResult.persistedId, resolvedNested.persistedIds)
923
+
924
+ for (const nested of resolvedNested.entities) {
925
+ const key = entityIdentityKey(nested.entityType, nested.entityId)
926
+ const conflict = conflicts.get(key)?.join('; ')
927
+ nestedResults.push(conflict
928
+ ? toFailedResult(nested, conflict)
929
+ : toSuccessResult(nested, resolvedNested.persistedIds.get(key)))
930
+ }
931
+ for (const nested of nestedUpdates) {
932
+ const conflict = conflicts.get(entityIdentityKey(nested.entityType, nested.entityId))?.join('; ')
933
+ nestedResults.push(conflict ? toFailedResult(nested, conflict) : toSuccessResult(nested))
934
+ }
935
+ const unresolvedMessage = resolvedNested.unresolved.length > 0
936
+ ? `Missing or ambiguous server ID for nested create ${resolvedNested.unresolved.map(item => `${item.entityType}:${item.entityId}`).join(', ')}`
937
+ : undefined
938
+ for (const nested of resolvedNested.unresolved) {
939
+ nestedResults.push(toFailedResult(nested, unresolvedMessage ?? 'Nested create could not be resolved'))
940
+ }
941
+ const conflictMessage = [...conflicts.values()].flat().join('; ')
942
+ const failure = [unresolvedMessage, conflictMessage || undefined].filter((message): message is string => message !== undefined).join('; ')
943
+ if (failure) {
944
+ this.mapServerErrors(entity.entityType, entity.entityId, undefined, failure)
945
+ results.push(toFailedResult(entity, failure))
946
+ } else {
947
+ results.push(toSuccessResult(entity, this.persistedIdFor(entity, mutationResult.persistedId)))
801
948
  }
802
949
  }
803
950
 
804
- return {
805
- success: transactionResult.ok,
951
+ for (const nested of execution.entities.filter(entity => entity.nested)) {
952
+ const key = entityIdentityKey(nested.entityType, nested.entityId)
953
+ if (nestedResults.some(entry => entityIdentityKey(entry.entityType, entry.entityId) === key)) continue
954
+ if (atomicFailure) nestedResults.push(toFailedResult(nested, 'Atomic transaction failed'))
955
+ }
956
+
957
+ const successCount = results.filter(entry => entry.success).length
958
+ const failedCount = results.length - successCount
959
+ const result: PersistenceResult = {
960
+ success: failedCount === 0 && transactionResult.ok,
806
961
  results,
807
962
  successCount,
808
963
  failedCount,
809
964
  skippedCount: 0,
810
965
  }
966
+ this.nestedOutcomes.set(result, nestedResults)
967
+ if (atomicFailure) {
968
+ this.eventOutcomes.set(result, [
969
+ ...execution.mutations.map(mutation => {
970
+ const entity = execution.entities.find(candidate => (
971
+ candidate.entityType === mutation.entityType && candidate.entityId === mutation.entityId
972
+ ))
973
+ return entity ? toFailedResult(entity, 'Atomic transaction failed') : undefined
974
+ }).filter((entry): entry is EntityPersistResult => entry !== undefined),
975
+ ...nestedResults,
976
+ ])
977
+ }
978
+ return result
811
979
  }
812
980
 
813
- /**
814
- * Captures the current state of entities for pessimistic mode.
815
- * This allows us to restore the dirty state after a successful persist.
816
- */
817
- private captureEntityStates(entities: DirtyEntity[]): CapturedEntityState[] {
818
- return entities.map(entity => {
819
- const snapshot = this.store.getEntitySnapshot(entity.entityType, entity.entityId)
820
- const relations = this.store.getAllRelationsForEntity(entity.entityType, entity.entityId)
821
- const hasManyStates = this.store.getAllHasManyForEntity(entity.entityType, entity.entityId)
981
+ private claimEntities(
982
+ entities: readonly DirtyEntity[],
983
+ claimed: Map<string, DirtyEntity>,
984
+ updateMode: UpdateMode,
985
+ ): void {
986
+ const fresh = entities.filter(entity => !claimed.has(entityIdentityKey(entity.entityType, entity.entityId)))
987
+ if (fresh.length === 0) return
988
+ this.changeRegistry.markInFlight(fresh)
989
+ for (const entity of fresh) {
990
+ claimed.set(entityIdentityKey(entity.entityType, entity.entityId), entity)
991
+ this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic'))
992
+ this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId))
993
+ }
994
+ }
822
995
 
823
- return {
824
- entityType: entity.entityType,
825
- entityId: entity.entityId,
826
- snapshot,
827
- relations,
828
- hasManyStates,
996
+ private releaseEntities(entities: readonly DirtyEntity[]): void {
997
+ if (entities.length === 0) return
998
+ this.changeRegistry.clearInFlight(entities)
999
+ for (const entity of entities) {
1000
+ this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, false))
1001
+ }
1002
+ }
1003
+
1004
+ private assertCustomCollectorIsSafe(
1005
+ execution: PersistExecution,
1006
+ collector: MutationDataCollector | undefined,
1007
+ ): void {
1008
+ if (!collector || collector instanceof MutationCollector) return
1009
+ for (const entity of execution.entities) {
1010
+ const relations = this.store.getDirtyRelations(entity.entityType, entity.entityId)
1011
+ if (relations.length > 0) {
1012
+ throw new Error(
1013
+ `Custom mutation collectors support scalar data only; ${entity.entityType}:${entity.entityId} has relation changes`,
1014
+ )
829
1015
  }
830
- })
1016
+ }
1017
+ if (execution.mutations.some(mutation => containsNestedMutation(mutation.data))) {
1018
+ throw new Error('Custom mutation collectors cannot emit nested or relation mutation operations')
1019
+ }
831
1020
  }
832
1021
 
833
- /**
834
- * Restores a captured entity state (pessimistic mode), optionally committing it.
835
- *
836
- * - commit=true (server confirmed): restore the dirty data and commit it as the
837
- * new server baseline.
838
- * - commit=false (server rejected): restore the dirty data but leave it dirty, so
839
- * the user's in-flight edits and creates survive for a retry (P2) instead of
840
- * being stuck at the server view the pessimistic reset left behind.
841
- */
842
- private restoreEntityState(capturedState: CapturedEntityState, commit: boolean): void {
843
- if (capturedState.snapshot) {
844
- this.store.setEntityData(
845
- capturedState.entityType,
846
- capturedState.entityId,
847
- capturedState.snapshot.data as Record<string, unknown>,
848
- commit, // server data only when confirmed; otherwise keep it as a dirty edit
1022
+ private resolveNestedResults(
1023
+ execution: PersistExecution,
1024
+ mutation: TransactionMutation,
1025
+ result: ExecutedMutationResult,
1026
+ ): {
1027
+ readonly entities: readonly ExecutionEntity[]
1028
+ readonly unresolved: readonly ExecutionEntity[]
1029
+ readonly persistedIds: ReadonlyMap<string, string>
1030
+ } {
1031
+ const expected = this.expectedNestedEntities(execution, mutation)
1032
+ if (expected.length === 0) return { entities: [], unresolved: [], persistedIds: new Map() }
1033
+
1034
+ let supplied = result.nestedResults ?? []
1035
+ if (supplied.length === 0 && result.nodeData) {
1036
+ supplied = this.extractNestedResultsFromNode(
1037
+ execution,
1038
+ result.nodeData,
1039
+ mutation.entityType,
1040
+ mutation.entityId,
849
1041
  )
850
1042
  }
1043
+ const flattened = flattenMutationResults(supplied)
1044
+ const persistedIds = new Map<string, string>()
1045
+ const unresolved: ExecutionEntity[] = []
1046
+ const typesById = new Map<string, Set<string>>()
1047
+ for (const entity of expected) {
1048
+ const types = typesById.get(entity.entityId)
1049
+ if (types) types.add(entity.entityType)
1050
+ else typesById.set(entity.entityId, new Set([entity.entityType]))
1051
+ }
1052
+ for (const entity of expected) {
1053
+ if ((typesById.get(entity.entityId)?.size ?? 0) > 1) {
1054
+ unresolved.push(entity)
1055
+ continue
1056
+ }
1057
+ const exactMatch = flattened.find(entry => (
1058
+ entry.entityType === entity.entityType && entry.entityId === entity.entityId && entry.ok
1059
+ ))
1060
+ const match = exactMatch ?? flattened.find(entry => (
1061
+ entry.entityType === 'Unknown'
1062
+ && entry.entityId === entity.entityId
1063
+ && entry.ok
1064
+ && typesById.get(entity.entityId)?.size === 1
1065
+ ))
1066
+ const persistedId = match?.persistedId ?? (!isTempId(entity.entityId) ? entity.entityId : undefined)
1067
+ if (!persistedId) unresolved.push(entity)
1068
+ else persistedIds.set(entityIdentityKey(entity.entityType, entity.entityId), persistedId)
1069
+ }
1070
+ return { entities: expected.filter(entity => !unresolved.includes(entity)), unresolved, persistedIds }
1071
+ }
851
1072
 
852
- for (const [relationKey, relationState] of capturedState.relations) {
853
- const fieldName = relationKey.split(':')[2]
854
- if (fieldName) {
855
- this.store.setRelation(capturedState.entityType, capturedState.entityId, fieldName, {
856
- currentId: relationState.currentId,
857
- state: relationState.state,
858
- // Restore placeholderData too: a has-one in the 'creating' state carries
859
- // its inline create payload here, and getDirtyRelations treats a
860
- // non-empty placeholderData as a dirty signal. Dropping it on a
861
- // commit=false retry would lose the inline data and silently mark the
862
- // relation clean. (For commit=true it is cleared by commitRelation below.)
863
- placeholderData: relationState.placeholderData,
864
- })
865
- if (commit) {
866
- this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName)
1073
+ private expectedNestedEntities(
1074
+ execution: PersistExecution,
1075
+ mutation: TransactionMutation,
1076
+ ): ExecutionEntity[] {
1077
+ return this.expectedNestedGraph(execution, mutation).creates
1078
+ }
1079
+
1080
+ private expectedNestedUpdates(execution: PersistExecution, mutation: TransactionMutation): ExecutionEntity[] {
1081
+ return this.expectedNestedGraph(execution, mutation).updates
1082
+ }
1083
+
1084
+ private expectedNestedGraph(
1085
+ execution: PersistExecution,
1086
+ mutation: TransactionMutation,
1087
+ ): { readonly creates: ExecutionEntity[]; readonly updates: ExecutionEntity[] } {
1088
+ const entities = new Map(execution.entities.map(entity => [entityIdentityKey(entity.entityType, entity.entityId), entity]))
1089
+ const ownerQueue = [entityIdentityKey(mutation.entityType, mutation.entityId)]
1090
+ const owners = new Set(ownerQueue)
1091
+ const creates = new Map<string, ExecutionEntity>()
1092
+ const updates = new Map<string, ExecutionEntity>()
1093
+ for (let index = 0; index < ownerQueue.length; index++) {
1094
+ const owner = ownerQueue[index]!
1095
+ for (const descriptor of execution.nestedCreates) {
1096
+ if (entityIdentityKey(descriptor.parentEntityType, descriptor.parentEntityId) !== owner) continue
1097
+ const key = entityIdentityKey(descriptor.entityType, descriptor.entityId)
1098
+ const entity = entities.get(key)
1099
+ if (!entity) continue
1100
+ creates.set(key, entity)
1101
+ if (!owners.has(key)) {
1102
+ owners.add(key)
1103
+ ownerQueue.push(key)
1104
+ }
1105
+ }
1106
+ for (const update of execution.nestedUpdates) {
1107
+ if (entityIdentityKey(update.parentEntityType, update.parentEntityId) !== owner) continue
1108
+ const key = entityIdentityKey(update.entityType, update.entityId)
1109
+ const entity = entities.get(key)
1110
+ if (!entity) continue
1111
+ updates.set(key, entity)
1112
+ if (!owners.has(key)) {
1113
+ owners.add(key)
1114
+ ownerQueue.push(key)
867
1115
  }
868
1116
  }
869
1117
  }
1118
+ return { creates: [...creates.values()], updates: [...updates.values()] }
1119
+ }
870
1120
 
871
- for (const [hasManyKey, hasManyState] of capturedState.hasManyStates) {
872
- const fieldName = hasManyKey.split(':')[2]
873
- if (fieldName) {
874
- this.store.restoreHasManyState(capturedState.entityType, capturedState.entityId, fieldName, hasManyState)
875
- if (commit) {
876
- // Compute new server IDs: serverIds - removals + connections
877
- const newServerIds = new Set(hasManyState.serverIds)
878
- for (const removedId of hasManyState.plannedRemovals.keys()) {
879
- newServerIds.delete(removedId)
880
- }
881
- for (const connectedId of hasManyState.plannedConnections) {
882
- newServerIds.add(connectedId)
883
- }
884
- this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds))
1121
+ private reconcileConfirmedEntities(
1122
+ execution: PersistExecution,
1123
+ entities: readonly ExecutionEntity[],
1124
+ confirmedNestedCreates: ReadonlySet<string>,
1125
+ ): ReadonlyMap<string, readonly string[]> {
1126
+ const keys = new Set(entities.map(entity => entityIdentityKey(entity.entityType, entity.entityId)))
1127
+ const conflicts = new Map<string, string[]>()
1128
+ const addConflict = (key: string, message: string): void => {
1129
+ const messages = conflicts.get(key)
1130
+ if (messages) messages.push(message)
1131
+ else conflicts.set(key, [message])
1132
+ }
1133
+ for (const entity of entities) {
1134
+ const key = entityIdentityKey(entity.entityType, entity.entityId)
1135
+ if (entity.operation === 'delete') {
1136
+ if (!this.store.isScheduledForDeletion(entity.entityType, entity.entityId)) {
1137
+ this.store.setExistsOnServer(entity.entityType, entity.entityId, false)
1138
+ addConflict(key, `Delete of ${entity.entityType}:${entity.entityId} completed after the local deletion was reversed`)
1139
+ continue
885
1140
  }
1141
+ this.store.removeEntity(entity.entityType, entity.entityId)
1142
+ continue
1143
+ }
1144
+ this.store.refreshServerData(entity.entityType, entity.entityId, entity.scalarData)
1145
+ }
1146
+
1147
+ for (const change of execution.hasOneChanges) {
1148
+ const ownerKey = entityIdentityKey(change.entityType, change.entityId)
1149
+ if (!keys.has(ownerKey)) continue
1150
+ if (change.transition.operation === 'create') {
1151
+ const descriptor = execution.nestedCreates.find(item => (
1152
+ item.parentEntityType === change.entityType
1153
+ && item.parentEntityId === change.entityId
1154
+ && item.fieldName === change.fieldName
1155
+ && item.entityId === change.transition.targetId
1156
+ ))
1157
+ if (!descriptor || !confirmedNestedCreates.has(entityIdentityKey(descriptor.entityType, descriptor.entityId))) continue
1158
+ }
1159
+ const outcome = this.store.reconcileSentRelation(
1160
+ change.entityType,
1161
+ change.entityId,
1162
+ change.fieldName,
1163
+ change.transition,
1164
+ )
1165
+ if (outcome === 'conflict') addConflict(ownerKey, relationConflictMessage(change.entityType, change.entityId, change.fieldName))
1166
+ }
1167
+ for (const change of execution.hasManyChanges) {
1168
+ const ownerKey = entityIdentityKey(change.entityType, change.entityId)
1169
+ if (!keys.has(ownerKey)) continue
1170
+ const additions = change.additions.filter(addition => {
1171
+ if (addition.kind !== 'created') return true
1172
+ const descriptor = execution.nestedCreates.find(item => (
1173
+ item.parentEntityType === change.entityType
1174
+ && item.parentEntityId === change.entityId
1175
+ && item.fieldName === change.fieldName
1176
+ && item.entityId === addition.itemId
1177
+ ))
1178
+ return descriptor !== undefined && confirmedNestedCreates.has(entityIdentityKey(descriptor.entityType, descriptor.entityId))
1179
+ })
1180
+ if (additions.length === 0 && change.removals.length === 0) continue
1181
+ const outcome = this.store.reconcileSentHasMany(
1182
+ change.entityType,
1183
+ change.entityId,
1184
+ change.fieldName,
1185
+ { additions, removals: change.removals },
1186
+ )
1187
+ if (outcome === 'conflict') addConflict(ownerKey, relationConflictMessage(change.entityType, change.entityId, change.fieldName))
1188
+ }
1189
+ return conflicts
1190
+ }
1191
+
1192
+ private mapConfirmedIds(
1193
+ entity: ExecutionEntity,
1194
+ persistedId: string | undefined,
1195
+ nestedIds: ReadonlyMap<string, string>,
1196
+ ): void {
1197
+ for (const [key, id] of nestedIds) {
1198
+ const nested = splitExecutionKey(key)
1199
+ if (isTempId(nested.entityId)) {
1200
+ this.store.mapTempIdToPersistedId(nested.entityType, nested.entityId, id)
886
1201
  }
887
1202
  }
1203
+ if (entity.operation === 'create' && isTempId(entity.entityId) && persistedId) {
1204
+ this.store.mapTempIdToPersistedId(entity.entityType, entity.entityId, persistedId)
1205
+ }
1206
+ }
1207
+
1208
+ private persistedIdFor(entity: ExecutionEntity, persistedId: string | undefined): string | undefined {
1209
+ if (entity.operation !== 'create') return undefined
1210
+ return persistedId ?? (!isTempId(entity.entityId) ? entity.entityId : undefined)
1211
+ }
1212
+
1213
+ private rollbackExecutionEntity(entity: ExecutionEntity): void {
1214
+ this.rollbackEntity({
1215
+ entityType: entity.entityType,
1216
+ entityId: entity.entityId,
1217
+ changeType: entity.operation,
1218
+ dirtyFields: [],
1219
+ dirtyRelations: [],
1220
+ })
888
1221
  }
889
1222
 
890
1223
  /**
@@ -976,193 +1309,138 @@ export class BatchPersister {
976
1309
  }
977
1310
  }
978
1311
 
979
- /**
980
- * Recursively commits nested entity results and maps their server IDs.
981
- * Called when the adapter provides nestedResults in the transaction response.
982
- * Uses the MutationCollector's nestedEntityTypes map to resolve entity types,
983
- * since the adapter may not know the correct entity type for nested entities.
984
- */
985
- private commitNestedResults(nestedResults: readonly TransactionMutationResult[]): void {
986
- const nestedTypes = this.mutationCollector instanceof MutationCollector
987
- ? this.mutationCollector.getNestedEntityTypes()
988
- : null
989
-
990
- for (const nested of nestedResults) {
991
- if (!nested.ok) continue
992
-
993
- // Resolve entity type from collector's tracking (adapter may report 'Unknown')
994
- const entityType = nestedTypes?.get(nested.entityId) ?? nested.entityType
995
- const snapshot = this.store.getEntitySnapshot(entityType, nested.entityId)
996
- if (!snapshot) continue
997
-
998
- // Commit the nested entity
999
- this.dispatcher.dispatch(commitEntity(entityType, nested.entityId))
1000
- this.store.commitAllRelations(entityType, nested.entityId)
1001
- this.store.setExistsOnServer(entityType, nested.entityId, true)
1002
-
1003
- // Map temp ID to server-assigned ID
1004
- if (nested.persistedId) {
1005
- this.store.mapTempIdToPersistedId(
1006
- entityType,
1007
- nested.entityId,
1008
- nested.persistedId,
1009
- )
1010
- }
1011
-
1012
- // Recurse for deeper nesting
1013
- if (nested.nestedResults) {
1014
- this.commitNestedResults(nested.nestedResults)
1015
- }
1016
- }
1017
- }
1018
-
1019
- /**
1020
- * Commits nested entities that were part of the transaction but don't have
1021
- * explicit results from the adapter. These entities were created inline
1022
- * inside a parent mutation and exist on the server after a successful persist.
1023
- */
1024
- private commitUnresolvedNestedEntities(entities: DirtyEntity[]): void {
1025
- if (!(this.mutationCollector instanceof MutationCollector)) return
1026
-
1027
- const nestedTypes = this.mutationCollector.getNestedEntityTypes()
1028
- if (nestedTypes.size === 0) return
1029
-
1030
- // Find nested entities that are in the entities list but weren't committed
1031
- // by the main result processing (they had no matching result from the adapter)
1032
- for (const entity of entities) {
1033
- if (entity.changeType !== 'create') continue
1034
- if (!nestedTypes.has(entity.entityId)) continue
1035
-
1036
- // Check if this entity was already committed (has server data)
1037
- if (this.store.existsOnServer(entity.entityType, entity.entityId)) continue
1038
-
1039
- // Commit it — the parent mutation succeeded, so this entity exists on the server
1040
- this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId))
1041
- this.store.commitAllRelations(entity.entityType, entity.entityId)
1042
- this.store.setExistsOnServer(entity.entityType, entity.entityId, true)
1043
- }
1044
-
1045
- // Also commit materialized entities that may not be in the entities list
1046
- // (they were created during mutation building by materializeEmbeddedHasMany/HasOne)
1047
- for (const [tempId, entityType] of nestedTypes) {
1048
- if (this.store.existsOnServer(entityType, tempId)) continue
1049
-
1050
- const snapshot = this.store.getEntitySnapshot(entityType, tempId)
1051
- if (!snapshot) continue
1052
-
1053
- this.dispatcher.dispatch(commitEntity(entityType, tempId))
1054
- this.store.commitAllRelations(entityType, tempId)
1055
- this.store.setExistsOnServer(entityType, tempId, true)
1056
- }
1057
- }
1058
-
1059
- /**
1060
- * Extracts nested entity results from a mutation's node response data.
1061
- * Walks the mutation data structure to find inline create operations,
1062
- * then matches them against the node response to extract server-assigned IDs.
1063
- *
1064
- * For hasOne creates: looks up the temp ID from the store's relation state
1065
- * for the parent entity, then gets the server ID from the response node field.
1066
- * For hasMany creates: filters new IDs from the response array by excluding
1067
- * known pre-existing IDs, matching to temp IDs (aliases) by position.
1068
- */
1312
+ /** Resolves nested create responses from the immutable planning descriptors. */
1069
1313
  private extractNestedResultsFromNode(
1070
- mutationData: Record<string, unknown>,
1314
+ execution: PersistExecution,
1071
1315
  nodeData: Record<string, unknown>,
1072
1316
  parentEntityType: string,
1073
1317
  parentEntityId: string,
1074
1318
  ): TransactionMutationResult[] {
1075
1319
  const results: TransactionMutationResult[] = []
1076
- const nestedTypes = this.mutationCollector instanceof MutationCollector
1077
- ? this.mutationCollector.getNestedEntityTypes()
1078
- : null
1079
-
1080
1320
  const makeResult = (
1081
- tempId: string,
1082
- createData: Record<string, unknown>,
1321
+ op: NodeCreateOp,
1083
1322
  nodeItem: Record<string, unknown>,
1084
1323
  ): TransactionMutationResult => {
1085
1324
  const childResults = this.extractNestedResultsFromNode(
1086
- createData, nodeItem,
1087
- nestedTypes?.get(tempId) ?? 'Unknown', tempId,
1325
+ execution,
1326
+ nodeItem,
1327
+ op.entityType,
1328
+ op.alias,
1088
1329
  )
1089
1330
  return {
1090
- entityType: nestedTypes?.get(tempId) ?? 'Unknown',
1091
- entityId: tempId,
1331
+ entityType: op.entityType,
1332
+ entityId: op.alias,
1092
1333
  ok: true,
1093
- persistedId: nodeItem['id'] as string,
1334
+ persistedId: getStringProperty(nodeItem, 'id'),
1094
1335
  nestedResults: childResults.length > 0 ? childResults : undefined,
1095
1336
  }
1096
1337
  }
1097
-
1098
- for (const [fieldName, fieldValue] of Object.entries(mutationData)) {
1099
- if (fieldValue === null || fieldValue === undefined) continue
1100
-
1101
- if (Array.isArray(fieldValue)) {
1102
- const nodeItems = nodeData[fieldName]
1103
- if (!Array.isArray(nodeItems)) continue
1104
-
1105
- // Separate create ops from known IDs (connect/update)
1106
- const knownIds = new Set<string>()
1107
- const createOps: Array<{ alias: string; createData: Record<string, unknown> }> = []
1108
-
1109
- for (const op of fieldValue) {
1110
- if (typeof op !== 'object' || op === null) continue
1111
- const opObj = op as Record<string, unknown>
1112
-
1113
- if ('connect' in opObj) {
1114
- const connectBy = opObj['connect'] as Record<string, unknown>
1115
- if (connectBy['id']) knownIds.add(connectBy['id'] as string)
1116
- } else if ('update' in opObj) {
1117
- const by = (opObj['update'] as Record<string, unknown>)['by'] as Record<string, unknown> | undefined
1118
- if (by?.['id']) knownIds.add(by['id'] as string)
1119
- } else if ('create' in opObj && opObj['alias']) {
1120
- createOps.push({
1121
- alias: opObj['alias'] as string,
1122
- createData: opObj['create'] as Record<string, unknown>,
1123
- })
1124
- }
1125
- }
1126
-
1127
- // Include pre-existing server IDs
1128
- const hasManyState = this.store.getHasMany(parentEntityType, parentEntityId, fieldName)
1129
- if (hasManyState) {
1130
- for (const id of hasManyState.serverIds) knownIds.add(id)
1338
+ const descriptors = execution.nestedCreates.filter(descriptor => (
1339
+ descriptor.parentEntityType === parentEntityType && descriptor.parentEntityId === parentEntityId
1340
+ ))
1341
+ for (const descriptor of descriptors.filter(item => item.relationType === 'hasOne')) {
1342
+ const nodeItem = nodeData[descriptor.fieldName]
1343
+ if (!isRecord(nodeItem)) continue
1344
+ results.push(makeResult({
1345
+ alias: descriptor.entityId,
1346
+ entityType: descriptor.entityType,
1347
+ createData: { ...descriptor.createData },
1348
+ }, nodeItem))
1349
+ }
1350
+ const hasManyFields = new Set(
1351
+ descriptors.filter(item => item.relationType === 'hasMany').map(item => item.fieldName),
1352
+ )
1353
+ for (const fieldName of hasManyFields) {
1354
+ const fieldDescriptors = descriptors.filter(item => item.relationType === 'hasMany' && item.fieldName === fieldName)
1355
+ const knownIds = new Set(fieldDescriptors.flatMap(item => item.knownServerIds))
1356
+ const nodeItems = recordArray(nodeData[fieldName]).filter(item => {
1357
+ const id = getStringProperty(item, 'id')
1358
+ return id === undefined || !knownIds.has(id)
1359
+ })
1360
+ const createOps = fieldDescriptors.map(descriptor => ({
1361
+ alias: descriptor.entityId,
1362
+ entityType: descriptor.entityType,
1363
+ createData: { ...descriptor.createData },
1364
+ }))
1365
+ for (const { op, nodeItem } of this.pairCreateOpsWithNodes(createOps, nodeItems)) {
1366
+ results.push(makeResult(op, nodeItem))
1367
+ }
1368
+ }
1369
+ const updates = execution.nestedUpdates.filter(update => (
1370
+ update.parentEntityType === parentEntityType && update.parentEntityId === parentEntityId
1371
+ ))
1372
+ for (const update of updates) {
1373
+ if (update.relationType === 'hasOne') {
1374
+ const nodeItem = nodeData[update.fieldName]
1375
+ if (isRecord(nodeItem)) {
1376
+ results.push(...this.extractNestedResultsFromNode(
1377
+ execution,
1378
+ nodeItem,
1379
+ update.entityType,
1380
+ update.entityId,
1381
+ ))
1131
1382
  }
1383
+ continue
1384
+ }
1385
+ const nodeItem = recordArray(nodeData[update.fieldName]).find(item => (
1386
+ getStringProperty(item, 'id') === update.entityId
1387
+ ))
1388
+ if (nodeItem) {
1389
+ results.push(...this.extractNestedResultsFromNode(
1390
+ execution,
1391
+ nodeItem,
1392
+ update.entityType,
1393
+ update.entityId,
1394
+ ))
1395
+ }
1396
+ }
1132
1397
 
1133
- // Filter response to new items only, then content-match to create ops.
1134
- // Contember API does not guarantee hasMany ordering in node response,
1135
- // so we match by scalar field values instead of position.
1136
- // Unmatched creates keep their temp IDs until the next fetch.
1137
- const unmatchedItems = (nodeItems as Record<string, unknown>[])
1138
- .filter(it => typeof it === 'object' && it !== null && !knownIds.has(it['id'] as string))
1139
-
1140
- for (const createOp of createOps) {
1141
- const idx = unmatchedItems.findIndex(it => this.isCreateDataMatchingNode(createOp.createData, it))
1142
- if (idx < 0) continue
1143
- results.push(makeResult(createOp.alias, createOp.createData, unmatchedItems.splice(idx, 1)[0]!))
1144
- }
1145
- } else if (typeof fieldValue === 'object') {
1146
- const opObj = fieldValue as Record<string, unknown>
1147
- const nodeField = nodeData[fieldName]
1398
+ return results
1399
+ }
1148
1400
 
1149
- if ('create' in opObj && typeof nodeField === 'object' && nodeField !== null) {
1150
- const serverId = (nodeField as Record<string, unknown>)['id'] as string | undefined
1151
- if (!serverId) continue
1401
+ /**
1402
+ * Pairs inline create operations with the response rows they produced.
1403
+ *
1404
+ * An unambiguous match is taken first. Indistinguishable siblings remain unresolved
1405
+ * instead of being paired by response position.
1406
+ *
1407
+ * A single op and row left over are paired by elimination. A server that echoes a
1408
+ * value back normalised (a date, a decimal) matches nothing byte-for-byte, and
1409
+ * discarding the pair would leave that op's whole subtree on temp IDs (issue #70).
1410
+ */
1411
+ private pairCreateOpsWithNodes(
1412
+ createOps: readonly NodeCreateOp[],
1413
+ nodeItems: readonly Record<string, unknown>[],
1414
+ ): NodeCreatePair[] {
1415
+ const pairs: NodeCreatePair[] = []
1416
+ const pendingOps = [...createOps]
1417
+ const freeItems = [...nodeItems]
1418
+
1419
+ const takePair = (opIndex: number, nodeItem: Record<string, unknown>): void => {
1420
+ pairs.push({ op: pendingOps[opIndex]!, nodeItem })
1421
+ pendingOps.splice(opIndex, 1)
1422
+ freeItems.splice(freeItems.indexOf(nodeItem), 1)
1423
+ }
1152
1424
 
1153
- const relationState = this.store.getRelation(parentEntityType, parentEntityId, fieldName)
1154
- if (!relationState?.currentId) continue
1425
+ while (pendingOps.length > 0 && freeItems.length > 0) {
1426
+ const candidatesPerOp = pendingOps.map(
1427
+ op => freeItems.filter(item => this.isCreateDataMatchingNode(op.createData, item)),
1428
+ )
1155
1429
 
1156
- results.push(makeResult(
1157
- relationState.currentId,
1158
- opObj['create'] as Record<string, unknown>,
1159
- nodeField as Record<string, unknown>,
1160
- ))
1161
- }
1430
+ const unique = candidatesPerOp.findIndex(candidates => candidates.length === 1)
1431
+ if (unique >= 0) {
1432
+ takePair(unique, candidatesPerOp[unique]![0]!)
1433
+ continue
1162
1434
  }
1435
+
1436
+ break
1163
1437
  }
1164
1438
 
1165
- return results
1439
+ if (pendingOps.length === 1 && freeItems.length === 1) {
1440
+ takePair(0, freeItems[0]!)
1441
+ }
1442
+
1443
+ return pairs
1166
1444
  }
1167
1445
 
1168
1446
  /**
@@ -1201,3 +1479,89 @@ export class BatchPersister {
1201
1479
  this.changeRegistry.clearAllInFlight()
1202
1480
  }
1203
1481
  }
1482
+
1483
+ /**
1484
+ * Normalizes a thrown value into an Error for `entity:persistFailed`.
1485
+ */
1486
+ function toError(error: unknown): Error {
1487
+ return error instanceof Error ? error : new Error(String(error))
1488
+ }
1489
+
1490
+ function emptyPersistenceResult(): PersistenceResult {
1491
+ return {
1492
+ success: true,
1493
+ results: [],
1494
+ successCount: 0,
1495
+ failedCount: 0,
1496
+ skippedCount: 0,
1497
+ }
1498
+ }
1499
+
1500
+ function toSuccessResult(entity: ExecutionEntity, persistedId?: string): EntityPersistResult {
1501
+ return {
1502
+ entityType: entity.entityType,
1503
+ entityId: entity.entityId,
1504
+ operation: entity.operation,
1505
+ success: true,
1506
+ persistedId,
1507
+ }
1508
+ }
1509
+
1510
+ function toFailedResult(
1511
+ entity: ExecutionEntity,
1512
+ message: string,
1513
+ mutationResult?: ContemberMutationResult,
1514
+ ): EntityPersistResult {
1515
+ return {
1516
+ entityType: entity.entityType,
1517
+ entityId: entity.entityId,
1518
+ operation: entity.operation,
1519
+ success: false,
1520
+ error: { message, mutationResult },
1521
+ }
1522
+ }
1523
+
1524
+ function relationConflictMessage(entityType: string, entityId: string, fieldName: string): string {
1525
+ return `Persisted relation ${entityType}:${entityId}.${fieldName} conflicts with a newer local change`
1526
+ }
1527
+
1528
+ function isRecord(value: unknown): value is Record<string, unknown> {
1529
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1530
+ }
1531
+
1532
+ function getStringProperty(value: unknown, property: string): string | undefined {
1533
+ if (!isRecord(value)) return undefined
1534
+ const propertyValue = value[property]
1535
+ return typeof propertyValue === 'string' ? propertyValue : undefined
1536
+ }
1537
+
1538
+ function recordArray(value: unknown): Record<string, unknown>[] {
1539
+ if (!Array.isArray(value)) return []
1540
+ return value.filter(isRecord)
1541
+ }
1542
+
1543
+ function containsNestedMutation(value: unknown): boolean {
1544
+ if (Array.isArray(value)) return value.some(containsNestedMutation)
1545
+ if (!isRecord(value)) return false
1546
+ if ('create' in value || 'connect' in value || 'disconnect' in value || 'delete' in value || 'update' in value) {
1547
+ return true
1548
+ }
1549
+ return Object.values(value).some(containsNestedMutation)
1550
+ }
1551
+
1552
+ function flattenMutationResults(results: readonly TransactionMutationResult[]): TransactionMutationResult[] {
1553
+ const flattened: TransactionMutationResult[] = []
1554
+ for (const result of results) {
1555
+ flattened.push(result)
1556
+ if (result.nestedResults) flattened.push(...flattenMutationResults(result.nestedResults))
1557
+ }
1558
+ return flattened
1559
+ }
1560
+
1561
+ function splitExecutionKey(key: string): { entityType: string; entityId: string } {
1562
+ const separator = key.indexOf(':')
1563
+ return {
1564
+ entityType: separator < 0 ? '' : key.slice(0, separator),
1565
+ entityId: separator < 0 ? key : key.slice(separator + 1),
1566
+ }
1567
+ }