@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.
Files changed (234) hide show
  1. package/dist/adapter/ContemberAdapter.d.ts +40 -0
  2. package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
  3. package/dist/adapter/MockAdapter.d.ts +81 -0
  4. package/dist/adapter/MockAdapter.d.ts.map +1 -0
  5. package/dist/adapter/MockQueryEngine.d.ts +43 -0
  6. package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
  7. package/dist/adapter/index.d.ts +5 -0
  8. package/dist/adapter/index.d.ts.map +1 -0
  9. package/dist/adapter/types.d.ts +184 -0
  10. package/dist/adapter/types.d.ts.map +1 -0
  11. package/dist/brand/ComponentBrand.d.ts +25 -0
  12. package/dist/brand/ComponentBrand.d.ts.map +1 -0
  13. package/dist/contember/index.d.ts +10 -0
  14. package/dist/contember/index.d.ts.map +1 -0
  15. package/dist/core/ActionDispatcher.d.ts +64 -0
  16. package/dist/core/ActionDispatcher.d.ts.map +1 -0
  17. package/dist/core/Disposable.d.ts +57 -0
  18. package/dist/core/Disposable.d.ts.map +1 -0
  19. package/dist/core/EntityLoader.d.ts +80 -0
  20. package/dist/core/EntityLoader.d.ts.map +1 -0
  21. package/dist/core/PersistenceManager.d.ts +126 -0
  22. package/dist/core/PersistenceManager.d.ts.map +1 -0
  23. package/dist/core/SelectionResolver.d.ts +21 -0
  24. package/dist/core/SelectionResolver.d.ts.map +1 -0
  25. package/dist/core/actionClassification.d.ts +48 -0
  26. package/dist/core/actionClassification.d.ts.map +1 -0
  27. package/dist/core/actions.d.ts +301 -0
  28. package/dist/core/actions.d.ts.map +1 -0
  29. package/dist/core/index.d.ts +4 -0
  30. package/dist/core/index.d.ts.map +1 -0
  31. package/dist/dataview/filterHandlers.d.ts +18 -0
  32. package/dist/dataview/filterHandlers.d.ts.map +1 -0
  33. package/dist/dataview/index.d.ts +3 -0
  34. package/dist/dataview/index.d.ts.map +1 -0
  35. package/dist/dataview/types.d.ts +164 -0
  36. package/dist/dataview/types.d.ts.map +1 -0
  37. package/dist/errors/UnfetchedFieldError.d.ts +11 -0
  38. package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
  39. package/dist/errors/errorPathResolver.d.ts +52 -0
  40. package/dist/errors/errorPathResolver.d.ts.map +1 -0
  41. package/dist/errors/index.d.ts +5 -0
  42. package/dist/errors/index.d.ts.map +1 -0
  43. package/dist/errors/pathMapper.d.ts +42 -0
  44. package/dist/errors/pathMapper.d.ts.map +1 -0
  45. package/dist/errors/types.d.ts +131 -0
  46. package/dist/errors/types.d.ts.map +1 -0
  47. package/dist/events/EventEmitter.d.ts +97 -0
  48. package/dist/events/EventEmitter.d.ts.map +1 -0
  49. package/dist/events/eventFactory.d.ts +40 -0
  50. package/dist/events/eventFactory.d.ts.map +1 -0
  51. package/dist/events/index.d.ts +15 -0
  52. package/dist/events/index.d.ts.map +1 -0
  53. package/dist/events/types.d.ts +264 -0
  54. package/dist/events/types.d.ts.map +1 -0
  55. package/dist/handles/BaseHandle.d.ts +64 -0
  56. package/dist/handles/BaseHandle.d.ts.map +1 -0
  57. package/dist/handles/EntityHandle.d.ts +208 -0
  58. package/dist/handles/EntityHandle.d.ts.map +1 -0
  59. package/dist/handles/FieldHandle.d.ts +111 -0
  60. package/dist/handles/FieldHandle.d.ts.map +1 -0
  61. package/dist/handles/HasManyListHandle.d.ts +166 -0
  62. package/dist/handles/HasManyListHandle.d.ts.map +1 -0
  63. package/dist/handles/HasOneHandle.d.ts +168 -0
  64. package/dist/handles/HasOneHandle.d.ts.map +1 -0
  65. package/dist/handles/PlaceholderHandle.d.ts +117 -0
  66. package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
  67. package/dist/handles/index.d.ts +8 -0
  68. package/dist/handles/index.d.ts.map +1 -0
  69. package/dist/handles/proxyFactory.d.ts +66 -0
  70. package/dist/handles/proxyFactory.d.ts.map +1 -0
  71. package/dist/handles/types.d.ts +430 -0
  72. package/dist/handles/types.d.ts.map +1 -0
  73. package/dist/index.d.ts +53 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/internal.d.ts +11 -0
  76. package/dist/internal.d.ts.map +1 -0
  77. package/dist/persistence/BatchPersister.d.ts +142 -0
  78. package/dist/persistence/BatchPersister.d.ts.map +1 -0
  79. package/dist/persistence/ChangeRegistry.d.ts +87 -0
  80. package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
  81. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
  82. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
  83. package/dist/persistence/MutationCollector.d.ts +96 -0
  84. package/dist/persistence/MutationCollector.d.ts.map +1 -0
  85. package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
  86. package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
  87. package/dist/persistence/index.d.ts +7 -0
  88. package/dist/persistence/index.d.ts.map +1 -0
  89. package/dist/persistence/types.d.ts +189 -0
  90. package/dist/persistence/types.d.ts.map +1 -0
  91. package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
  92. package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
  93. package/dist/roles/index.d.ts +11 -0
  94. package/dist/roles/index.d.ts.map +1 -0
  95. package/dist/roles/types.d.ts +242 -0
  96. package/dist/roles/types.d.ts.map +1 -0
  97. package/dist/schema/ContemberSchema.d.ts +98 -0
  98. package/dist/schema/ContemberSchema.d.ts.map +1 -0
  99. package/dist/schema/SchemaLoader.d.ts +28 -0
  100. package/dist/schema/SchemaLoader.d.ts.map +1 -0
  101. package/dist/schema/SchemaRegistry.d.ts +95 -0
  102. package/dist/schema/SchemaRegistry.d.ts.map +1 -0
  103. package/dist/schema/index.d.ts +6 -0
  104. package/dist/schema/index.d.ts.map +1 -0
  105. package/dist/schema/roles.d.ts +62 -0
  106. package/dist/schema/roles.d.ts.map +1 -0
  107. package/dist/schema/types.d.ts +179 -0
  108. package/dist/schema/types.d.ts.map +1 -0
  109. package/dist/selection/SelectionMetaCollector.d.ts +32 -0
  110. package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
  111. package/dist/selection/SelectionScope.d.ts +77 -0
  112. package/dist/selection/SelectionScope.d.ts.map +1 -0
  113. package/dist/selection/buildQuery.d.ts +45 -0
  114. package/dist/selection/buildQuery.d.ts.map +1 -0
  115. package/dist/selection/createFragment.d.ts +29 -0
  116. package/dist/selection/createFragment.d.ts.map +1 -0
  117. package/dist/selection/createSelectionBuilder.d.ts +18 -0
  118. package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
  119. package/dist/selection/index.d.ts +8 -0
  120. package/dist/selection/index.d.ts.map +1 -0
  121. package/dist/selection/queryTypes.d.ts +84 -0
  122. package/dist/selection/queryTypes.d.ts.map +1 -0
  123. package/dist/selection/types.d.ts +238 -0
  124. package/dist/selection/types.d.ts.map +1 -0
  125. package/dist/store/DirtyTracker.d.ts +26 -0
  126. package/dist/store/DirtyTracker.d.ts.map +1 -0
  127. package/dist/store/EntityMetaStore.d.ts +52 -0
  128. package/dist/store/EntityMetaStore.d.ts.map +1 -0
  129. package/dist/store/EntitySnapshotStore.d.ts +63 -0
  130. package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
  131. package/dist/store/ErrorStore.d.ts +82 -0
  132. package/dist/store/ErrorStore.d.ts.map +1 -0
  133. package/dist/store/RelationStore.d.ts +175 -0
  134. package/dist/store/RelationStore.d.ts.map +1 -0
  135. package/dist/store/SnapshotStore.d.ts +143 -0
  136. package/dist/store/SnapshotStore.d.ts.map +1 -0
  137. package/dist/store/SubscriptionManager.d.ts +90 -0
  138. package/dist/store/SubscriptionManager.d.ts.map +1 -0
  139. package/dist/store/TouchedStore.d.ts +24 -0
  140. package/dist/store/TouchedStore.d.ts.map +1 -0
  141. package/dist/store/entityId.d.ts +29 -0
  142. package/dist/store/entityId.d.ts.map +1 -0
  143. package/dist/store/index.d.ts +1 -0
  144. package/dist/store/index.d.ts.map +1 -0
  145. package/dist/store/snapshots.d.ts +144 -0
  146. package/dist/store/snapshots.d.ts.map +1 -0
  147. package/dist/undo/UndoManager.d.ts +120 -0
  148. package/dist/undo/UndoManager.d.ts.map +1 -0
  149. package/dist/undo/index.d.ts +3 -0
  150. package/dist/undo/index.d.ts.map +1 -0
  151. package/dist/undo/types.d.ts +74 -0
  152. package/dist/undo/types.d.ts.map +1 -0
  153. package/dist/utils/aliasGenerator.d.ts +25 -0
  154. package/dist/utils/aliasGenerator.d.ts.map +1 -0
  155. package/dist/utils/deepEqual.d.ts +6 -0
  156. package/dist/utils/deepEqual.d.ts.map +1 -0
  157. package/dist/utils/index.d.ts +3 -0
  158. package/dist/utils/index.d.ts.map +1 -0
  159. package/package.json +29 -0
  160. package/src/adapter/ContemberAdapter.ts +239 -0
  161. package/src/adapter/MockAdapter.ts +394 -0
  162. package/src/adapter/MockQueryEngine.ts +236 -0
  163. package/src/adapter/index.ts +21 -0
  164. package/src/adapter/types.ts +199 -0
  165. package/src/brand/ComponentBrand.ts +28 -0
  166. package/src/contember/index.ts +17 -0
  167. package/src/core/ActionDispatcher.ts +450 -0
  168. package/src/core/Disposable.ts +126 -0
  169. package/src/core/EntityLoader.ts +169 -0
  170. package/src/core/SelectionResolver.ts +78 -0
  171. package/src/core/actionClassification.ts +196 -0
  172. package/src/core/actions.ts +491 -0
  173. package/src/core/index.ts +20 -0
  174. package/src/dataview/filterHandlers.ts +443 -0
  175. package/src/dataview/index.ts +36 -0
  176. package/src/dataview/types.ts +220 -0
  177. package/src/errors/UnfetchedFieldError.ts +18 -0
  178. package/src/errors/errorPathResolver.ts +239 -0
  179. package/src/errors/index.ts +40 -0
  180. package/src/errors/pathMapper.ts +40 -0
  181. package/src/errors/types.ts +223 -0
  182. package/src/events/EventEmitter.ts +447 -0
  183. package/src/events/eventFactory.ts +430 -0
  184. package/src/events/index.ts +89 -0
  185. package/src/events/types.ts +374 -0
  186. package/src/handles/BaseHandle.ts +100 -0
  187. package/src/handles/EntityHandle.ts +538 -0
  188. package/src/handles/FieldHandle.ts +268 -0
  189. package/src/handles/HasManyListHandle.ts +500 -0
  190. package/src/handles/HasOneHandle.ts +500 -0
  191. package/src/handles/PlaceholderHandle.ts +321 -0
  192. package/src/handles/index.ts +37 -0
  193. package/src/handles/proxyFactory.ts +218 -0
  194. package/src/handles/types.ts +743 -0
  195. package/src/index.ts +384 -0
  196. package/src/internal.ts +19 -0
  197. package/src/persistence/BatchPersister.ts +911 -0
  198. package/src/persistence/ChangeRegistry.ts +150 -0
  199. package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
  200. package/src/persistence/MutationCollector.ts +567 -0
  201. package/src/persistence/MutationSchemaProvider.ts +33 -0
  202. package/src/persistence/index.ts +23 -0
  203. package/src/persistence/types.ts +220 -0
  204. package/src/schema/ContemberSchema.ts +132 -0
  205. package/src/schema/SchemaLoader.ts +93 -0
  206. package/src/schema/SchemaRegistry.ts +286 -0
  207. package/src/schema/index.ts +51 -0
  208. package/src/schema/roles.ts +83 -0
  209. package/src/schema/types.ts +238 -0
  210. package/src/selection/SelectionMetaCollector.ts +122 -0
  211. package/src/selection/SelectionScope.ts +208 -0
  212. package/src/selection/buildQuery.ts +134 -0
  213. package/src/selection/createFragment.ts +50 -0
  214. package/src/selection/createSelectionBuilder.ts +283 -0
  215. package/src/selection/index.ts +31 -0
  216. package/src/selection/queryTypes.ts +135 -0
  217. package/src/selection/types.ts +320 -0
  218. package/src/store/DirtyTracker.ts +117 -0
  219. package/src/store/EntityMetaStore.ts +143 -0
  220. package/src/store/EntitySnapshotStore.ts +260 -0
  221. package/src/store/ErrorStore.ts +251 -0
  222. package/src/store/RelationStore.ts +696 -0
  223. package/src/store/SnapshotStore.ts +765 -0
  224. package/src/store/SubscriptionManager.ts +241 -0
  225. package/src/store/TouchedStore.ts +42 -0
  226. package/src/store/entityId.ts +43 -0
  227. package/src/store/index.ts +2 -0
  228. package/src/store/snapshots.ts +203 -0
  229. package/src/undo/UndoManager.ts +418 -0
  230. package/src/undo/index.ts +9 -0
  231. package/src/undo/types.ts +84 -0
  232. package/src/utils/aliasGenerator.ts +56 -0
  233. package/src/utils/deepEqual.ts +33 -0
  234. 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
+ }