@contember/bindx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/ContemberAdapter.d.ts +40 -0
- package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
- package/dist/adapter/MockAdapter.d.ts +81 -0
- package/dist/adapter/MockAdapter.d.ts.map +1 -0
- package/dist/adapter/MockQueryEngine.d.ts +43 -0
- package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +5 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/types.d.ts +184 -0
- package/dist/adapter/types.d.ts.map +1 -0
- package/dist/brand/ComponentBrand.d.ts +25 -0
- package/dist/brand/ComponentBrand.d.ts.map +1 -0
- package/dist/contember/index.d.ts +10 -0
- package/dist/contember/index.d.ts.map +1 -0
- package/dist/core/ActionDispatcher.d.ts +64 -0
- package/dist/core/ActionDispatcher.d.ts.map +1 -0
- package/dist/core/Disposable.d.ts +57 -0
- package/dist/core/Disposable.d.ts.map +1 -0
- package/dist/core/EntityLoader.d.ts +80 -0
- package/dist/core/EntityLoader.d.ts.map +1 -0
- package/dist/core/PersistenceManager.d.ts +126 -0
- package/dist/core/PersistenceManager.d.ts.map +1 -0
- package/dist/core/SelectionResolver.d.ts +21 -0
- package/dist/core/SelectionResolver.d.ts.map +1 -0
- package/dist/core/actionClassification.d.ts +48 -0
- package/dist/core/actionClassification.d.ts.map +1 -0
- package/dist/core/actions.d.ts +301 -0
- package/dist/core/actions.d.ts.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/dataview/filterHandlers.d.ts +18 -0
- package/dist/dataview/filterHandlers.d.ts.map +1 -0
- package/dist/dataview/index.d.ts +3 -0
- package/dist/dataview/index.d.ts.map +1 -0
- package/dist/dataview/types.d.ts +164 -0
- package/dist/dataview/types.d.ts.map +1 -0
- package/dist/errors/UnfetchedFieldError.d.ts +11 -0
- package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
- package/dist/errors/errorPathResolver.d.ts +52 -0
- package/dist/errors/errorPathResolver.d.ts.map +1 -0
- package/dist/errors/index.d.ts +5 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/pathMapper.d.ts +42 -0
- package/dist/errors/pathMapper.d.ts.map +1 -0
- package/dist/errors/types.d.ts +131 -0
- package/dist/errors/types.d.ts.map +1 -0
- package/dist/events/EventEmitter.d.ts +97 -0
- package/dist/events/EventEmitter.d.ts.map +1 -0
- package/dist/events/eventFactory.d.ts +40 -0
- package/dist/events/eventFactory.d.ts.map +1 -0
- package/dist/events/index.d.ts +15 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/types.d.ts +264 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/handles/BaseHandle.d.ts +64 -0
- package/dist/handles/BaseHandle.d.ts.map +1 -0
- package/dist/handles/EntityHandle.d.ts +208 -0
- package/dist/handles/EntityHandle.d.ts.map +1 -0
- package/dist/handles/FieldHandle.d.ts +111 -0
- package/dist/handles/FieldHandle.d.ts.map +1 -0
- package/dist/handles/HasManyListHandle.d.ts +166 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -0
- package/dist/handles/HasOneHandle.d.ts +168 -0
- package/dist/handles/HasOneHandle.d.ts.map +1 -0
- package/dist/handles/PlaceholderHandle.d.ts +117 -0
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
- package/dist/handles/index.d.ts +8 -0
- package/dist/handles/index.d.ts.map +1 -0
- package/dist/handles/proxyFactory.d.ts +66 -0
- package/dist/handles/proxyFactory.d.ts.map +1 -0
- package/dist/handles/types.d.ts +430 -0
- package/dist/handles/types.d.ts.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/persistence/BatchPersister.d.ts +142 -0
- package/dist/persistence/BatchPersister.d.ts.map +1 -0
- package/dist/persistence/ChangeRegistry.d.ts +87 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
- package/dist/persistence/MutationCollector.d.ts +96 -0
- package/dist/persistence/MutationCollector.d.ts.map +1 -0
- package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
- package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +189 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
- package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
- package/dist/roles/index.d.ts +11 -0
- package/dist/roles/index.d.ts.map +1 -0
- package/dist/roles/types.d.ts +242 -0
- package/dist/roles/types.d.ts.map +1 -0
- package/dist/schema/ContemberSchema.d.ts +98 -0
- package/dist/schema/ContemberSchema.d.ts.map +1 -0
- package/dist/schema/SchemaLoader.d.ts +28 -0
- package/dist/schema/SchemaLoader.d.ts.map +1 -0
- package/dist/schema/SchemaRegistry.d.ts +95 -0
- package/dist/schema/SchemaRegistry.d.ts.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/roles.d.ts +62 -0
- package/dist/schema/roles.d.ts.map +1 -0
- package/dist/schema/types.d.ts +179 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/selection/SelectionMetaCollector.d.ts +32 -0
- package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
- package/dist/selection/SelectionScope.d.ts +77 -0
- package/dist/selection/SelectionScope.d.ts.map +1 -0
- package/dist/selection/buildQuery.d.ts +45 -0
- package/dist/selection/buildQuery.d.ts.map +1 -0
- package/dist/selection/createFragment.d.ts +29 -0
- package/dist/selection/createFragment.d.ts.map +1 -0
- package/dist/selection/createSelectionBuilder.d.ts +18 -0
- package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
- package/dist/selection/index.d.ts +8 -0
- package/dist/selection/index.d.ts.map +1 -0
- package/dist/selection/queryTypes.d.ts +84 -0
- package/dist/selection/queryTypes.d.ts.map +1 -0
- package/dist/selection/types.d.ts +238 -0
- package/dist/selection/types.d.ts.map +1 -0
- package/dist/store/DirtyTracker.d.ts +26 -0
- package/dist/store/DirtyTracker.d.ts.map +1 -0
- package/dist/store/EntityMetaStore.d.ts +52 -0
- package/dist/store/EntityMetaStore.d.ts.map +1 -0
- package/dist/store/EntitySnapshotStore.d.ts +63 -0
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
- package/dist/store/ErrorStore.d.ts +82 -0
- package/dist/store/ErrorStore.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +175 -0
- package/dist/store/RelationStore.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +143 -0
- package/dist/store/SnapshotStore.d.ts.map +1 -0
- package/dist/store/SubscriptionManager.d.ts +90 -0
- package/dist/store/SubscriptionManager.d.ts.map +1 -0
- package/dist/store/TouchedStore.d.ts +24 -0
- package/dist/store/TouchedStore.d.ts.map +1 -0
- package/dist/store/entityId.d.ts +29 -0
- package/dist/store/entityId.d.ts.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/snapshots.d.ts +144 -0
- package/dist/store/snapshots.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +120 -0
- package/dist/undo/UndoManager.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -0
- package/dist/undo/index.d.ts.map +1 -0
- package/dist/undo/types.d.ts +74 -0
- package/dist/undo/types.d.ts.map +1 -0
- package/dist/utils/aliasGenerator.d.ts +25 -0
- package/dist/utils/aliasGenerator.d.ts.map +1 -0
- package/dist/utils/deepEqual.d.ts +6 -0
- package/dist/utils/deepEqual.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/package.json +29 -0
- package/src/adapter/ContemberAdapter.ts +239 -0
- package/src/adapter/MockAdapter.ts +394 -0
- package/src/adapter/MockQueryEngine.ts +236 -0
- package/src/adapter/index.ts +21 -0
- package/src/adapter/types.ts +199 -0
- package/src/brand/ComponentBrand.ts +28 -0
- package/src/contember/index.ts +17 -0
- package/src/core/ActionDispatcher.ts +450 -0
- package/src/core/Disposable.ts +126 -0
- package/src/core/EntityLoader.ts +169 -0
- package/src/core/SelectionResolver.ts +78 -0
- package/src/core/actionClassification.ts +196 -0
- package/src/core/actions.ts +491 -0
- package/src/core/index.ts +20 -0
- package/src/dataview/filterHandlers.ts +443 -0
- package/src/dataview/index.ts +36 -0
- package/src/dataview/types.ts +220 -0
- package/src/errors/UnfetchedFieldError.ts +18 -0
- package/src/errors/errorPathResolver.ts +239 -0
- package/src/errors/index.ts +40 -0
- package/src/errors/pathMapper.ts +40 -0
- package/src/errors/types.ts +223 -0
- package/src/events/EventEmitter.ts +447 -0
- package/src/events/eventFactory.ts +430 -0
- package/src/events/index.ts +89 -0
- package/src/events/types.ts +374 -0
- package/src/handles/BaseHandle.ts +100 -0
- package/src/handles/EntityHandle.ts +538 -0
- package/src/handles/FieldHandle.ts +268 -0
- package/src/handles/HasManyListHandle.ts +500 -0
- package/src/handles/HasOneHandle.ts +500 -0
- package/src/handles/PlaceholderHandle.ts +321 -0
- package/src/handles/index.ts +37 -0
- package/src/handles/proxyFactory.ts +218 -0
- package/src/handles/types.ts +743 -0
- package/src/index.ts +384 -0
- package/src/internal.ts +19 -0
- package/src/persistence/BatchPersister.ts +911 -0
- package/src/persistence/ChangeRegistry.ts +150 -0
- package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
- package/src/persistence/MutationCollector.ts +567 -0
- package/src/persistence/MutationSchemaProvider.ts +33 -0
- package/src/persistence/index.ts +23 -0
- package/src/persistence/types.ts +220 -0
- package/src/schema/ContemberSchema.ts +132 -0
- package/src/schema/SchemaLoader.ts +93 -0
- package/src/schema/SchemaRegistry.ts +286 -0
- package/src/schema/index.ts +51 -0
- package/src/schema/roles.ts +83 -0
- package/src/schema/types.ts +238 -0
- package/src/selection/SelectionMetaCollector.ts +122 -0
- package/src/selection/SelectionScope.ts +208 -0
- package/src/selection/buildQuery.ts +134 -0
- package/src/selection/createFragment.ts +50 -0
- package/src/selection/createSelectionBuilder.ts +283 -0
- package/src/selection/index.ts +31 -0
- package/src/selection/queryTypes.ts +135 -0
- package/src/selection/types.ts +320 -0
- package/src/store/DirtyTracker.ts +117 -0
- package/src/store/EntityMetaStore.ts +143 -0
- package/src/store/EntitySnapshotStore.ts +260 -0
- package/src/store/ErrorStore.ts +251 -0
- package/src/store/RelationStore.ts +696 -0
- package/src/store/SnapshotStore.ts +765 -0
- package/src/store/SubscriptionManager.ts +241 -0
- package/src/store/TouchedStore.ts +42 -0
- package/src/store/entityId.ts +43 -0
- package/src/store/index.ts +2 -0
- package/src/store/snapshots.ts +203 -0
- package/src/undo/UndoManager.ts +418 -0
- package/src/undo/index.ts +9 -0
- package/src/undo/types.ts +84 -0
- package/src/utils/aliasGenerator.ts +56 -0
- package/src/utils/deepEqual.ts +33 -0
- package/src/utils/index.ts +2 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import type { BackendAdapter } from '../adapter/types.js'
|
|
2
|
+
import type { ActionDispatcher } from '../core/ActionDispatcher.js'
|
|
3
|
+
import type { MutationDataCollector } from './types.js'
|
|
4
|
+
import type { SchemaRegistry } from '../schema/SchemaRegistry.js'
|
|
5
|
+
import type { SnapshotStore } from '../store/SnapshotStore.js'
|
|
6
|
+
import { isTempId } from '../store/entityId.js'
|
|
7
|
+
import type { UndoManager } from '../undo/UndoManager.js'
|
|
8
|
+
import { ChangeRegistry, type DirtyEntity } from './ChangeRegistry.js'
|
|
9
|
+
import type {
|
|
10
|
+
BatchPersistOptions,
|
|
11
|
+
EntityPersistResult,
|
|
12
|
+
PersistenceResult,
|
|
13
|
+
PersistScope,
|
|
14
|
+
TransactionMutation,
|
|
15
|
+
TransactionResult,
|
|
16
|
+
UpdateMode,
|
|
17
|
+
} from './types.js'
|
|
18
|
+
import { setPersisting, commitEntity, resetEntity, addFieldError, addEntityError, addRelationError, clearAllServerErrors } from '../core/actions.js'
|
|
19
|
+
import { type ContemberMutationResult } from '../errors/pathMapper.js'
|
|
20
|
+
import { resolveAllErrors } from '../errors/errorPathResolver.js'
|
|
21
|
+
import { createServerError } from '../errors/types.js'
|
|
22
|
+
import { MutationCollector } from './MutationCollector.js'
|
|
23
|
+
import type { EntitySnapshot } from '../store/snapshots.js'
|
|
24
|
+
import type { StoredHasManyState, StoredRelationState } from '../store/SnapshotStore.js'
|
|
25
|
+
import { deepEqual } from '../utils/deepEqual.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Captured state for pessimistic mode restoration.
|
|
29
|
+
* Contains all the data needed to restore an entity's dirty state after
|
|
30
|
+
* temporarily resetting it to server state during pessimistic persistence.
|
|
31
|
+
*/
|
|
32
|
+
interface CapturedEntityState {
|
|
33
|
+
entityType: string
|
|
34
|
+
entityId: string
|
|
35
|
+
snapshot: EntitySnapshot | undefined
|
|
36
|
+
relations: Map<string, StoredRelationState>
|
|
37
|
+
hasManyStates: Map<string, StoredHasManyState>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for BatchPersister
|
|
42
|
+
*/
|
|
43
|
+
export interface BatchPersisterOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Custom mutation collector for building complex mutation inputs.
|
|
46
|
+
*/
|
|
47
|
+
mutationCollector?: MutationDataCollector
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* UndoManager instance to block during persist operations.
|
|
51
|
+
*/
|
|
52
|
+
undoManager?: UndoManager
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Schema registry for mapping server errors to fields.
|
|
56
|
+
*/
|
|
57
|
+
schema?: SchemaRegistry
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Default update mode for all persist operations.
|
|
61
|
+
* Can be overridden per-operation via BatchPersistOptions.
|
|
62
|
+
* @default 'optimistic'
|
|
63
|
+
*/
|
|
64
|
+
defaultUpdateMode?: UpdateMode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* BatchPersister orchestrates multi-entity persistence with:
|
|
69
|
+
* - Deduplication (same entity referenced multiple times → single mutation)
|
|
70
|
+
* - Dependency ordering (new entities before entities referencing them)
|
|
71
|
+
* - Transactional execution (all-or-nothing via adapter.persistTransaction)
|
|
72
|
+
* - Field-level granularity (persist only specific fields)
|
|
73
|
+
*/
|
|
74
|
+
export class BatchPersister {
|
|
75
|
+
private readonly changeRegistry: ChangeRegistry
|
|
76
|
+
private readonly mutationCollector?: MutationDataCollector
|
|
77
|
+
private readonly undoManager?: UndoManager
|
|
78
|
+
private readonly schema?: SchemaRegistry
|
|
79
|
+
private readonly defaultUpdateMode: UpdateMode
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly adapter: BackendAdapter,
|
|
83
|
+
private readonly store: SnapshotStore,
|
|
84
|
+
private readonly dispatcher: ActionDispatcher,
|
|
85
|
+
options?: BatchPersisterOptions,
|
|
86
|
+
) {
|
|
87
|
+
this.changeRegistry = new ChangeRegistry(store)
|
|
88
|
+
this.mutationCollector = options?.mutationCollector
|
|
89
|
+
this.undoManager = options?.undoManager
|
|
90
|
+
this.schema = options?.schema
|
|
91
|
+
this.defaultUpdateMode = options?.defaultUpdateMode ?? 'optimistic'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Gets the default update mode for this persister.
|
|
96
|
+
*/
|
|
97
|
+
getDefaultUpdateMode(): UpdateMode {
|
|
98
|
+
return this.defaultUpdateMode
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Gets the change registry for external access.
|
|
103
|
+
*/
|
|
104
|
+
getChangeRegistry(): ChangeRegistry {
|
|
105
|
+
return this.changeRegistry
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Persists all dirty entities in a single transaction.
|
|
110
|
+
*/
|
|
111
|
+
async persistAll(options?: BatchPersistOptions): Promise<PersistenceResult> {
|
|
112
|
+
return this.persistScope({ type: 'all' }, options)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Persists a single entity.
|
|
117
|
+
*/
|
|
118
|
+
async persist(
|
|
119
|
+
entityType: string,
|
|
120
|
+
entityId: string,
|
|
121
|
+
options?: BatchPersistOptions,
|
|
122
|
+
): Promise<EntityPersistResult> {
|
|
123
|
+
const result = await this.persistScope(
|
|
124
|
+
{ type: 'entity', entityType, entityId },
|
|
125
|
+
options,
|
|
126
|
+
)
|
|
127
|
+
return result.results[0] ?? {
|
|
128
|
+
entityType,
|
|
129
|
+
entityId,
|
|
130
|
+
operation: 'update',
|
|
131
|
+
success: false,
|
|
132
|
+
error: { message: 'Entity not found or not dirty' },
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persists specific fields of an entity.
|
|
138
|
+
*/
|
|
139
|
+
async persistFields(
|
|
140
|
+
entityType: string,
|
|
141
|
+
entityId: string,
|
|
142
|
+
fields: readonly string[],
|
|
143
|
+
options?: BatchPersistOptions,
|
|
144
|
+
): Promise<EntityPersistResult> {
|
|
145
|
+
const result = await this.persistScope(
|
|
146
|
+
{ type: 'fields', entityType, entityId, fields },
|
|
147
|
+
options,
|
|
148
|
+
)
|
|
149
|
+
return result.results[0] ?? {
|
|
150
|
+
entityType,
|
|
151
|
+
entityId,
|
|
152
|
+
operation: 'update',
|
|
153
|
+
success: false,
|
|
154
|
+
error: { message: 'Entity not found or specified fields not dirty' },
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Persists entities matching the given scope.
|
|
160
|
+
*/
|
|
161
|
+
async persistScope(
|
|
162
|
+
scope: PersistScope,
|
|
163
|
+
options?: BatchPersistOptions,
|
|
164
|
+
): Promise<PersistenceResult> {
|
|
165
|
+
const skipInFlight = options?.skipInFlight ?? true
|
|
166
|
+
const updateMode = options?.updateMode ?? this.defaultUpdateMode
|
|
167
|
+
|
|
168
|
+
// Collect entities to persist based on scope
|
|
169
|
+
const entitiesToPersist = this.collectEntitiesForScope(scope, skipInFlight)
|
|
170
|
+
|
|
171
|
+
if (entitiesToPersist.length === 0) {
|
|
172
|
+
return {
|
|
173
|
+
success: true,
|
|
174
|
+
results: [],
|
|
175
|
+
successCount: 0,
|
|
176
|
+
failedCount: 0,
|
|
177
|
+
skippedCount: 0,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for client validation errors
|
|
182
|
+
for (const entity of entitiesToPersist) {
|
|
183
|
+
if (this.store.hasClientErrors(entity.entityType, entity.entityId)) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
results: [{
|
|
187
|
+
entityType: entity.entityType,
|
|
188
|
+
entityId: entity.entityId,
|
|
189
|
+
operation: entity.changeType,
|
|
190
|
+
success: false,
|
|
191
|
+
error: { message: `Entity ${entity.entityType}:${entity.entityId} has client validation errors` },
|
|
192
|
+
}],
|
|
193
|
+
successCount: 0,
|
|
194
|
+
failedCount: 1,
|
|
195
|
+
skippedCount: entitiesToPersist.length - 1,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Sort by dependencies (creates first)
|
|
201
|
+
const sortedEntities = this.sortByDependencies(entitiesToPersist)
|
|
202
|
+
|
|
203
|
+
// Mark all as in-flight
|
|
204
|
+
this.changeRegistry.markInFlight(sortedEntities)
|
|
205
|
+
|
|
206
|
+
// Set persisting state for all entities
|
|
207
|
+
for (const entity of sortedEntities) {
|
|
208
|
+
this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true))
|
|
209
|
+
this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Block undo during persist
|
|
213
|
+
this.undoManager?.block()
|
|
214
|
+
|
|
215
|
+
// For pessimistic mode, capture dirty state before building mutations
|
|
216
|
+
// This allows us to restore and commit the state after server confirmation
|
|
217
|
+
let capturedStates: CapturedEntityState[] | null = null
|
|
218
|
+
if (updateMode === 'pessimistic') {
|
|
219
|
+
capturedStates = this.captureEntityStates(sortedEntities)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Build mutations BEFORE resetting state (for pessimistic mode)
|
|
224
|
+
// The mutations need to contain the dirty data to send to server
|
|
225
|
+
const mutations = this.buildMutations(sortedEntities, scope)
|
|
226
|
+
|
|
227
|
+
if (mutations.length === 0) {
|
|
228
|
+
// Nothing to persist
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
results: [],
|
|
232
|
+
successCount: 0,
|
|
233
|
+
failedCount: 0,
|
|
234
|
+
skippedCount: 0,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// For pessimistic mode, reset entities to server state after capturing
|
|
239
|
+
// This makes the UI show the "old" (server) data while persisting
|
|
240
|
+
if (updateMode === 'pessimistic') {
|
|
241
|
+
for (const entity of sortedEntities) {
|
|
242
|
+
// Only reset updates, not creates (creates don't have server state)
|
|
243
|
+
if (entity.changeType === 'update') {
|
|
244
|
+
this.dispatcher.dispatch(resetEntity(entity.entityType, entity.entityId))
|
|
245
|
+
this.store.resetAllRelations(entity.entityType, entity.entityId)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Execute transaction
|
|
251
|
+
const transactionResult = await this.executeTransaction(mutations, options?.signal)
|
|
252
|
+
|
|
253
|
+
// Process results with captured state for pessimistic mode
|
|
254
|
+
return this.processTransactionResult(sortedEntities, transactionResult, scope, options, capturedStates)
|
|
255
|
+
|
|
256
|
+
} finally {
|
|
257
|
+
// Clear in-flight status
|
|
258
|
+
this.changeRegistry.clearInFlight(sortedEntities)
|
|
259
|
+
|
|
260
|
+
// Clear persisting state
|
|
261
|
+
for (const entity of sortedEntities) {
|
|
262
|
+
this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, false))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Unblock undo
|
|
266
|
+
this.undoManager?.unblock()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Collects entities to persist based on the scope.
|
|
272
|
+
*/
|
|
273
|
+
private collectEntitiesForScope(
|
|
274
|
+
scope: PersistScope,
|
|
275
|
+
skipInFlight: boolean,
|
|
276
|
+
): DirtyEntity[] {
|
|
277
|
+
let entities: DirtyEntity[]
|
|
278
|
+
|
|
279
|
+
switch (scope.type) {
|
|
280
|
+
case 'all':
|
|
281
|
+
entities = skipInFlight
|
|
282
|
+
? [...this.changeRegistry.getDirtyEntitiesNotInFlight()]
|
|
283
|
+
: [...this.changeRegistry.getDirtyEntities()]
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
case 'entity': {
|
|
287
|
+
const dirtyEntities = this.changeRegistry.getDirtyEntities()
|
|
288
|
+
const entity = dirtyEntities.find(
|
|
289
|
+
e => e.entityType === scope.entityType && e.entityId === scope.entityId,
|
|
290
|
+
)
|
|
291
|
+
entities = entity ? [entity] : []
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'fields': {
|
|
296
|
+
// For field scope, we need the entity even if only the specified fields are dirty
|
|
297
|
+
const snapshot = this.store.getEntitySnapshot(scope.entityType, scope.entityId)
|
|
298
|
+
if (!snapshot) {
|
|
299
|
+
entities = []
|
|
300
|
+
break
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const dirtyFields = this.store.getDirtyFields(scope.entityType, scope.entityId)
|
|
304
|
+
const relevantDirtyFields = scope.fields.filter(f => dirtyFields.includes(f))
|
|
305
|
+
|
|
306
|
+
if (relevantDirtyFields.length === 0) {
|
|
307
|
+
entities = []
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Determine change type
|
|
312
|
+
let changeType: 'create' | 'update' | 'delete' = 'update'
|
|
313
|
+
if (!this.store.existsOnServer(scope.entityType, scope.entityId)) {
|
|
314
|
+
changeType = 'create'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
entities = [{
|
|
318
|
+
entityType: scope.entityType,
|
|
319
|
+
entityId: scope.entityId,
|
|
320
|
+
changeType,
|
|
321
|
+
dirtyFields: relevantDirtyFields,
|
|
322
|
+
dirtyRelations: [],
|
|
323
|
+
}]
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'relation': {
|
|
328
|
+
const dirtyRelations = this.store.getDirtyRelations(scope.entityType, scope.entityId)
|
|
329
|
+
if (!dirtyRelations.includes(scope.relationName)) {
|
|
330
|
+
entities = []
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
entities = [{
|
|
335
|
+
entityType: scope.entityType,
|
|
336
|
+
entityId: scope.entityId,
|
|
337
|
+
changeType: 'update',
|
|
338
|
+
dirtyFields: [],
|
|
339
|
+
dirtyRelations: [scope.relationName],
|
|
340
|
+
}]
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case 'custom':
|
|
345
|
+
entities = scope.entities
|
|
346
|
+
.map(e => {
|
|
347
|
+
const dirtyEntities = this.changeRegistry.getDirtyEntities()
|
|
348
|
+
return dirtyEntities.find(
|
|
349
|
+
d => d.entityType === e.entityType && d.entityId === e.entityId,
|
|
350
|
+
)
|
|
351
|
+
})
|
|
352
|
+
.filter((e): e is DirtyEntity => e !== undefined)
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Filter out in-flight if requested
|
|
357
|
+
if (skipInFlight) {
|
|
358
|
+
entities = entities.filter(
|
|
359
|
+
e => !this.changeRegistry.isInFlight(e.entityType, e.entityId),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return entities
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Sorts entities by dependencies (creates before updates that reference them).
|
|
368
|
+
*/
|
|
369
|
+
private sortByDependencies(entities: DirtyEntity[]): DirtyEntity[] {
|
|
370
|
+
// Simple sort: creates first, then updates, then deletes
|
|
371
|
+
return [...entities].sort((a, b) => {
|
|
372
|
+
const order = { create: 0, update: 1, delete: 2 }
|
|
373
|
+
return order[a.changeType] - order[b.changeType]
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Builds mutations for the given entities.
|
|
379
|
+
*/
|
|
380
|
+
private buildMutations(
|
|
381
|
+
entities: DirtyEntity[],
|
|
382
|
+
scope: PersistScope,
|
|
383
|
+
): TransactionMutation[] {
|
|
384
|
+
// Tell MutationCollector which entities have their own top-level mutations
|
|
385
|
+
// so it skips generating nested updates for them
|
|
386
|
+
if (this.mutationCollector instanceof MutationCollector) {
|
|
387
|
+
const excludedIds = new Set(entities.map(e => e.entityId))
|
|
388
|
+
this.mutationCollector.setExcludedEntities(excludedIds)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const mutations: TransactionMutation[] = []
|
|
392
|
+
|
|
393
|
+
for (const entity of entities) {
|
|
394
|
+
let data: Record<string, unknown> | null = null
|
|
395
|
+
|
|
396
|
+
if (entity.changeType === 'delete') {
|
|
397
|
+
mutations.push({
|
|
398
|
+
entityType: entity.entityType,
|
|
399
|
+
entityId: entity.entityId,
|
|
400
|
+
operation: 'delete',
|
|
401
|
+
})
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Collect mutation data
|
|
406
|
+
if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) {
|
|
407
|
+
// Field-specific collection
|
|
408
|
+
data = this.collectFieldsData(entity.entityType, entity.entityId, scope.fields)
|
|
409
|
+
} else if (entity.changeType === 'create') {
|
|
410
|
+
data = this.mutationCollector?.collectCreateData?.(entity.entityType, entity.entityId)
|
|
411
|
+
?? this.collectCreateDataWithRelationCheck(entity)
|
|
412
|
+
} else {
|
|
413
|
+
data = this.mutationCollector?.collectUpdateData(entity.entityType, entity.entityId)
|
|
414
|
+
?? this.collectUpdateDataWithRelationCheck(entity)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (data && Object.keys(data).length > 0) {
|
|
418
|
+
mutations.push({
|
|
419
|
+
entityType: entity.entityType,
|
|
420
|
+
entityId: entity.entityId,
|
|
421
|
+
operation: entity.changeType,
|
|
422
|
+
data,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return mutations
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Collects create data, throwing if dirty relations exist without a MutationCollector.
|
|
432
|
+
*/
|
|
433
|
+
private collectCreateDataWithRelationCheck(entity: DirtyEntity): Record<string, unknown> | null {
|
|
434
|
+
this.assertNoRelationChanges(entity)
|
|
435
|
+
return this.collectCreateData(entity.entityType, entity.entityId)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Collects update data, throwing if dirty relations exist without a MutationCollector.
|
|
440
|
+
*/
|
|
441
|
+
private collectUpdateDataWithRelationCheck(entity: DirtyEntity): Record<string, unknown> | null {
|
|
442
|
+
this.assertNoRelationChanges(entity)
|
|
443
|
+
return this.collectUpdateData(entity.entityType, entity.entityId)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Throws if the entity has dirty relations but no MutationCollector is configured.
|
|
448
|
+
*/
|
|
449
|
+
private assertNoRelationChanges(entity: DirtyEntity): void {
|
|
450
|
+
if (entity.dirtyRelations.length > 0) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`Entity ${entity.entityType}:${entity.entityId} has dirty relations (${entity.dirtyRelations.join(', ')}), ` +
|
|
453
|
+
`but no MutationCollector is configured. Relation changes would be silently lost. ` +
|
|
454
|
+
`Provide a 'schema' or 'mutationCollector' option to enable relation persistence.`,
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Collects data for specific fields only.
|
|
461
|
+
*/
|
|
462
|
+
private collectFieldsData(
|
|
463
|
+
entityType: string,
|
|
464
|
+
entityId: string,
|
|
465
|
+
fields: readonly string[],
|
|
466
|
+
): Record<string, unknown> | null {
|
|
467
|
+
const snapshot = this.store.getEntitySnapshot(entityType, entityId)
|
|
468
|
+
if (!snapshot) return null
|
|
469
|
+
|
|
470
|
+
const data = snapshot.data as Record<string, unknown>
|
|
471
|
+
const result: Record<string, unknown> = {}
|
|
472
|
+
|
|
473
|
+
for (const field of fields) {
|
|
474
|
+
if (field in data) {
|
|
475
|
+
result[field] = data[field]
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Object.keys(result).length > 0 ? result : null
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Simple update data collection (field diff).
|
|
484
|
+
*/
|
|
485
|
+
private collectUpdateData(
|
|
486
|
+
entityType: string,
|
|
487
|
+
entityId: string,
|
|
488
|
+
): Record<string, unknown> | null {
|
|
489
|
+
const snapshot = this.store.getEntitySnapshot(entityType, entityId)
|
|
490
|
+
if (!snapshot) return null
|
|
491
|
+
|
|
492
|
+
const data = snapshot.data as Record<string, unknown>
|
|
493
|
+
const serverData = snapshot.serverData as Record<string, unknown>
|
|
494
|
+
const changes: Record<string, unknown> = {}
|
|
495
|
+
|
|
496
|
+
for (const [key, value] of Object.entries(data)) {
|
|
497
|
+
if (key === 'id') continue
|
|
498
|
+
if (!deepEqual(value, serverData[key])) {
|
|
499
|
+
changes[key] = value
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return Object.keys(changes).length > 0 ? changes : null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Simple create data collection.
|
|
508
|
+
*/
|
|
509
|
+
private collectCreateData(
|
|
510
|
+
entityType: string,
|
|
511
|
+
entityId: string,
|
|
512
|
+
): Record<string, unknown> | null {
|
|
513
|
+
const snapshot = this.store.getEntitySnapshot(entityType, entityId)
|
|
514
|
+
if (!snapshot) return null
|
|
515
|
+
|
|
516
|
+
const data = snapshot.data as Record<string, unknown>
|
|
517
|
+
const createData: Record<string, unknown> = {}
|
|
518
|
+
|
|
519
|
+
for (const [key, value] of Object.entries(data)) {
|
|
520
|
+
if (key === 'id' && typeof value === 'string' && isTempId(value)) {
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
if (value !== null && value !== undefined) {
|
|
524
|
+
createData[key] = value
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return Object.keys(createData).length > 0 ? createData : null
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Executes mutations as a transaction.
|
|
533
|
+
*/
|
|
534
|
+
private async executeTransaction(
|
|
535
|
+
mutations: TransactionMutation[],
|
|
536
|
+
signal?: AbortSignal,
|
|
537
|
+
): Promise<TransactionResult> {
|
|
538
|
+
// Check if adapter supports transactions
|
|
539
|
+
if ('persistTransaction' in this.adapter && typeof this.adapter.persistTransaction === 'function') {
|
|
540
|
+
try {
|
|
541
|
+
return await this.adapter.persistTransaction(mutations)
|
|
542
|
+
} catch (error) {
|
|
543
|
+
// Adapter threw an exception - mark all as failed
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
results: mutations.map(m => ({
|
|
547
|
+
entityType: m.entityType,
|
|
548
|
+
entityId: m.entityId,
|
|
549
|
+
ok: false,
|
|
550
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
551
|
+
})),
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Fallback: execute sequentially (not truly transactional)
|
|
557
|
+
const results: TransactionResult['results'][number][] = []
|
|
558
|
+
let allOk = true
|
|
559
|
+
|
|
560
|
+
for (const mutation of mutations) {
|
|
561
|
+
if (signal?.aborted) {
|
|
562
|
+
allOk = false
|
|
563
|
+
results.push({
|
|
564
|
+
entityType: mutation.entityType,
|
|
565
|
+
entityId: mutation.entityId,
|
|
566
|
+
ok: false,
|
|
567
|
+
errorMessage: 'Operation aborted',
|
|
568
|
+
})
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
if (mutation.operation === 'delete') {
|
|
574
|
+
if (this.adapter.delete) {
|
|
575
|
+
const result = await this.adapter.delete(mutation.entityType, mutation.entityId)
|
|
576
|
+
results.push({
|
|
577
|
+
entityType: mutation.entityType,
|
|
578
|
+
entityId: mutation.entityId,
|
|
579
|
+
ok: result.ok,
|
|
580
|
+
errorMessage: result.errorMessage,
|
|
581
|
+
mutationResult: result.mutationResult,
|
|
582
|
+
})
|
|
583
|
+
if (!result.ok) allOk = false
|
|
584
|
+
}
|
|
585
|
+
} else if (mutation.operation === 'create') {
|
|
586
|
+
if (this.adapter.create && mutation.data) {
|
|
587
|
+
const result = await this.adapter.create(mutation.entityType, mutation.data)
|
|
588
|
+
const persistedId = result.data?.['id'] as string | undefined
|
|
589
|
+
results.push({
|
|
590
|
+
entityType: mutation.entityType,
|
|
591
|
+
entityId: mutation.entityId,
|
|
592
|
+
ok: result.ok,
|
|
593
|
+
persistedId,
|
|
594
|
+
errorMessage: result.errorMessage,
|
|
595
|
+
mutationResult: result.mutationResult,
|
|
596
|
+
})
|
|
597
|
+
if (!result.ok) allOk = false
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
// update
|
|
601
|
+
if (mutation.data) {
|
|
602
|
+
const result = await this.adapter.persist(
|
|
603
|
+
mutation.entityType,
|
|
604
|
+
mutation.entityId,
|
|
605
|
+
mutation.data,
|
|
606
|
+
)
|
|
607
|
+
results.push({
|
|
608
|
+
entityType: mutation.entityType,
|
|
609
|
+
entityId: mutation.entityId,
|
|
610
|
+
ok: result.ok,
|
|
611
|
+
errorMessage: result.errorMessage,
|
|
612
|
+
mutationResult: result.mutationResult,
|
|
613
|
+
})
|
|
614
|
+
if (!result.ok) allOk = false
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
allOk = false
|
|
619
|
+
results.push({
|
|
620
|
+
entityType: mutation.entityType,
|
|
621
|
+
entityId: mutation.entityId,
|
|
622
|
+
ok: false,
|
|
623
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return { ok: allOk, results }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Processes transaction result and commits/rolls back as needed.
|
|
633
|
+
* For pessimistic mode, restores captured state on success.
|
|
634
|
+
*/
|
|
635
|
+
private processTransactionResult(
|
|
636
|
+
entities: DirtyEntity[],
|
|
637
|
+
transactionResult: TransactionResult,
|
|
638
|
+
scope: PersistScope,
|
|
639
|
+
options?: BatchPersistOptions,
|
|
640
|
+
capturedStates?: CapturedEntityState[] | null,
|
|
641
|
+
): PersistenceResult {
|
|
642
|
+
const results: EntityPersistResult[] = []
|
|
643
|
+
let successCount = 0
|
|
644
|
+
let failedCount = 0
|
|
645
|
+
const rollbackOnError = options?.rollbackOnError ?? false
|
|
646
|
+
const isPessimistic = capturedStates != null
|
|
647
|
+
|
|
648
|
+
if (transactionResult.ok) {
|
|
649
|
+
// All succeeded - commit all
|
|
650
|
+
for (let i = 0; i < transactionResult.results.length; i++) {
|
|
651
|
+
const mutationResult = transactionResult.results[i]!
|
|
652
|
+
const entity = entities.find(
|
|
653
|
+
e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
if (entity) {
|
|
657
|
+
// For pessimistic mode, restore captured state and commit
|
|
658
|
+
if (isPessimistic && capturedStates) {
|
|
659
|
+
const capturedState = capturedStates.find(
|
|
660
|
+
s => s.entityType === entity.entityType && s.entityId === entity.entityId,
|
|
661
|
+
)
|
|
662
|
+
if (capturedState) {
|
|
663
|
+
this.restoreAndCommitEntityState(capturedState)
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
// Commit based on scope
|
|
667
|
+
if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) {
|
|
668
|
+
// Partial commit for field scope
|
|
669
|
+
this.store.commitFields(entity.entityType, entity.entityId, [...scope.fields])
|
|
670
|
+
} else {
|
|
671
|
+
// Full commit
|
|
672
|
+
this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId))
|
|
673
|
+
this.store.commitAllRelations(entity.entityType, entity.entityId)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Map temp ID if create
|
|
678
|
+
if (entity.changeType === 'create' && mutationResult.persistedId) {
|
|
679
|
+
this.store.mapTempIdToPersistedId(
|
|
680
|
+
entity.entityType,
|
|
681
|
+
entity.entityId,
|
|
682
|
+
mutationResult.persistedId,
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
results.push({
|
|
687
|
+
entityType: entity.entityType,
|
|
688
|
+
entityId: entity.entityId,
|
|
689
|
+
operation: entity.changeType,
|
|
690
|
+
success: true,
|
|
691
|
+
persistedId: mutationResult.persistedId,
|
|
692
|
+
})
|
|
693
|
+
successCount++
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
// Transaction failed - map errors and optionally rollback
|
|
698
|
+
// For pessimistic mode, entities are already at server state, no rollback needed
|
|
699
|
+
for (const mutationResult of transactionResult.results) {
|
|
700
|
+
const entity = entities.find(
|
|
701
|
+
e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
if (entity) {
|
|
705
|
+
if (!mutationResult.ok) {
|
|
706
|
+
// Map errors to fields
|
|
707
|
+
this.mapServerErrors(
|
|
708
|
+
entity.entityType,
|
|
709
|
+
entity.entityId,
|
|
710
|
+
mutationResult.mutationResult,
|
|
711
|
+
mutationResult.errorMessage,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
// Rollback optimistic changes if enabled (and not in pessimistic mode)
|
|
715
|
+
// In pessimistic mode, we're already at server state, no rollback needed
|
|
716
|
+
if (rollbackOnError && !isPessimistic) {
|
|
717
|
+
this.rollbackEntity(entity)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
results.push({
|
|
722
|
+
entityType: entity.entityType,
|
|
723
|
+
entityId: entity.entityId,
|
|
724
|
+
operation: entity.changeType,
|
|
725
|
+
success: mutationResult.ok,
|
|
726
|
+
error: mutationResult.ok ? undefined : {
|
|
727
|
+
message: mutationResult.errorMessage ?? 'Unknown error',
|
|
728
|
+
mutationResult: mutationResult.mutationResult,
|
|
729
|
+
},
|
|
730
|
+
persistedId: mutationResult.persistedId,
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
if (mutationResult.ok) {
|
|
734
|
+
successCount++
|
|
735
|
+
} else {
|
|
736
|
+
failedCount++
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
success: transactionResult.ok,
|
|
744
|
+
results,
|
|
745
|
+
successCount,
|
|
746
|
+
failedCount,
|
|
747
|
+
skippedCount: 0,
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Captures the current state of entities for pessimistic mode.
|
|
753
|
+
* This allows us to restore the dirty state after a successful persist.
|
|
754
|
+
*/
|
|
755
|
+
private captureEntityStates(entities: DirtyEntity[]): CapturedEntityState[] {
|
|
756
|
+
return entities.map(entity => {
|
|
757
|
+
const snapshot = this.store.getEntitySnapshot(entity.entityType, entity.entityId)
|
|
758
|
+
const relations = this.store.getAllRelationsForEntity(entity.entityType, entity.entityId)
|
|
759
|
+
const hasManyStates = this.store.getAllHasManyForEntity(entity.entityType, entity.entityId)
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
entityType: entity.entityType,
|
|
763
|
+
entityId: entity.entityId,
|
|
764
|
+
snapshot,
|
|
765
|
+
relations,
|
|
766
|
+
hasManyStates,
|
|
767
|
+
}
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Restores a captured entity state and commits it.
|
|
773
|
+
* Used in pessimistic mode after successful server confirmation.
|
|
774
|
+
*/
|
|
775
|
+
private restoreAndCommitEntityState(capturedState: CapturedEntityState): void {
|
|
776
|
+
if (capturedState.snapshot) {
|
|
777
|
+
// Restore the entity data from captured snapshot
|
|
778
|
+
this.store.setEntityData(
|
|
779
|
+
capturedState.entityType,
|
|
780
|
+
capturedState.entityId,
|
|
781
|
+
capturedState.snapshot.data as Record<string, unknown>,
|
|
782
|
+
true, // Mark as server data since it's now confirmed
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Restore and commit relations
|
|
787
|
+
for (const [relationKey, relationState] of capturedState.relations) {
|
|
788
|
+
const fieldName = relationKey.split(':')[2]
|
|
789
|
+
if (fieldName) {
|
|
790
|
+
this.store.setRelation(capturedState.entityType, capturedState.entityId, fieldName, {
|
|
791
|
+
currentId: relationState.currentId,
|
|
792
|
+
state: relationState.state,
|
|
793
|
+
})
|
|
794
|
+
this.store.commitRelation(capturedState.entityType, capturedState.entityId, fieldName)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Restore and commit has-many states
|
|
799
|
+
for (const [hasManyKey, hasManyState] of capturedState.hasManyStates) {
|
|
800
|
+
const fieldName = hasManyKey.split(':')[2]
|
|
801
|
+
if (fieldName) {
|
|
802
|
+
this.store.restoreHasManyState(capturedState.entityType, capturedState.entityId, fieldName, hasManyState)
|
|
803
|
+
// Compute new server IDs: serverIds - removals + connections
|
|
804
|
+
const newServerIds = new Set(hasManyState.serverIds)
|
|
805
|
+
for (const removedId of hasManyState.plannedRemovals.keys()) {
|
|
806
|
+
newServerIds.delete(removedId)
|
|
807
|
+
}
|
|
808
|
+
for (const connectedId of hasManyState.plannedConnections) {
|
|
809
|
+
newServerIds.add(connectedId)
|
|
810
|
+
}
|
|
811
|
+
this.store.commitHasMany(capturedState.entityType, capturedState.entityId, fieldName, Array.from(newServerIds))
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Rolls back an entity's optimistic changes to server state.
|
|
818
|
+
* Handles all mutation types: create, update, and delete.
|
|
819
|
+
*/
|
|
820
|
+
private rollbackEntity(entity: DirtyEntity): void {
|
|
821
|
+
switch (entity.changeType) {
|
|
822
|
+
case 'create':
|
|
823
|
+
// For new entities, remove them from the store entirely
|
|
824
|
+
// They don't exist on the server, so there's nothing to revert to
|
|
825
|
+
this.store.removeEntity(entity.entityType, entity.entityId)
|
|
826
|
+
break
|
|
827
|
+
|
|
828
|
+
case 'update':
|
|
829
|
+
// Reset entity data to server state
|
|
830
|
+
this.dispatcher.dispatch(resetEntity(entity.entityType, entity.entityId))
|
|
831
|
+
// Reset all relations to server state
|
|
832
|
+
this.store.resetAllRelations(entity.entityType, entity.entityId)
|
|
833
|
+
break
|
|
834
|
+
|
|
835
|
+
case 'delete':
|
|
836
|
+
// Unschedule deletion - the entity should remain as-is
|
|
837
|
+
this.store.unscheduleForDeletion(entity.entityType, entity.entityId)
|
|
838
|
+
break
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Maps server errors to entity fields/relations.
|
|
844
|
+
*/
|
|
845
|
+
private mapServerErrors(
|
|
846
|
+
entityType: string,
|
|
847
|
+
entityId: string,
|
|
848
|
+
mutationResult?: ContemberMutationResult,
|
|
849
|
+
errorMessage?: string,
|
|
850
|
+
): void {
|
|
851
|
+
if (!mutationResult) {
|
|
852
|
+
if (errorMessage) {
|
|
853
|
+
this.dispatcher.dispatch(
|
|
854
|
+
addEntityError(entityType, entityId, createServerError(errorMessage)),
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (this.schema) {
|
|
861
|
+
const resolved = resolveAllErrors(mutationResult, entityType, entityId, {
|
|
862
|
+
schema: this.schema,
|
|
863
|
+
store: this.store,
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
// Clear server errors for all resolved target entities
|
|
867
|
+
const clearedEntities = new Set<string>()
|
|
868
|
+
for (const { target } of resolved) {
|
|
869
|
+
const key = `${target.entityType}:${target.entityId}`
|
|
870
|
+
if (!clearedEntities.has(key)) {
|
|
871
|
+
clearedEntities.add(key)
|
|
872
|
+
this.dispatcher.dispatch(clearAllServerErrors(target.entityType, target.entityId))
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
for (const { target, error } of resolved) {
|
|
877
|
+
if (target.type === 'field' && target.fieldName) {
|
|
878
|
+
this.dispatcher.dispatch(
|
|
879
|
+
addFieldError(target.entityType, target.entityId, target.fieldName, error),
|
|
880
|
+
)
|
|
881
|
+
} else if (target.type === 'relation' && target.fieldName) {
|
|
882
|
+
this.dispatcher.dispatch(
|
|
883
|
+
addRelationError(target.entityType, target.entityId, target.fieldName, error),
|
|
884
|
+
)
|
|
885
|
+
} else {
|
|
886
|
+
this.dispatcher.dispatch(
|
|
887
|
+
addEntityError(target.entityType, target.entityId, error),
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
for (const error of mutationResult.errors) {
|
|
893
|
+
this.dispatcher.dispatch(
|
|
894
|
+
addEntityError(entityType, entityId, createServerError(error.message, error.type)),
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
for (const error of mutationResult.validation.errors) {
|
|
898
|
+
this.dispatcher.dispatch(
|
|
899
|
+
addEntityError(entityType, entityId, createServerError(error.message.text, undefined, 'VALIDATION_ERROR')),
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Cancels all in-flight operations.
|
|
907
|
+
*/
|
|
908
|
+
cancelAll(): void {
|
|
909
|
+
this.changeRegistry.clearAllInFlight()
|
|
910
|
+
}
|
|
911
|
+
}
|