@contember/bindx 0.1.46 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +120 -16
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- 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,
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
this.
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
result = this.
|
|
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
|
-
|
|
264
|
-
this.
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
384
|
+
return { accepted, cancelled }
|
|
385
|
+
}
|
|
273
386
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
)
|
|
405
|
-
|
|
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 =
|
|
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 =
|
|
433
|
-
?
|
|
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 (
|
|
450
|
-
const
|
|
451
|
-
if (
|
|
452
|
-
return mutations.filter(m => !(m.operation === 'create' &&
|
|
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
|
-
|
|
752
|
+
execution: PersistExecution,
|
|
565
753
|
signal?: AbortSignal,
|
|
566
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
673
|
-
|
|
674
|
-
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
|
-
|
|
681
|
-
let failedCount = 0
|
|
880
|
+
const nestedResults: EntityPersistResult[] = []
|
|
682
881
|
const rollbackOnError = options?.rollbackOnError ?? false
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1082
|
-
createData: Record<string, unknown>,
|
|
1321
|
+
op: NodeCreateOp,
|
|
1083
1322
|
nodeItem: Record<string, unknown>,
|
|
1084
1323
|
): TransactionMutationResult => {
|
|
1085
1324
|
const childResults = this.extractNestedResultsFromNode(
|
|
1086
|
-
|
|
1087
|
-
|
|
1325
|
+
execution,
|
|
1326
|
+
nodeItem,
|
|
1327
|
+
op.entityType,
|
|
1328
|
+
op.alias,
|
|
1088
1329
|
)
|
|
1089
1330
|
return {
|
|
1090
|
-
entityType:
|
|
1091
|
-
entityId:
|
|
1331
|
+
entityType: op.entityType,
|
|
1332
|
+
entityId: op.alias,
|
|
1092
1333
|
ok: true,
|
|
1093
|
-
persistedId: nodeItem
|
|
1334
|
+
persistedId: getStringProperty(nodeItem, 'id'),
|
|
1094
1335
|
nestedResults: childResults.length > 0 ? childResults : undefined,
|
|
1095
1336
|
}
|
|
1096
1337
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
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
|
+
}
|