@abloatai/ablo 0.3.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 (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * SyncClient - Mutation and offline queue manager
3
+ *
4
+ * Responsibilities:
5
+ * - Handle model mutations (create, update, delete, archive)
6
+ * - Manage offline mutation queue with persistence
7
+ * - Send mutations to server via API client
8
+ * - Handle conflict resolution for local changes
9
+ */
10
+ import { ModelScope } from './ObjectPool.js';
11
+ // ModelRegistry instance accessed via this.objectPool.registry
12
+ import { LoadStrategy } from './types/index.js';
13
+ import { getContext } from './context.js';
14
+ import { AbloAuthenticationError, AbloError, AbloValidationError } from './errors.js';
15
+ import { EventEmitter } from 'events';
16
+ import { NetworkMonitor } from './NetworkMonitor.js';
17
+ import { TransactionQueue } from './transactions/TransactionQueue.js';
18
+ import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
19
+ export class SyncClient extends EventEmitter {
20
+ objectPool;
21
+ database;
22
+ get mutationExecutor() { return getContext().mutationExecutor; }
23
+ networkMonitor;
24
+ transactionQueue;
25
+ observers = new Set();
26
+ // Authentication context
27
+ userId = null;
28
+ organizationId = null;
29
+ // Pending mutations queue
30
+ pendingMutations = [];
31
+ /**
32
+ * Tracks transaction ids the client has optimistically applied but
33
+ * the server has not yet confirmed. The receive path consults it
34
+ * to recognize delta echoes of own mutations and suppress the
35
+ * (otherwise-redundant) pool mutation — the IDB write still runs
36
+ * because the delta is the authoritative version of the row.
37
+ *
38
+ * The receive-layer discriminator named in
39
+ * `apps/sync-server/docs/OPTIMISTIC_RECONCILIATION.md`. Without
40
+ * it, an optimistically-applied DELETE followed by a
41
+ * server-confirming CREATE echo resurrects the row for the window
42
+ * between the two confirmations (the chart-delete flicker).
43
+ *
44
+ * Bounded with FIFO eviction; observability via `getEchoMetrics()`.
45
+ */
46
+ echoTracker = new OptimisticEchoTracker();
47
+ // Connection state
48
+ connectionState = 'disconnected';
49
+ offlineSince;
50
+ // Configuration
51
+ maxRetries = 3;
52
+ isDisposed = false;
53
+ constructor(objectPool, database) {
54
+ super();
55
+ this.objectPool = objectPool;
56
+ this.database = database;
57
+ this.networkMonitor = new NetworkMonitor();
58
+ // Initialize TransactionQueue with proper configuration
59
+ this.transactionQueue = new TransactionQueue({
60
+ maxBatchSize: 50, // Increased from 10 to reduce batch count for large operations
61
+ // Lower delay for snappier dev UX; batching still happens via coalescing
62
+ batchDelay: 150,
63
+ maxRetries: 3,
64
+ enableOptimistic: true,
65
+ enablePersistence: true,
66
+ conflictResolution: {
67
+ strategy: 'last-write-wins',
68
+ },
69
+ });
70
+ // Provide connection state to TransactionQueue - prevents rollbacks during disconnection
71
+ this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
72
+ // LINEAR PATTERN: Subscribe to rollback events to restore ObjectPool state
73
+ // When a transaction fails (server rejects or timeout), we need to restore the model
74
+ // Since we no longer write to IndexedDB optimistically, IndexedDB already has correct state
75
+ this.setupTransactionRollbackHandling();
76
+ // REPLICACHE PATTERN: Forward reconciliation requests from TransactionQueue
77
+ // When delta confirmation times out, instead of rolling back we request the sync layer
78
+ // to cycle the WebSocket connection, triggering a delta catch-up from the server
79
+ this.setupReconciliationForwarding();
80
+ // LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB
81
+ // When delta retries exhaust, cache in IDB so they survive tab close
82
+ this.setupAwaitingTransactionPersistence();
83
+ // Setup network monitoring
84
+ this.setupNetworkMonitoring();
85
+ }
86
+ /**
87
+ * Setup network monitoring handlers
88
+ */
89
+ setupNetworkMonitoring() {
90
+ this.networkMonitor.on('online', () => this.handleReconnection());
91
+ this.networkMonitor.on('offline', () => this.handleDisconnection());
92
+ }
93
+ /**
94
+ * Handle transaction rollback. Two distinct shapes flow through this
95
+ * event:
96
+ *
97
+ * 1. **Server-rejected rollback** (`reason === 'permanent_error'`,
98
+ * `'max_retries_exhausted'`, `'conflict_server_wins'`) — the
99
+ * optimistic state is wrong, the row exists, restore previous
100
+ * state and notify the UI.
101
+ *
102
+ * 2. **Local-cancellation cleanup** (`reason === 'model_cancelled'`,
103
+ * `'cascade_parent_deleted'`) — the user deleted this model (or
104
+ * its parent), so a pending UPDATE on it gets cancelled. There's
105
+ * nothing to restore (the model is doomed) and no UI notification
106
+ * needed (the delete itself already triggered re-renders). Just
107
+ * discard the optimistic state silently.
108
+ *
109
+ * Treating both paths the same caused the deletion-flicker bug: every
110
+ * cancelled update on a multi-layer chart fired a per-model observer
111
+ * event and a `[SyncClient.rollback]` warn, producing N renders and N
112
+ * spam log lines for one user-initiated delete.
113
+ */
114
+ setupTransactionRollbackHandling() {
115
+ this.transactionQueue.on('optimistic:rollback', (event) => {
116
+ const { model, previousState, transaction, reason, error } = event;
117
+ // Local cleanup path — discard quietly. The optimistic state was
118
+ // applied to a model that's already disposed by the cascading
119
+ // delete, and emitting per-model observer events here would
120
+ // re-render N times for one user-initiated cascade.
121
+ if (reason === 'model_cancelled' || reason === 'cascade_parent_deleted') {
122
+ return;
123
+ }
124
+ // Surface the typed AbloError fields directly — `type`/`code`/
125
+ // `httpStatus`/`requestId` are what tell us the rollback cause
126
+ // (e.g. `AbloValidationError` with `code: 'schema_...'`,
127
+ // `AbloServerError` with `httpStatus: 500`). Falling back to
128
+ // generic message lets us still see unstructured errors.
129
+ const abloErr = error instanceof AbloError ? error : undefined;
130
+ getContext().logger.warn('[SyncClient.rollback]', {
131
+ txType: transaction.type,
132
+ modelName: transaction.modelName,
133
+ modelId: transaction.modelId.slice(0, 12),
134
+ reason: reason ?? 'unknown',
135
+ errorType: abloErr?.type ?? error?.name,
136
+ errorCode: abloErr?.code,
137
+ httpStatus: abloErr?.httpStatus,
138
+ requestId: abloErr?.requestId,
139
+ message: error?.message,
140
+ });
141
+ getContext().observability.captureRollback({
142
+ transactionType: transaction.type,
143
+ modelName: transaction.modelName,
144
+ modelId: transaction.modelId,
145
+ reason: reason ?? 'unknown',
146
+ error: error?.message,
147
+ connectionState: this.connectionState,
148
+ });
149
+ try {
150
+ if (transaction.type === 'create') {
151
+ // CREATE rollback: remove the optimistically created entity
152
+ this.objectPool.remove(transaction.modelId);
153
+ }
154
+ else if (transaction.type === 'delete' &&
155
+ reason === 'permanent_error' &&
156
+ error?.message?.includes('not found')) {
157
+ // DELETE "not found" rollback: the entity doesn't exist on the server.
158
+ // Instead of restoring a ghost entity, remove it locally too.
159
+ // Both sides agree: this entity should not exist.
160
+ getContext().observability.breadcrumb('DELETE rolled back with "not found" - removing ghost entity', 'sync.conflict', 'info', {
161
+ modelId: transaction.modelId,
162
+ modelName: transaction.modelName,
163
+ });
164
+ this.objectPool.remove(transaction.modelId);
165
+ }
166
+ else if (model) {
167
+ // For update/delete/archive: restore model (with previousState if available)
168
+ // Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
169
+ // cascade), don't re-add it — Object.assign cannot restore the private
170
+ // isDisposed flag, so the model would be added in a broken state.
171
+ if (model.disposed) {
172
+ getContext().logger.warn('[SyncClient] Skipping rollback restore for disposed model', {
173
+ modelId: transaction.modelId,
174
+ modelName: transaction.modelName,
175
+ reason,
176
+ });
177
+ }
178
+ else {
179
+ if (previousState)
180
+ Object.assign(model, previousState);
181
+ this.objectPool.add(model, ModelScope.live);
182
+ }
183
+ }
184
+ this.notifyObservers({
185
+ type: 'rollback',
186
+ modelType: transaction.modelName,
187
+ modelId: transaction.modelId,
188
+ transactionType: transaction.type,
189
+ });
190
+ // Emit event so SyncedStore can clear pendingDeletes on delete rollback
191
+ this.emit('sync:rollback', {
192
+ modelId: transaction.modelId,
193
+ modelName: transaction.modelName,
194
+ transactionType: transaction.type,
195
+ reason,
196
+ });
197
+ }
198
+ catch (error) {
199
+ getContext().observability.captureTransactionFailure({
200
+ context: 'rollback-failed',
201
+ transactionId: transaction.id,
202
+ modelName: transaction.modelName,
203
+ modelId: transaction.modelId,
204
+ error: error instanceof Error ? error : new Error(String(error)),
205
+ });
206
+ }
207
+ });
208
+ }
209
+ /**
210
+ * Forward reconciliation requests from TransactionQueue to the sync layer.
211
+ * When delta confirmation times out, TransactionQueue emits 'reconciliation:needed'
212
+ * instead of rolling back — following the Replicache/PowerSync pattern of never
213
+ * destroying optimistic state that the server may have committed.
214
+ */
215
+ setupReconciliationForwarding() {
216
+ this.transactionQueue.on('reconciliation:needed', (event) => {
217
+ getContext().observability.captureReconciliation({
218
+ reason: event.reason,
219
+ model: event.model,
220
+ modelId: event.modelId,
221
+ syncIdNeeded: event.syncIdNeeded,
222
+ lastSeenSyncId: event.lastSeenSyncId,
223
+ retryCount: event.retryCount,
224
+ connectionState: this.connectionState,
225
+ });
226
+ // Forward to SyncedStore via event — it has access to the WebSocket
227
+ this.emit('reconciliation:needed', event);
228
+ });
229
+ }
230
+ /**
231
+ * LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB.
232
+ * When delta confirmation retries exhaust, the transaction data is cached in IDB
233
+ * so it survives tab close. On next session, WebSocket reconnect + delta catch-up
234
+ * will deliver the missing deltas and naturally confirm the transaction.
235
+ */
236
+ setupAwaitingTransactionPersistence() {
237
+ this.transactionQueue.on('transaction:persist_awaiting', async (event) => {
238
+ if (!this.database)
239
+ return;
240
+ try {
241
+ await this.database.saveTransaction({
242
+ id: `awaiting_${event.txId}`,
243
+ type: 'awaiting_delta',
244
+ timestamp: Date.now(),
245
+ awaitingDelta: {
246
+ syncIdNeeded: event.syncIdNeeded ?? 0,
247
+ modelName: event.model,
248
+ modelId: event.modelId,
249
+ operationType: event.operationType,
250
+ },
251
+ });
252
+ getContext().observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
253
+ txId: event.txId,
254
+ model: event.model,
255
+ modelId: event.modelId,
256
+ });
257
+ }
258
+ catch (error) {
259
+ getContext().observability.captureTransactionFailure({
260
+ context: 'persist-awaiting-transaction',
261
+ modelName: event.model,
262
+ modelId: event.modelId,
263
+ error: error instanceof Error ? error : new Error(String(error)),
264
+ });
265
+ }
266
+ });
267
+ // Clean up persisted awaiting transactions when they're finally confirmed
268
+ this.transactionQueue.on('transaction:completed', async (tx) => {
269
+ if (!this.database)
270
+ return;
271
+ try {
272
+ await this.database.removeTransaction(`awaiting_${tx.id}`);
273
+ }
274
+ catch {
275
+ // Ignore — might not have been persisted
276
+ }
277
+ });
278
+ // Echo detection bridge. When the queue stages a transaction, the
279
+ // client has already optimistically applied the change to the
280
+ // pool — record the tx id so the matching server delta echo gets
281
+ // recognized in `applyDeltaBatchToPool`. The set is drained when
282
+ // the echo lands; if a transaction is rolled back before the
283
+ // server processes it, we drain on rollback too so a stale id
284
+ // doesn't permanently silence a foreign delta sharing the same id
285
+ // (vanishingly unlikely for UUIDs, but cheap insurance).
286
+ this.transactionQueue.on('transaction:created', (tx) => this.echoTracker.markPending(tx.id));
287
+ this.transactionQueue.on('optimistic:rollback', (event) => {
288
+ this.echoTracker.drainOnRollback(event.transaction.id);
289
+ });
290
+ }
291
+ /**
292
+ * Initialize sync client with authentication
293
+ */
294
+ async initialize(userId, organizationId) {
295
+ this.userId = userId;
296
+ this.organizationId = organizationId;
297
+ getContext().observability.setContext(userId, organizationId);
298
+ // Restore queued mutations from previous session
299
+ await this.restoreMutationQueue();
300
+ // Check network status via the DI'd OnlineStatusProvider (see interfaces.ts:192).
301
+ // In the browser this is wired to the service worker's connectivity signal via
302
+ // abloOnlineStatus in ablo-sync-adapters.ts; in Node it returns true (assume
303
+ // online) via the browserOnlineStatus fallback. NetworkMonitor still drives
304
+ // event-based online/offline transitions below; this read is just the initial
305
+ // status snapshot at registerUser() time.
306
+ if (getContext().onlineStatus.isOnline()) {
307
+ this.setConnectionState('connected');
308
+ }
309
+ else {
310
+ // Offline - start in offline mode
311
+ this.setConnectionState('disconnected');
312
+ this.offlineSince = new Date();
313
+ this.emit('sync:offline');
314
+ }
315
+ }
316
+ /**
317
+ * Self-healing helper for individual model records.
318
+ *
319
+ * Two registry-driven repair passes run on every row hydrated from
320
+ * IDB or merged from a delta:
321
+ *
322
+ * 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
323
+ * declares on this model, copy the corresponding identity value
324
+ * (`organizationId` / `userId`) onto the row when it's missing.
325
+ * Repairs rows from a past version that didn't write the field.
326
+ *
327
+ * 2. **Required-field gate** — if the row is missing any field listed
328
+ * in the model's `requiredFields`, return `null` so the caller
329
+ * skips this record. Used for FK columns whose absence renders the
330
+ * row unrecoverable (e.g. a SlideLayer with no slideId).
331
+ *
332
+ * The engine itself is product-neutral: model identity (which fields
333
+ * to back-fill, which absences are fatal) lives entirely in the
334
+ * consumer schema.
335
+ */
336
+ healModelRecord(modelType, data) {
337
+ const meta = this.objectPool.registry.getMetadata(modelType);
338
+ if (!meta)
339
+ return { data, healed: false };
340
+ const idPrefix = data.id?.slice(0, 8) ?? 'unknown';
341
+ let result = data;
342
+ let healed = false;
343
+ if (meta.autoFill) {
344
+ for (const rule of meta.autoFill) {
345
+ if (result[rule.field])
346
+ continue;
347
+ const replacement = rule.from === 'organizationId' ? this.organizationId : this.userId;
348
+ if (!replacement)
349
+ continue;
350
+ getContext().observability.captureSelfHealing({
351
+ modelName: modelType,
352
+ modelId: idPrefix,
353
+ field: rule.field,
354
+ action: `added missing ${rule.field}`,
355
+ });
356
+ result = { ...result, [rule.field]: replacement };
357
+ healed = true;
358
+ }
359
+ }
360
+ if (meta.requiredFields) {
361
+ for (const field of meta.requiredFields) {
362
+ if (result[field])
363
+ continue;
364
+ getContext().observability.captureSelfHealing({
365
+ modelName: modelType,
366
+ modelId: idPrefix,
367
+ field,
368
+ action: `skipped corrupted ${modelType} - missing ${field}`,
369
+ });
370
+ return null;
371
+ }
372
+ }
373
+ return { data: result, healed };
374
+ }
375
+ /**
376
+ * Hydrate ObjectPool with data from Database
377
+ * Called after bootstrap is complete
378
+ */
379
+ async hydrateFromDatabase() {
380
+ if (!this.database) {
381
+ throw new AbloValidationError('Database not available for hydration', {
382
+ code: 'sync_client_db_missing',
383
+ });
384
+ }
385
+ // Get model types that should be hydrated on startup (skip lazy per LSE)
386
+ const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
387
+ const meta = this.objectPool.registry.getMetadata(name);
388
+ return (meta?.loadStrategy === LoadStrategy.instant || meta?.loadStrategy === LoadStrategy.partial);
389
+ });
390
+ const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
391
+ // Phase 1: Fetch all data from IndexedDB and create model instances (async I/O).
392
+ // We collect all models across ALL types before touching MobX, so that Phase 2
393
+ // can add them in a single addBatch() call → ONE MobX action → ONE re-render.
394
+ const allModelsToAdd = [];
395
+ const perTypePerfLogs = [];
396
+ for (const modelType of modelTypes) {
397
+ const typeStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
398
+ try {
399
+ // Get raw data from Database (via StoreManager)
400
+ const rawData = await this.database.hydrateModels(modelType);
401
+ const afterFetch = typeof performance !== 'undefined' ? performance.now() : Date.now();
402
+ // Create models in batch first, collect for deferred addBatch
403
+ const modelsForType = [];
404
+ const recordsToHeal = [];
405
+ for (const data of rawData) {
406
+ let withType = data && typeof data === 'object' && !data.__typename
407
+ ? { __typename: modelType, ...data }
408
+ : data;
409
+ // Self-healing: Fix corrupted IndexedDB records missing essential fields
410
+ const healResult = this.healModelRecord(modelType, withType);
411
+ if (healResult === null) {
412
+ continue; // Record is corrupted beyond repair — skip
413
+ }
414
+ withType = healResult.data;
415
+ if (healResult.healed) {
416
+ recordsToHeal.push({ id: healResult.data.id, data: healResult.data });
417
+ }
418
+ const model = this.objectPool.createFromData(withType);
419
+ if (model) {
420
+ modelsForType.push(model);
421
+ }
422
+ }
423
+ // Collect models for the single batched addBatch call in Phase 2
424
+ allModelsToAdd.push(...modelsForType);
425
+ // Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
426
+ if (recordsToHeal.length > 0 && this.database) {
427
+ getContext().logger.info(`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`);
428
+ // Use fire-and-forget to not block hydration
429
+ Promise.resolve().then(async () => {
430
+ try {
431
+ for (const { id, data } of recordsToHeal) {
432
+ await this.database.putRecord(modelType, id, data);
433
+ }
434
+ getContext().logger.info(`[SyncClient.hydrate] Successfully healed ${recordsToHeal.length} ${modelType} records`);
435
+ }
436
+ catch (err) {
437
+ getContext().observability.captureTransactionFailure({
438
+ context: 'persist-healed-records',
439
+ modelName: modelType,
440
+ error: err instanceof Error ? err : new Error(String(err)),
441
+ });
442
+ }
443
+ });
444
+ }
445
+ const typeEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
446
+ // Dev-only hydration summary
447
+ if (modelType === 'InboxItem' && process.env.NODE_ENV !== 'production') {
448
+ getContext().logger.debug('[SyncClient] InboxItem hydration summary', {
449
+ fetched: rawData.length,
450
+ added: modelsForType.length,
451
+ });
452
+ }
453
+ perTypePerfLogs.push({
454
+ type: modelType,
455
+ fetched: rawData.length,
456
+ added: modelsForType.length,
457
+ fetchMs: (afterFetch - typeStart).toFixed(2),
458
+ createMs: (typeEnd - afterFetch).toFixed(2),
459
+ });
460
+ }
461
+ catch (error) {
462
+ getContext().observability.captureBootstrapFailure(error, { type: `hydrate-${modelType}` });
463
+ }
464
+ }
465
+ // Phase 2: Single MobX action — add ALL models across all types at once.
466
+ const addStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
467
+ const totalAdded = this.objectPool.addBatch(allModelsToAdd, ModelScope.live);
468
+ const addEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
469
+ // Log per-type perf after the batched add (so logs still show per-type breakdown)
470
+ for (const entry of perTypePerfLogs) {
471
+ getContext().logger.debug('hydrate:type', parseFloat(entry.fetchMs) + parseFloat(entry.createMs), {
472
+ type: entry.type,
473
+ fetched: entry.fetched,
474
+ added: entry.added,
475
+ fetchMs: entry.fetchMs,
476
+ createMs: entry.createMs,
477
+ });
478
+ }
479
+ const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
480
+ getContext().logger.debug('hydrate:total', totalEnd - totalStart, {
481
+ totalModels: totalAdded,
482
+ addBatchMs: (addEnd - addStart).toFixed(2),
483
+ });
484
+ // One-line startup summary: types pre-seeded and items per type
485
+ try {
486
+ const preseededTypes = this.objectPool.registry.getRegisteredModelNames();
487
+ const stats = this.objectPool.getStats();
488
+ getContext().logger.info('startup_summary', {
489
+ typesPreseeded: preseededTypes.length,
490
+ poolSize: stats.size,
491
+ typeCounts: stats.typeCounts,
492
+ });
493
+ }
494
+ catch { }
495
+ }
496
+ /**
497
+ * Re-hydrate ObjectPool from IndexedDB when the pool already has data.
498
+ *
499
+ * Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
500
+ * this method properly:
501
+ * 1. Upserts models — updates existing models in-place, adds new ones
502
+ * 2. Removes ghosts — deletes models from the pool that no longer exist in IndexedDB
503
+ *
504
+ * Used by background bootstrap, network recovery, and server-triggered re-bootstrap.
505
+ */
506
+ async rehydrateFromDatabase() {
507
+ if (!this.database) {
508
+ throw new AbloValidationError('Database not available for rehydration', {
509
+ code: 'sync_client_db_missing',
510
+ });
511
+ }
512
+ const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
513
+ // Model types to rehydrate (same filter as hydrateFromDatabase)
514
+ const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
515
+ const meta = this.objectPool.registry.getMetadata(name);
516
+ return (meta?.loadStrategy === LoadStrategy.instant || meta?.loadStrategy === LoadStrategy.partial);
517
+ });
518
+ // ── Phase 1: Read from IndexedDB & create model instances (async I/O) ──
519
+ const allModels = [];
520
+ const idbIdsByType = new Map();
521
+ let healedCount = 0;
522
+ let skippedCount = 0;
523
+ for (const modelType of modelTypes) {
524
+ try {
525
+ const rawData = await this.database.hydrateModels(modelType);
526
+ const idsForType = new Set();
527
+ idbIdsByType.set(modelType, idsForType);
528
+ for (const data of rawData) {
529
+ let withType = data && typeof data === 'object' && !data.__typename
530
+ ? { __typename: modelType, ...data }
531
+ : data;
532
+ // Self-healing
533
+ const healResult = this.healModelRecord(modelType, withType);
534
+ if (healResult === null) {
535
+ skippedCount++;
536
+ continue;
537
+ }
538
+ withType = healResult.data;
539
+ if (healResult.healed) {
540
+ healedCount++;
541
+ // Persist heal back to IndexedDB (fire-and-forget)
542
+ if (this.database) {
543
+ const id = healResult.data.id;
544
+ const healedData = healResult.data;
545
+ Promise.resolve().then(async () => {
546
+ try {
547
+ await this.database.putRecord(modelType, id, healedData);
548
+ }
549
+ catch {
550
+ // Non-critical — will heal again next time
551
+ }
552
+ });
553
+ }
554
+ }
555
+ // Register ID before createFromData — prevents ghost removal
556
+ // if createFromData fails for a record that exists in IDB
557
+ const recordId = withType.id;
558
+ if (recordId) {
559
+ idsForType.add(recordId);
560
+ }
561
+ try {
562
+ const model = this.objectPool.createFromData(withType);
563
+ if (model) {
564
+ allModels.push(model);
565
+ }
566
+ }
567
+ catch (error) {
568
+ getContext().observability.breadcrumb('Model creation failed during rehydration', 'sync.bootstrap', 'warning', {
569
+ modelType,
570
+ modelId: recordId?.slice(0, 8) ?? 'unknown',
571
+ error: error instanceof Error ? error.message : String(error),
572
+ });
573
+ skippedCount++;
574
+ }
575
+ }
576
+ }
577
+ catch (error) {
578
+ getContext().observability.captureBootstrapFailure(error, { type: `rehydrate-${modelType}` });
579
+ }
580
+ }
581
+ // ── Phase 2: Upsert batch (single MobX action) ──
582
+ // createFromData already calls updateFromData() on existing models,
583
+ // so existing models are up-to-date. Upsert adds the new ones and
584
+ // updates scope for any that changed.
585
+ const beforeSize = this.objectPool.size;
586
+ this.objectPool.upsertBatch(allModels, ModelScope.live);
587
+ const addedCount = this.objectPool.size - beforeSize;
588
+ const updatedCount = allModels.length - addedCount;
589
+ // ── Phase 3: Reconcile ghost deletions (single MobX action) ──
590
+ // Only reconcile types that were rehydrated — never touch lazy-loaded types.
591
+ const ghostIds = [];
592
+ for (const modelType of modelTypes) {
593
+ const idbIds = idbIdsByType.get(modelType);
594
+ if (!idbIds)
595
+ continue; // Type had an error during fetch — don't reconcile
596
+ const poolIds = this.objectPool.getIdsByModelType(modelType);
597
+ if (!poolIds)
598
+ continue;
599
+ for (const poolId of poolIds) {
600
+ if (!idbIds.has(poolId)) {
601
+ ghostIds.push(poolId);
602
+ }
603
+ }
604
+ }
605
+ const removedCount = this.objectPool.removeBatch(ghostIds);
606
+ // ── Phase 4: Stats & logging ──
607
+ const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
608
+ const elapsedMs = Math.round(totalEnd - totalStart);
609
+ const stats = {
610
+ added: addedCount,
611
+ updated: updatedCount,
612
+ removed: removedCount,
613
+ skipped: skippedCount,
614
+ healed: healedCount,
615
+ elapsedMs,
616
+ };
617
+ getContext().logger.info('[SyncClient.rehydrate] Complete', {
618
+ ...stats,
619
+ poolSize: this.objectPool.size,
620
+ ghostIds: ghostIds.length > 0 ? ghostIds.slice(0, 5).map((id) => id.slice(0, 8)) : [],
621
+ });
622
+ getContext().observability.breadcrumb('Rehydration complete', 'sync.bootstrap', 'info', {
623
+ added: stats.added,
624
+ updated: stats.updated,
625
+ removed: stats.removed,
626
+ elapsedMs: stats.elapsedMs,
627
+ });
628
+ return stats;
629
+ }
630
+ /**
631
+ * Mutate model optimistically and queue for server sync.
632
+ * IndexedDB is only updated when server confirms via delta packet.
633
+ *
634
+ * CRITICAL: Changes are captured BEFORE poolAction to prevent data loss.
635
+ * The captured changes are frozen and passed to queueMutation.
636
+ *
637
+ * @see src/sync-engine/types/TrackableModel.ts for change capture pattern
638
+ */
639
+ mutate(type, model, poolAction, writeOptions) {
640
+ // CRITICAL FIX: Capture changes BEFORE pool action
641
+ // Pool operations (especially upsert) can clear _local changes
642
+ // By capturing first, we ensure changes are never lost
643
+ const capturedChanges = type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
644
+ poolAction();
645
+ this.queueMutation({ type, model, timestamp: new Date(), capturedChanges, writeOptions });
646
+ this.notifyObservers({
647
+ type,
648
+ modelType: model.getModelName(),
649
+ model: type !== 'delete' ? model : undefined,
650
+ modelId: model.id,
651
+ });
652
+ // QueryProcessor uses `models:changed` to invalidate caches. Coalesce
653
+ // to one event per microtask: a paste of 100 layers should re-run
654
+ // affected queries ONCE, not 100×.
655
+ this.markModelChanged(model.getModelName());
656
+ }
657
+ pendingChangedTypes = null;
658
+ markModelChanged(modelType) {
659
+ if (!this.pendingChangedTypes) {
660
+ this.pendingChangedTypes = new Set();
661
+ const schedule = typeof queueMicrotask === 'function'
662
+ ? queueMicrotask
663
+ : (cb) => Promise.resolve().then(cb);
664
+ schedule(() => {
665
+ const types = this.pendingChangedTypes;
666
+ this.pendingChangedTypes = null;
667
+ if (types && types.size > 0)
668
+ this.emit('models:changed', types);
669
+ });
670
+ }
671
+ this.pendingChangedTypes.add(modelType);
672
+ }
673
+ /**
674
+ * Capture model changes immutably BEFORE any pool operations
675
+ * This prevents the fragile pattern of reading changes after state modification
676
+ */
677
+ captureModelChanges(model) {
678
+ if (typeof model.getChanges !== 'function')
679
+ return undefined;
680
+ const changes = model.getChanges();
681
+ // Return a frozen copy to prevent accidental modification
682
+ return Object.keys(changes).length > 0 ? Object.freeze({ ...changes }) : undefined;
683
+ }
684
+ /** Add new model (CREATE) - works offline */
685
+ add(model, options) {
686
+ this.mutate('create', model, () => this.objectPool.add(model, ModelScope.live), options);
687
+ }
688
+ /** Update existing model (UPDATE) - works offline */
689
+ update(model, options) {
690
+ this.mutate('update', model, () => this.objectPool.upsert(model, ModelScope.live), options);
691
+ }
692
+ /**
693
+ * Update existing model with pre-computed changes.
694
+ * Used by saveManyOptimized when incoming models have empty change-tracking
695
+ * (e.g. freshly constructed SpreadsheetCellModels from decomposeSpreadsheetDocument).
696
+ */
697
+ updateWithChanges(model, changes) {
698
+ getContext().logger.debug(`SyncClient.updateWithChanges`, {
699
+ modelId: model.id,
700
+ modelType: model.getModelName(),
701
+ });
702
+ // Use pre-computed changes if provided, otherwise fall back to model.getChanges()
703
+ const capturedChanges = changes && Object.keys(changes).length > 0
704
+ ? Object.freeze({ ...changes })
705
+ : this.captureModelChanges(model);
706
+ this.objectPool.upsert(model, ModelScope.live);
707
+ this.queueMutation({ type: 'update', model, timestamp: new Date(), capturedChanges });
708
+ this.notifyObservers({
709
+ type: 'update',
710
+ modelType: model.getModelName(),
711
+ model,
712
+ modelId: model.id,
713
+ });
714
+ }
715
+ /** Expose the GraphQL client for atomic mutations (e.g., createSlideWithLayers).
716
+ * Used by SyncedStore for operations that bypass the transaction queue
717
+ * but still need optimistic pool updates at the sync layer. */
718
+ get gql() {
719
+ return this.mutationExecutor;
720
+ }
721
+ /** Delete model (DELETE) - works offline */
722
+ delete(model, options) {
723
+ // Clear pending mutations first to prevent "not found" errors on fast delete
724
+ this.clearPendingMutationsForModel(model.id);
725
+ this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
726
+ }
727
+ /**
728
+ * Clear all pending mutations for a specific model
729
+ * Called before deletion to prevent "layer not found" errors on the server
730
+ */
731
+ clearPendingMutationsForModel(modelId) {
732
+ const beforeCount = this.pendingMutations.length;
733
+ this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
734
+ const afterCount = this.pendingMutations.length;
735
+ if (beforeCount !== afterCount) {
736
+ getContext().logger.debug('[SyncClient.clearPendingMutationsForModel] Cleared pending mutations', {
737
+ modelId,
738
+ clearedCount: beforeCount - afterCount,
739
+ remainingCount: afterCount,
740
+ });
741
+ // Persist updated queue immediately
742
+ void this.persistMutationQueue();
743
+ }
744
+ }
745
+ /**
746
+ * Upload file and create attachment (UPLOAD operation)
747
+ * Uses Linear-style pattern with immediate URL generation
748
+ */
749
+ async uploadFile(file, options) {
750
+ if (!this.userId || !this.organizationId) {
751
+ throw new AbloAuthenticationError('Authentication required for file uploads', {
752
+ code: 'file_upload_auth_required',
753
+ });
754
+ }
755
+ try {
756
+ // Use TransactionQueue to handle the upload mutation
757
+ const result = await this.transactionQueue.uploadAttachment(file, {
758
+ id: options.id,
759
+ attachableType: options.attachableType,
760
+ attachableId: options.attachableId,
761
+ metadata: options.metadata,
762
+ }, {
763
+ userId: this.userId,
764
+ organizationId: this.organizationId,
765
+ });
766
+ if (result) {
767
+ // Create model from response using ModelRegistry (generic — no concrete class import)
768
+ const model = this.objectPool.createFromData({
769
+ id: options.id,
770
+ ...result,
771
+ });
772
+ if (model) {
773
+ this.objectPool.add(model, ModelScope.live);
774
+ this.notifyObservers({
775
+ type: 'create',
776
+ modelType: model.getModelName(),
777
+ model,
778
+ });
779
+ return model;
780
+ }
781
+ }
782
+ return null;
783
+ }
784
+ catch (error) {
785
+ getContext().observability.captureTransactionFailure({
786
+ context: 'file-upload',
787
+ error: error instanceof Error ? error : new Error(String(error)),
788
+ });
789
+ throw error;
790
+ }
791
+ }
792
+ /**
793
+ * Batch upload files — single GraphQL call + parallel S3 PUTs.
794
+ *
795
+ * Returns the raw `Model[]` built by the object pool (typename is
796
+ * determined by the payload the server returns — currently always
797
+ * `Attachment`). The SDK has no knowledge of app-specific model classes,
798
+ * so it cannot honestly claim a narrower return type; consumers that
799
+ * need an `Attachment[]` project through their own typed accessor
800
+ * (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
801
+ * after the upload resolves.
802
+ */
803
+ async batchUploadFiles(files, options) {
804
+ if (!this.userId || !this.organizationId) {
805
+ throw new AbloAuthenticationError('Authentication required for file uploads', {
806
+ code: 'file_upload_auth_required',
807
+ });
808
+ }
809
+ const items = options.ids.map((id) => ({
810
+ id,
811
+ attachableType: options.attachableType,
812
+ attachableId: options.attachableId,
813
+ metadata: options.metadata,
814
+ }));
815
+ const results = await this.transactionQueue.batchUploadAttachments(files, items, {
816
+ userId: this.userId,
817
+ organizationId: this.organizationId,
818
+ });
819
+ const models = [];
820
+ for (const result of results) {
821
+ const model = this.objectPool.createFromData({ ...result });
822
+ if (model) {
823
+ this.objectPool.add(model, ModelScope.live);
824
+ this.notifyObservers({
825
+ type: 'create',
826
+ modelType: model.getModelName(),
827
+ model,
828
+ });
829
+ models.push(model);
830
+ }
831
+ }
832
+ return models;
833
+ }
834
+ /** Archive model (ARCHIVE) - works offline */
835
+ archive(model) {
836
+ this.mutate('archive', model, () => this.objectPool.updateScope(model.id, ModelScope.archived));
837
+ }
838
+ /**
839
+ * Append a mutation and schedule its sync work.
840
+ *
841
+ * IDB persistence and the server push are deferred to a microtask so N
842
+ * pushes inside the same tick collapse into ONE IDB serialization + ONE
843
+ * process call. Without the deferral, queueing 100 mutations (paste,
844
+ * PPTX import, AI sandbox layer creation) reserializes the entire
845
+ * growing queue 100× — O(N²) `model.toJSON()`.
846
+ *
847
+ * @param mutation.capturedChanges - Pre-captured changes (frozen), used
848
+ * to avoid re-reading changes after pool ops that might clear them.
849
+ */
850
+ queueMutation(mutation) {
851
+ this.pendingMutations.push(mutation);
852
+ this.scheduleSync();
853
+ }
854
+ syncScheduled = false;
855
+ scheduleSync() {
856
+ if (this.syncScheduled)
857
+ return;
858
+ this.syncScheduled = true;
859
+ const schedule = typeof queueMicrotask === 'function'
860
+ ? queueMicrotask
861
+ : (cb) => Promise.resolve().then(cb);
862
+ schedule(() => {
863
+ this.syncScheduled = false;
864
+ void this.persistMutationQueue();
865
+ if (getContext().onlineStatus.isOnline()) {
866
+ this.processPendingMutations().catch((err) => {
867
+ getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
868
+ });
869
+ }
870
+ });
871
+ }
872
+ /**
873
+ * Persist mutation queue to IndexedDB
874
+ */
875
+ async persistMutationQueue() {
876
+ if (!this.database || !this.userId)
877
+ return;
878
+ try {
879
+ const serializedMutations = this.pendingMutations.map((m) => ({
880
+ type: m.type,
881
+ modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
882
+ modelName: m.model.getModelName(),
883
+ timestamp: m.timestamp.toISOString(),
884
+ writeOptions: m.writeOptions,
885
+ }));
886
+ await this.database.saveTransaction({
887
+ id: 'mutation-queue',
888
+ type: 'queue',
889
+ mutations: serializedMutations,
890
+ timestamp: Date.now(),
891
+ });
892
+ }
893
+ catch (error) { }
894
+ }
895
+ /**
896
+ * Restore mutation queue from IndexedDB
897
+ */
898
+ async restoreMutationQueue() {
899
+ if (!this.database || !this.userId)
900
+ return;
901
+ try {
902
+ const stored = await this.database.getPersistedTransactions();
903
+ const queue = stored.find((t) => t.id === 'mutation-queue');
904
+ if (queue?.mutations) {
905
+ for (const mutation of queue.mutations) {
906
+ const model = this.objectPool.createFromData(mutation.modelData);
907
+ if (model) {
908
+ this.pendingMutations.push({
909
+ type: mutation.type,
910
+ model,
911
+ timestamp: new Date(mutation.timestamp),
912
+ writeOptions: mutation.writeOptions,
913
+ });
914
+ }
915
+ }
916
+ }
917
+ }
918
+ catch (error) { }
919
+ }
920
+ /**
921
+ * Process pending mutations - can be called by SyncedStore when online
922
+ *
923
+ * Best Practice: Only sync models that still exist locally (local-first principle)
924
+ * - If a model was deleted locally → skip any pending updates/creates for it
925
+ * - This prevents "layer not found" errors from fast copy-paste-delete workflows
926
+ */
927
+ async processPendingMutations() {
928
+ if (this.pendingMutations.length === 0)
929
+ return;
930
+ // Identity guard. The early returns here used to be silent — the bug
931
+ // pattern was "every mutation from a logged-in user evaporates" when
932
+ // `SyncClient.initialize()` wasn't called (e.g., missing wiring in
933
+ // the consumer's `BaseSyncedStore.initialize` generator). Warn so
934
+ // this class of misconfiguration surfaces in dev instead of
935
+ // manifesting as "my drag doesn't save."
936
+ if (!this.userId || !this.organizationId) {
937
+ getContext().logger.warn('[sync] mutations dropped — SyncClient has no identity. ' +
938
+ 'Did the store call `syncClient.initialize(userId, orgId)`?', {
939
+ pending: this.pendingMutations.length,
940
+ userId: this.userId,
941
+ organizationId: this.organizationId,
942
+ });
943
+ return;
944
+ }
945
+ if (!getContext().onlineStatus.isOnline())
946
+ return; // Skip if offline
947
+ if (this.isDisposed)
948
+ return; // Skip if disposed
949
+ const mutations = this.pendingMutations;
950
+ this.pendingMutations = [];
951
+ // Clear persisted queue before processing
952
+ await this.persistMutationQueue();
953
+ // LINEAR PATTERN: Stage all mutations synchronously in same event loop tick
954
+ // TransactionQueue's microtask will batch and send them together
955
+ for (const mutation of mutations) {
956
+ // Skip mutations for deleted models (prevents "not found" errors)
957
+ if (mutation.type !== 'delete' && !this.objectPool.get(mutation.model.id)) {
958
+ continue;
959
+ }
960
+ // Stage synchronously - TransactionQueue handles batching, retry, and errors
961
+ this.stageMutation(mutation);
962
+ }
963
+ }
964
+ /**
965
+ * Stage mutation to TransactionQueue - mutations in same tick are batched via microtask
966
+ *
967
+ * @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
968
+ */
969
+ stageMutation(mutation) {
970
+ if (!this.userId || !this.organizationId)
971
+ return;
972
+ const ctx = { userId: this.userId, organizationId: this.organizationId };
973
+ if (mutation.type === 'update') {
974
+ this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions);
975
+ }
976
+ else {
977
+ const handler = this.transactionQueue[mutation.type].bind(this.transactionQueue);
978
+ handler(mutation.model, ctx, mutation.writeOptions);
979
+ }
980
+ }
981
+ /**
982
+ * Resolve conflicts between local and server data
983
+ * Used when processing deltas from WebSocket
984
+ *
985
+ * CRITICAL: Always respects certain server states (deletes, deactivations)
986
+ * even when there are local changes, to maintain data consistency.
987
+ */
988
+ resolveConflicts(localModel, serverData) {
989
+ const hasLocalChanges = localModel.hasChanges;
990
+ // Safely get timestamp, handling both Date objects and strings
991
+ const localUpdatedAt = localModel.updatedAt
992
+ ? localModel.updatedAt instanceof Date
993
+ ? localModel.updatedAt.getTime()
994
+ : new Date(localModel.updatedAt).getTime()
995
+ : 0;
996
+ const serverUpdatedAt = serverData?.updatedAt ? new Date(serverData.updatedAt).getTime() : 0;
997
+ getContext().logger.debug('Conflict resolution', {
998
+ modelId: localModel.id,
999
+ modelType: localModel.getModelName(),
1000
+ hasLocalChanges,
1001
+ localUpdatedAt: localModel.updatedAt?.toString(),
1002
+ serverUpdatedAt: serverData.updatedAt,
1003
+ localChanges: localModel.getChanges(),
1004
+ serverState: this.extractCriticalState(serverData),
1005
+ });
1006
+ // PRIORITY 1: Check for critical server states that must be respected
1007
+ // These states override any local changes to maintain data consistency
1008
+ const criticalServerStates = this.extractCriticalState(serverData);
1009
+ const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
1010
+ if (shouldForceAcceptServer) {
1011
+ getContext().logger.debug('Accepting server update - critical state change detected', {
1012
+ modelId: localModel.id,
1013
+ criticalStates: criticalServerStates,
1014
+ });
1015
+ // Force accept server state for critical changes
1016
+ localModel.updateFromData(serverData);
1017
+ localModel.clearChanges();
1018
+ localModel.markAsSynced();
1019
+ return localModel;
1020
+ }
1021
+ // Local-first: if we have local dirty fields, merge by field.
1022
+ // Keep locally changed fields; apply server for the rest.
1023
+ if (hasLocalChanges) {
1024
+ const localChanges = localModel.getChanges();
1025
+ getContext().logger.debug('Merging server update with local dirty fields', {
1026
+ modelId: localModel.id,
1027
+ keptFields: Object.keys(localChanges || {}),
1028
+ });
1029
+ // Merge: server baseline + local dirty fields win
1030
+ const merged = { ...serverData, ...(localChanges || {}) };
1031
+ // Preserve the most recent updatedAt without clearing dirty flags
1032
+ if (serverData?.updatedAt || localModel.updatedAt) {
1033
+ const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1034
+ // updateFromData accepts Date or ISO string for dates
1035
+ merged.updatedAt = mergedUpdatedAt;
1036
+ }
1037
+ localModel.updateFromData(merged);
1038
+ // Intentionally DO NOT clearChanges here; pending tx will confirm and clear
1039
+ return localModel;
1040
+ }
1041
+ // No local changes: fall back to LWW to converge
1042
+ // Accept server regardless of timestamp equality to stay in sync
1043
+ const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
1044
+ getContext().logger.debug(`Accepting server update - ${acceptReason}`);
1045
+ localModel.updateFromData(serverData);
1046
+ localModel.clearChanges();
1047
+ localModel.markAsSynced();
1048
+ return localModel;
1049
+ }
1050
+ /**
1051
+ * Extract critical state fields from server data
1052
+ * These are states that must always be respected, even with local changes
1053
+ */
1054
+ extractCriticalState(serverData) {
1055
+ const critical = {};
1056
+ if (!serverData || typeof serverData !== 'object') {
1057
+ return critical;
1058
+ }
1059
+ // Deletion/archival states - always critical
1060
+ if (serverData.deletedAt !== undefined) {
1061
+ critical.deletedAt = serverData.deletedAt;
1062
+ }
1063
+ if (serverData.archivedAt !== undefined) {
1064
+ critical.archivedAt = serverData.archivedAt;
1065
+ }
1066
+ // Deactivation states - critical for assignments and similar entities
1067
+ if (serverData.isActive !== undefined && serverData.isActive === false) {
1068
+ critical.isActive = false;
1069
+ }
1070
+ if (serverData.unassignedAt !== undefined) {
1071
+ critical.unassignedAt = serverData.unassignedAt;
1072
+ }
1073
+ return critical;
1074
+ }
1075
+ /**
1076
+ * Check if critical state changes exist that require forcing server state
1077
+ */
1078
+ hasCriticalStateChange(criticalStates) {
1079
+ // Any critical state present means we should force accept server
1080
+ return (Object.keys(criticalStates).length > 0 &&
1081
+ Object.values(criticalStates).some((v) => v !== null && v !== undefined));
1082
+ }
1083
+ /**
1084
+ * Handle network reconnection
1085
+ */
1086
+ async handleReconnection() {
1087
+ getContext().observability.breadcrumb('Network reconnected', 'sync.offline');
1088
+ this.emit('sync:reconnecting');
1089
+ try {
1090
+ // Prefer a single batch flush for pending mutations (fast path)
1091
+ try {
1092
+ await this.transactionQueue.flushOfflineQueue();
1093
+ }
1094
+ catch { }
1095
+ // Process all queued mutations
1096
+ await this.processPendingMutations();
1097
+ this.setConnectionState('connected');
1098
+ this.emit('sync:reconnected');
1099
+ // Clear offline timestamp
1100
+ this.offlineSince = undefined;
1101
+ }
1102
+ catch (error) {
1103
+ getContext().observability.captureTransactionFailure({
1104
+ context: 'reconnection-sync',
1105
+ error: error instanceof Error ? error : new Error(String(error)),
1106
+ });
1107
+ this.emit('sync:error', error);
1108
+ }
1109
+ }
1110
+ /**
1111
+ * Handle network disconnection
1112
+ */
1113
+ async handleDisconnection() {
1114
+ getContext().observability.breadcrumb('Network disconnected', 'sync.offline');
1115
+ this.setConnectionState('disconnected');
1116
+ this.offlineSince = new Date();
1117
+ this.emit('sync:offline');
1118
+ }
1119
+ /**
1120
+ * Get current sync state
1121
+ */
1122
+ getState() {
1123
+ return {
1124
+ connectionState: this.connectionState,
1125
+ pendingMutations: this.pendingMutations.length,
1126
+ lastSyncAt: new Date(),
1127
+ error: undefined,
1128
+ };
1129
+ }
1130
+ /**
1131
+ * Set connection state
1132
+ */
1133
+ setConnectionState(state) {
1134
+ const oldState = this.connectionState;
1135
+ this.connectionState = state;
1136
+ if (oldState !== state) {
1137
+ getContext().observability.setConnectionState(state);
1138
+ getContext().observability.breadcrumb(`Connection: ${oldState} → ${state}`, 'sync.websocket');
1139
+ if (state === 'connected') {
1140
+ this.emit('connection:established');
1141
+ this.transactionQueue.setConnectionState('connected');
1142
+ }
1143
+ else if (state === 'disconnected') {
1144
+ this.emit('connection:disconnected');
1145
+ this.transactionQueue.setConnectionState('disconnected');
1146
+ }
1147
+ }
1148
+ }
1149
+ /**
1150
+ * Subscribe to events with disposer pattern
1151
+ */
1152
+ subscribe(event, handler) {
1153
+ super.on(event, handler);
1154
+ // Return disposer function
1155
+ return () => {
1156
+ this.off(event, handler);
1157
+ };
1158
+ }
1159
+ /**
1160
+ * Add observer for sync events
1161
+ */
1162
+ addObserver(observer) {
1163
+ this.observers.add(observer);
1164
+ }
1165
+ /**
1166
+ * Remove observer
1167
+ */
1168
+ removeObserver(observer) {
1169
+ this.observers.delete(observer);
1170
+ }
1171
+ /**
1172
+ * Notify all observers
1173
+ */
1174
+ notifyObservers(event) {
1175
+ for (const observer of this.observers) {
1176
+ if (observer.onSync) {
1177
+ try {
1178
+ observer.onSync(event);
1179
+ }
1180
+ catch (error) {
1181
+ getContext().observability.breadcrumb('Observer error', 'sync.transaction', 'error', {
1182
+ error: error instanceof Error ? error.message : String(error),
1183
+ });
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Disconnect from sync
1190
+ */
1191
+ disconnect() {
1192
+ this.setConnectionState('disconnected');
1193
+ }
1194
+ /**
1195
+ * Mark the sync client as connected
1196
+ * Called when WebSocket successfully connects (can happen independently of browser online/offline)
1197
+ */
1198
+ markConnected() {
1199
+ this.setConnectionState('connected');
1200
+ }
1201
+ /**
1202
+ * Dispose and cleanup
1203
+ */
1204
+ dispose() {
1205
+ this.isDisposed = true;
1206
+ this.disconnect();
1207
+ this.networkMonitor.dispose();
1208
+ this.observers.clear();
1209
+ this.pendingMutations = [];
1210
+ this.removeAllListeners();
1211
+ }
1212
+ /**
1213
+ * LINEAR PATTERN: Notify TransactionQueue of incoming delta for sync ID threshold confirmation.
1214
+ * Transactions are confirmed when any delta with id >= their lastSyncId threshold arrives.
1215
+ * @param syncId - The sync ID of the received delta
1216
+ */
1217
+ onDeltaReceived(syncId) {
1218
+ try {
1219
+ this.transactionQueue.onDeltaReceived(syncId);
1220
+ }
1221
+ catch (e) {
1222
+ getContext().observability.breadcrumb('Failed to notify delta received', 'sync.transaction', 'warning', {
1223
+ syncId,
1224
+ });
1225
+ }
1226
+ }
1227
+ /**
1228
+ * LINEAR PATTERN: Cancel transactions for orphaned child entities
1229
+ *
1230
+ * Called by SyncedStore when a DELETE delta arrives for a parent entity.
1231
+ * Cancels pending transactions for children that reference the deleted parent.
1232
+ *
1233
+ * @param childModelName - The child model type (e.g., 'SlideLayer')
1234
+ * @param foreignKey - The FK property name (e.g., 'slideId')
1235
+ * @param parentId - The deleted parent's ID
1236
+ * @returns Number of transactions cancelled
1237
+ */
1238
+ cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
1239
+ return this.transactionQueue.cancelTransactionsByForeignKey(childModelName, foreignKey, parentId);
1240
+ }
1241
+ /**
1242
+ * Wait for a transaction to be confirmed via delta echo (Linear pattern)
1243
+ * Delegates to TransactionQueue which already handles timeouts
1244
+ */
1245
+ waitForDeltaConfirmation(transactionId) {
1246
+ return this.transactionQueue.waitForConfirmation(transactionId);
1247
+ }
1248
+ /**
1249
+ * Force sync now - process pending mutations
1250
+ */
1251
+ async syncNow() {
1252
+ await this.processPendingMutations();
1253
+ }
1254
+ /**
1255
+ * Get sync statistics. Return type is inferred from the literal so
1256
+ * the call site sees the actual shape — `connectionState` narrowed
1257
+ * to its three states, `objectPoolStats` typed by `ObjectPool.getStats`.
1258
+ */
1259
+ getSyncStats() {
1260
+ return {
1261
+ connectionState: this.connectionState,
1262
+ pendingMutations: this.pendingMutations.length,
1263
+ objectPoolStats: this.objectPool.getStats(),
1264
+ };
1265
+ }
1266
+ /**
1267
+ * Get pending transaction count from TransactionQueue
1268
+ * Used by SyncedStore to compute hasUnsyncedChanges
1269
+ */
1270
+ getPendingTransactionCount() {
1271
+ const stats = this.transactionQueue.getStats();
1272
+ // Include pending and executing as "unsynced"
1273
+ // awaiting_delta transactions are included in 'executing' until confirmed
1274
+ // Completed and failed are "synced" (either done or gave up)
1275
+ return stats.pending + stats.executing;
1276
+ }
1277
+ /**
1278
+ * Subscribe to transaction events for sync status tracking
1279
+ * Returns unsubscribe function
1280
+ */
1281
+ onTransactionEvent(event, callback) {
1282
+ const eventName = `transaction:${event}`;
1283
+ this.transactionQueue.on(eventName, callback);
1284
+ return () => this.transactionQueue.off(eventName, callback);
1285
+ }
1286
+ /**
1287
+ * Subscribe to mutation failures with the full payload. Mirrors the
1288
+ * underlying TransactionQueue 'transaction:failed' shape so consumers
1289
+ * can render typed UI (toast keyed by `AbloError.type`, route-level
1290
+ * "this entity reverted" boundaries, telemetry).
1291
+ *
1292
+ * Distinct from `onTransactionEvent('failed', cb)`, which exists only
1293
+ * for the legacy parameterless `pendingChanges` counter and intentionally
1294
+ * drops the payload. The two coexist — keep the counter callback fast
1295
+ * and the typed listener for user-visible surfaces.
1296
+ */
1297
+ onMutationFailure(listener) {
1298
+ this.transactionQueue.on('transaction:failed', listener);
1299
+ return () => this.transactionQueue.off('transaction:failed', listener);
1300
+ }
1301
+ /**
1302
+ * Wait for the latest in-flight transaction for (modelName, modelId)
1303
+ * to be confirmed by the server, or reject if it's rolled back.
1304
+ * Resolves immediately when no transaction is in flight — see
1305
+ * `TransactionQueue.confirmationFor` for the lookup contract.
1306
+ *
1307
+ * Distinct from `waitForDeltaConfirmation(transactionId)` which keys
1308
+ * off a known tx id; this variant is for call sites that hold a
1309
+ * Model reference but never see the underlying transaction.
1310
+ */
1311
+ waitForConfirmation(modelName, modelId) {
1312
+ return this.transactionQueue.confirmationFor(modelName, modelId);
1313
+ }
1314
+ /**
1315
+ * Get detailed debug info for the sync debug page
1316
+ */
1317
+ getDebugInfo() {
1318
+ return {
1319
+ connectionState: this.connectionState,
1320
+ pendingMutationsCount: this.pendingMutations.length,
1321
+ transactionQueue: this.transactionQueue.getDebugInfo(),
1322
+ };
1323
+ }
1324
+ // --- Best-practice assignment ops ---
1325
+ async unassignEntity(entityType, entityId) {
1326
+ // Call server-side unassign to avoid per-id races
1327
+ await this.mutationExecutor.executeDelete('Assignment', entityId);
1328
+ }
1329
+ async reassignEntity(entityType, entityId, assigneeType, assigneeId, id) {
1330
+ await this.mutationExecutor.executeCreate('Assignment', id || '', {
1331
+ entityType,
1332
+ entityId,
1333
+ assigneeType,
1334
+ assigneeId,
1335
+ });
1336
+ }
1337
+ // ── Delta + Bootstrap application (owns ObjectPool writes) ──────────────
1338
+ /**
1339
+ * Apply a batch of delta results from Database to the ObjectPool.
1340
+ * Owns: model creation, upsert, remove, archive, conflict resolution.
1341
+ * Returns: nothing — ObjectPool is updated in place.
1342
+ */
1343
+ /**
1344
+ * Mark a local transaction as optimistically applied. The matching
1345
+ * server delta (when it arrives with the same `transactionId`) will
1346
+ * be recognized as an echo and skip the pool mutation. Called
1347
+ * automatically by `TransactionQueue` when a transaction is staged;
1348
+ * exposed publicly so tests can drive the API directly.
1349
+ */
1350
+ markTransactionPending(transactionId) {
1351
+ this.echoTracker.markPending(transactionId);
1352
+ }
1353
+ /**
1354
+ * Read echo-detection counters: hits, rollbacks, evictions, and the
1355
+ * current pending-set size. Surfaced for production observability
1356
+ * — a sustained `evictions > 0` rate or `rollbacks` spike is a
1357
+ * health signal worth alerting on.
1358
+ */
1359
+ getEchoMetrics() {
1360
+ return this.echoTracker.getMetrics();
1361
+ }
1362
+ /**
1363
+ * Package-internal accessor for the TransactionQueue. Used by
1364
+ * `Ablo.commits.create()` to route raw multi-op envelopes through the
1365
+ * same retry-on-reconnect lane as the Model proxy path, and by tests
1366
+ * to exercise the queue ↔ markTransactionPending wiring on the real
1367
+ * instance the SyncClient subscribes to. NOT re-exported to SDK
1368
+ * consumers — `Ablo` itself is the public surface.
1369
+ */
1370
+ getTransactionQueue() {
1371
+ return this.transactionQueue;
1372
+ }
1373
+ applyDeltaBatchToPool(dbResults, enrichRelations) {
1374
+ const modelsToAdd = [];
1375
+ const modelsToUpsert = [];
1376
+ const idsToRemove = [];
1377
+ const idsToArchive = [];
1378
+ // Pre-pass: collect every id slated for `remove` in this batch. The
1379
+ // chart-delete flicker came from this exact pattern: a peer (or the
1380
+ // user themself) deletes a chart with N layers; the commit produces
1381
+ // BOTH residual `update` deltas (from the optimistic edits that
1382
+ // happened just before the delete) AND `remove` deltas. The
1383
+ // `update` branch below would `createFromData` the row back into
1384
+ // the pool when `existing` was already gone (optimistic remove
1385
+ // happened), and the next loop iteration's `remove` would strip
1386
+ // it again — net effect: pool transitions live → gone → live →
1387
+ // gone in one tick, which the renderer catches mid-frame as a
1388
+ // flicker. Filter ops on doomed ids before they touch the pool.
1389
+ const idsBeingRemoved = new Set();
1390
+ for (const r of dbResults) {
1391
+ if (r.action === 'remove')
1392
+ idsBeingRemoved.add(r.modelId);
1393
+ }
1394
+ for (const result of dbResults) {
1395
+ const { modelName, modelId, action, transactionId } = result;
1396
+ // ECHO DETECTION. If this delta carries a transaction id that
1397
+ // matches one we've optimistically applied locally, the pool
1398
+ // already reflects this mutation — skip the pool op. The
1399
+ // upstream IDB write (in `Database.processDeltaBatch`) still
1400
+ // runs; only the in-memory pool mutation is suppressed. This is
1401
+ // the architectural fix for the chart-delete flicker: a
1402
+ // server-confirmed CREATE arriving AFTER the user has
1403
+ // optimistically deleted the row would otherwise re-add the row
1404
+ // for the ~2s window before the matching DELETE confirmation
1405
+ // lands. See `OPTIMISTIC_RECONCILIATION.md` for the framing.
1406
+ if (this.echoTracker.consumeEcho(transactionId)) {
1407
+ continue;
1408
+ }
1409
+ // If a later op in this batch will remove this id, skip earlier
1410
+ // add/update ops on it. Server FK ordering can produce
1411
+ // U(layer)+D(layer) when an optimistic edit and a delete both
1412
+ // commit in the same window; only the final state matters.
1413
+ if ((action === 'add' || action === 'update') && idsBeingRemoved.has(modelId)) {
1414
+ continue;
1415
+ }
1416
+ switch (action) {
1417
+ case 'add': {
1418
+ const existing = this.objectPool.get(modelId);
1419
+ if (existing) {
1420
+ existing.markAsSynced();
1421
+ }
1422
+ else if (result.data) {
1423
+ const data = enrichRelations(modelName, { ...result.data, __typename: modelName });
1424
+ const model = this.objectPool.createFromData(data);
1425
+ if (model)
1426
+ modelsToAdd.push(model);
1427
+ }
1428
+ break;
1429
+ }
1430
+ case 'update': {
1431
+ const existing = this.objectPool.get(modelId);
1432
+ if (existing && !existing.disposed && result.data) {
1433
+ enrichRelations(modelName, result.data);
1434
+ const resolved = this.resolveConflicts(existing, result.data);
1435
+ modelsToUpsert.push(resolved);
1436
+ }
1437
+ // Resurrection drop: if `existing` is gone (optimistic delete
1438
+ // discarded it; the matching D delta is in-flight) we used
1439
+ // to call `createFromData` here, which reintroduced the row
1440
+ // for a frame before the D delta stripped it again — the
1441
+ // chart-delete flicker. Trust the local state. If the server
1442
+ // still considers the row alive, a subsequent bootstrap or
1443
+ // resync will reconcile.
1444
+ break;
1445
+ }
1446
+ case 'remove':
1447
+ idsToRemove.push(modelId);
1448
+ break;
1449
+ case 'archive':
1450
+ idsToArchive.push(modelId);
1451
+ break;
1452
+ case 'verify':
1453
+ // `verify` is `Database.processDeltaBatch`'s signal for a delta
1454
+ // whose IDB store transaction FAILED. Pool isn't updated for
1455
+ // this delta — by design, since the persisted view doesn't
1456
+ // reflect it either — and the persistence-gated cursor in
1457
+ // `BaseSyncedStore.flushPendingDeltas` will NOT ack past it,
1458
+ // so the next 30s catch-up poll (or reconnect handshake) will
1459
+ // re-fetch and re-apply. Logged here so silent IDB failures
1460
+ // are observable instead of disappearing into a default switch
1461
+ // fall-through.
1462
+ getContext().logger.warn('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
1463
+ modelName,
1464
+ modelId: modelId.slice(0, 12),
1465
+ });
1466
+ break;
1467
+ }
1468
+ }
1469
+ // Batch ObjectPool mutations — minimal MobX actions
1470
+ if (modelsToAdd.length > 0)
1471
+ this.objectPool.addBatch(modelsToAdd, ModelScope.live);
1472
+ if (modelsToUpsert.length > 0)
1473
+ this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
1474
+ if (idsToRemove.length > 0)
1475
+ this.objectPool.removeBatch(idsToRemove);
1476
+ for (const id of idsToArchive)
1477
+ this.objectPool.updateScope(id, ModelScope.archived);
1478
+ // Emit changed model types so QueryProcessor can auto-invalidate
1479
+ const changedTypes = new Set(dbResults.map(r => r.modelName));
1480
+ if (changedTypes.size > 0)
1481
+ this.emit('models:changed', changedTypes);
1482
+ }
1483
+ /**
1484
+ * Apply bootstrap data to the ObjectPool with ghost removal.
1485
+ * Owns: model creation, batch upsert, ghost detection + removal.
1486
+ */
1487
+ applyBootstrapDataToPool(bootstrapData, protectedIds) {
1488
+ if (!bootstrapData.models) {
1489
+ return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
1490
+ }
1491
+ const allModels = [];
1492
+ const serverIdsByType = new Map();
1493
+ let healedCount = 0;
1494
+ let skippedCount = 0;
1495
+ const failedTypes = new Set(bootstrapData.failedModels ?? []);
1496
+ for (const [modelType, records] of Object.entries(bootstrapData.models)) {
1497
+ if (failedTypes.has(modelType))
1498
+ continue;
1499
+ const idsForType = new Set();
1500
+ serverIdsByType.set(modelType, idsForType);
1501
+ if (!Array.isArray(records) || records.length === 0)
1502
+ continue;
1503
+ for (const rawRecord of records) {
1504
+ if (!rawRecord || typeof rawRecord !== 'object') {
1505
+ skippedCount++;
1506
+ continue;
1507
+ }
1508
+ let data = rawRecord;
1509
+ if (!data.__typename)
1510
+ data = { __typename: modelType, ...data };
1511
+ const healResult = this.healModelRecord(modelType, data);
1512
+ if (healResult === null) {
1513
+ skippedCount++;
1514
+ continue;
1515
+ }
1516
+ data = healResult.data;
1517
+ if (healResult.healed)
1518
+ healedCount++;
1519
+ const recordId = data.id;
1520
+ if (recordId)
1521
+ idsForType.add(recordId);
1522
+ try {
1523
+ const model = this.objectPool.createFromData(data);
1524
+ if (model)
1525
+ allModels.push(model);
1526
+ }
1527
+ catch {
1528
+ skippedCount++;
1529
+ }
1530
+ }
1531
+ }
1532
+ // Batch upsert
1533
+ const beforeSize = this.objectPool.size;
1534
+ this.objectPool.upsertBatch(allModels, ModelScope.live);
1535
+ const addedCount = this.objectPool.size - beforeSize;
1536
+ const updatedCount = allModels.length - addedCount;
1537
+ // Ghost removal — remove pool entities not in server snapshot
1538
+ const ghostIds = [];
1539
+ for (const [modelType, serverIds] of serverIdsByType) {
1540
+ const poolIds = this.objectPool.getIdsByModelType(modelType);
1541
+ if (!poolIds)
1542
+ continue;
1543
+ for (const poolId of poolIds) {
1544
+ if (!serverIds.has(poolId) && !protectedIds?.has(poolId))
1545
+ ghostIds.push(poolId);
1546
+ }
1547
+ }
1548
+ const removedCount = this.objectPool.removeBatch(ghostIds);
1549
+ // Emit changed model types so QueryProcessor can auto-invalidate
1550
+ const changedTypes = new Set(Object.keys(bootstrapData.models));
1551
+ if (changedTypes.size > 0)
1552
+ this.emit('models:changed', changedTypes);
1553
+ return { added: addedCount, updated: updatedCount, removed: removedCount, skipped: skippedCount, healed: healedCount };
1554
+ }
1555
+ }