@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,1895 @@
1
+ /**
2
+ * TransactionQueue - Production-ready transaction management
3
+ *
4
+ * Key features:
5
+ * - Optimistic updates with rollback
6
+ * - Conflict resolution strategies
7
+ * - LINEAR-style microtask batching (transactions in same event loop share batchId)
8
+ * - Proper dependency injection (no singleton)
9
+ */
10
+ import { EventEmitter } from 'events';
11
+ import { getContext } from '../context.js';
12
+ import { getActiveRegistry } from '../ModelRegistry.js';
13
+ import { MutationOperationType } from '../types/index.js';
14
+ import { handleMutationError } from './mutation-error-handler.js';
15
+ import { AbloError, AbloConnectionError } from '../errors.js';
16
+ /**
17
+ * Framework-internal keys added by `Model.toJSON()` that must never
18
+ * reach the wire. The server treats each top-level key as a target
19
+ * column, so shipping these would blow up the INSERT/UPDATE.
20
+ */
21
+ const FRAMEWORK_KEYS = new Set(['__class', '__typename', 'clientId', 'syncStatus']);
22
+ /**
23
+ * Project a Model's serialized data onto its schema-declared fields
24
+ * and return a wire-safe commit payload. Two jobs:
25
+ *
26
+ * 1. Drop framework internals (`__class`, `__typename`, `clientId`,
27
+ * `syncStatus`) and anything not declared on the model's schema.
28
+ * 2. JSON.stringify values typed as `field.json()` — TEXT columns
29
+ * storing JSON need explicit stringification; postgres.js won't
30
+ * auto-serialize for non-JSONB columns.
31
+ *
32
+ * For updates (`dropUndefined: true`), `undefined` values are also
33
+ * stripped so they don't translate to `SET column = NULL` on the
34
+ * server side.
35
+ *
36
+ * Fields are read from `ModelRegistry`, populated by
37
+ * `registerModelsFromSchema` at SDK initialization. If the model
38
+ * isn't registered with field metadata (edge case — e.g., tests or
39
+ * manually registered models), projection falls back to identity and
40
+ * the caller gets whatever the Model serialized.
41
+ */
42
+ function projectCommitPayload(modelName, source, opts) {
43
+ const metadata = getActiveRegistry().getMetadata(modelName);
44
+ const fields = metadata?.fields;
45
+ const out = {};
46
+ if (!fields) {
47
+ // Unknown registration — strip framework keys and ship the rest.
48
+ for (const [k, v] of Object.entries(source)) {
49
+ if (FRAMEWORK_KEYS.has(k))
50
+ continue;
51
+ if (opts.dropUndefined && v === undefined)
52
+ continue;
53
+ out[k] = v;
54
+ }
55
+ return out;
56
+ }
57
+ for (const [key, meta] of Object.entries(fields)) {
58
+ if (!(key in source))
59
+ continue;
60
+ const value = source[key];
61
+ if (opts.dropUndefined && value === undefined)
62
+ continue;
63
+ // JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
64
+ // the wire, not pre-stringified strings. Previously we stringified
65
+ // here, which round-tripped incorrectly:
66
+ //
67
+ // 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
68
+ // 2. Server writes to jsonb column (parses string → jsonb object, fine)
69
+ // 3. Server's delta echoes `data: JSON.stringify(op.input)` where
70
+ // `op.input.position` is still the STRING from step 1
71
+ // 4. Client merges delta → `model.position = "{...}"` (STRING)
72
+ // 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
73
+ // char-by-char, producing corrupted char-indexed objects like
74
+ // `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
75
+ // 6. That corrupt object lands in the next commit, stored in jsonb.
76
+ //
77
+ // Sending objects avoids the round-trip mismatch: the wire carries
78
+ // the object through delta + commit unchanged, and `postgres-js`
79
+ // serializes JS objects to jsonb correctly via its own
80
+ // `json.serialize` (triggered by Postgres's ParameterDescription
81
+ // response identifying the column as type 3802 / jsonb).
82
+ out[key] = value;
83
+ }
84
+ return out;
85
+ }
86
+ const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
87
+ const stripModelSuffix = (modelName) => modelName.replace('Model', '');
88
+ /**
89
+ * FK-ordered create priority.
90
+ *
91
+ * Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
92
+ * this map is populated once at `createSyncEngine(...)` time by walking the
93
+ * schema's `belongsTo` graph (see `computeFKDepthPriority` in
94
+ * `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
95
+ * names appear here, and consumer applications can override specific
96
+ * priorities via `configOverrides.modelCreatePriority` without touching the
97
+ * SDK.
98
+ *
99
+ * Non-create ops (update/delete/archive/unarchive) don't need FK ordering
100
+ * because the row already exists, so they all share
101
+ * `config.defaultNonCreatePriority`.
102
+ */
103
+ const computePriorityScore = (type, modelName) => {
104
+ const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
105
+ if (type !== 'create')
106
+ return defaultNonCreatePriority;
107
+ return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
108
+ };
109
+ const TX_TYPE_TO_MUTATION_OP = {
110
+ create: MutationOperationType.CREATE,
111
+ update: MutationOperationType.UPDATE,
112
+ delete: MutationOperationType.DELETE,
113
+ archive: MutationOperationType.ARCHIVE,
114
+ unarchive: MutationOperationType.UNARCHIVE,
115
+ };
116
+ function hasStaleWriteOptions(options) {
117
+ return (options?.readAt !== undefined ||
118
+ options?.onStale !== undefined);
119
+ }
120
+ function applyStaleWriteOptions(op, transaction) {
121
+ const operation = op;
122
+ if (transaction.writeOptions?.readAt !== undefined) {
123
+ operation.readAt = transaction.writeOptions.readAt;
124
+ }
125
+ if (transaction.writeOptions?.onStale !== undefined) {
126
+ operation.onStale = transaction.writeOptions.onStale;
127
+ }
128
+ return operation;
129
+ }
130
+ function asTransportError(value) {
131
+ return (value && typeof value === 'object' ? value : {});
132
+ }
133
+ function extractStatusCode(error) {
134
+ return asTransportError(error).response?.status;
135
+ }
136
+ class TransactionStore {
137
+ transactions = new Map();
138
+ byStatus = new Map();
139
+ add(transaction) {
140
+ this.transactions.set(transaction.id, transaction);
141
+ if (!this.byStatus.has(transaction.status)) {
142
+ this.byStatus.set(transaction.status, new Set());
143
+ }
144
+ this.byStatus.get(transaction.status).add(transaction.id);
145
+ }
146
+ get(id) {
147
+ return this.transactions.get(id);
148
+ }
149
+ updateStatus(id, newStatus) {
150
+ const tx = this.transactions.get(id);
151
+ if (!tx)
152
+ return;
153
+ this.byStatus.get(tx.status)?.delete(id);
154
+ tx.status = newStatus;
155
+ if (!this.byStatus.has(newStatus)) {
156
+ this.byStatus.set(newStatus, new Set());
157
+ }
158
+ this.byStatus.get(newStatus).add(id);
159
+ }
160
+ getByStatus(status) {
161
+ const ids = this.byStatus.get(status) || new Set();
162
+ return Array.from(ids)
163
+ .map((id) => this.transactions.get(id))
164
+ .filter(Boolean);
165
+ }
166
+ remove(id) {
167
+ const tx = this.transactions.get(id);
168
+ if (!tx)
169
+ return;
170
+ this.transactions.delete(id);
171
+ this.byStatus.get(tx.status)?.delete(id);
172
+ }
173
+ clear() {
174
+ this.transactions.clear();
175
+ this.byStatus.clear();
176
+ }
177
+ getAll() {
178
+ return Array.from(this.transactions.values());
179
+ }
180
+ }
181
+ export class TransactionQueue extends EventEmitter {
182
+ store = new TransactionStore();
183
+ // Per-instance executor binding. Set by `setMutationExecutor(...)` from the
184
+ // owning Ablo right after construction. Falls back to `getContext()` only
185
+ // when unset (preserves legacy tests / SDK consumers that haven't migrated).
186
+ //
187
+ // Why this exists: `initSyncEngine()` writes a *module-level* singleton.
188
+ // Constructing a second Ablo (e.g. worker + per-job peer in agent-worker)
189
+ // overwrites the first instance's executor. Without an instance binding,
190
+ // queue commits on Ablo A would dispatch through Ablo B's executor closure,
191
+ // which captures B's `storeHolder.store` — and once B disposes its store,
192
+ // that closure returns `null` for `getWs()` and every commit on A throws
193
+ // `ws_not_ready` forever (queue classifies it as transient → retry loop).
194
+ _mutationExecutor = null;
195
+ get mutationExecutor() {
196
+ return this._mutationExecutor ?? getContext().mutationExecutor;
197
+ }
198
+ executionQueue = [];
199
+ isProcessing = false;
200
+ processTimer;
201
+ processScheduled = false;
202
+ // LINEAR PATTERN: Staging area for transactions created in same event loop tick
203
+ // All transactions go here first, then get committed together via microtask
204
+ createdTransactions = [];
205
+ commitScheduled = false;
206
+ // Per-model in-flight tracking and merge buffer
207
+ inFlightByModel = new Set();
208
+ pendingMergeByModel = new Map();
209
+ // Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
210
+ // Drained serially (one envelope at a time) since each is atomic; no
211
+ // coalescing with model-proxy transactions.
212
+ commitLane = [];
213
+ commitStore = new Map();
214
+ commitProcessing = false;
215
+ computePriorityScore(type, modelName) {
216
+ return computePriorityScore(type, modelName);
217
+ }
218
+ ensureDerivedFields(transaction) {
219
+ if (!transaction.modelKey) {
220
+ transaction.modelKey = normalizeModelKey(transaction.modelName);
221
+ }
222
+ if (transaction.priorityScore === undefined) {
223
+ transaction.priorityScore = this.computePriorityScore(transaction.type, transaction.modelName);
224
+ }
225
+ }
226
+ // Merge two GraphQL update payloads with special handling for metadata fields
227
+ mergeUpdateData(left, right, _modelName) {
228
+ const out = { ...(left || {}) };
229
+ const src = right || {};
230
+ for (const key of Object.keys(src)) {
231
+ // Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
232
+ if (key === 'metadata') {
233
+ const l = out.metadata;
234
+ const r = src.metadata;
235
+ // If both sides undefined/null, continue
236
+ if (l == null && r == null) {
237
+ continue;
238
+ }
239
+ // Normalize to objects
240
+ const toObj = (v) => {
241
+ if (v == null)
242
+ return {};
243
+ if (typeof v === 'string') {
244
+ try {
245
+ return JSON.parse(v);
246
+ }
247
+ catch {
248
+ return {};
249
+ }
250
+ }
251
+ if (typeof v === 'object')
252
+ return v;
253
+ return {};
254
+ };
255
+ const lobj = toObj(l);
256
+ const robj = toObj(r);
257
+ const merged = { ...lobj, ...robj };
258
+ // Re-stringify to match schema input type
259
+ try {
260
+ out.metadata = JSON.stringify(merged);
261
+ }
262
+ catch {
263
+ // Fallback to right-hand side if stringify fails
264
+ out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
265
+ }
266
+ continue;
267
+ }
268
+ // Default: shallow overwrite with right-hand value
269
+ out[key] = src[key];
270
+ }
271
+ return out;
272
+ }
273
+ // Configuration - tuned for LINEAR-style batching
274
+ // Higher batch size and delay allows more operations to coalesce into single HTTP call
275
+ config = {
276
+ maxBatchSize: 50, // Increased from 10 - matches Linear's batch size
277
+ batchDelay: 150, // Increased from 50ms - more time to coalesce rapid operations
278
+ maxRetries: 3,
279
+ conflictResolution: {
280
+ strategy: 'last-write-wins',
281
+ },
282
+ enablePersistence: true,
283
+ enableOptimistic: true,
284
+ // Backpressure: don't schedule more batches if too many transactions are executing
285
+ maxExecutingTransactions: 100,
286
+ // Delta confirmation initial timeout - first retry fires at 30s
287
+ // On timeout: retries with exponential backoff (30s → 60s → 120s) instead of rolling back
288
+ deltaConfirmationTimeout: 30000,
289
+ retryBackoff: { baseMs: 200, capMs: 1500 },
290
+ commitOfflineGraceMs: 30_000,
291
+ };
292
+ // Track executing transactions for backpressure
293
+ executingCount = 0;
294
+ // Optimistic update tracking
295
+ optimisticUpdates = new Map();
296
+ // LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
297
+ // Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
298
+ deltaConfirmationTimeouts = new Map();
299
+ // Track retry attempts per transaction for exponential backoff
300
+ deltaConfirmationRetries = new Map();
301
+ // Connection state check - set by SyncClient to prevent rollbacks during disconnection
302
+ isConnectedFn = () => true;
303
+ // Grace timer that, when fired, fails any commit-lane transaction
304
+ // still awaiting an ack. Started on `setConnectionState('disconnected')`,
305
+ // cleared on `'connected'`. The reconnect-retry behavior of the queue
306
+ // is preserved for brief blips; this only catches persistent disconnects.
307
+ commitOfflineGraceTimer = null;
308
+ // Track the highest syncId received from WebSocket deltas
309
+ // Used to immediately confirm transactions when HTTP response arrives AFTER the delta
310
+ // (fixes race condition where WebSocket delta arrives before HTTP response)
311
+ lastSeenSyncId = 0;
312
+ // Delta confirmation retry config (Replicache-style exponential backoff)
313
+ // Max retries before requesting full reconciliation
314
+ static DELTA_MAX_RETRIES = 5;
315
+ // Initial timeout (first attempt)
316
+ static DELTA_INITIAL_TIMEOUT_MS = 30_000;
317
+ // Max timeout cap (like Replicache's maxDelayMs of 60s)
318
+ static DELTA_MAX_TIMEOUT_MS = 120_000;
319
+ // Batch management
320
+ batchIndex = 0;
321
+ /**
322
+ * Resolvers for per-transaction `confirmation` promises. Populated in
323
+ * `attachConfirmation` at staging time, consumed by the constructor-time
324
+ * listeners on `transaction:completed` / `transaction:failed`. Kept off
325
+ * the Transaction row so the store's iteration order stays plain-data
326
+ * and serialization-friendly.
327
+ */
328
+ confirmationResolvers = new Map();
329
+ constructor(config) {
330
+ super();
331
+ if (config) {
332
+ this.config = { ...this.config, ...config };
333
+ }
334
+ // Centralized fan-in for `tx.confirmation`. Completion/failure are
335
+ // emitted from ~10 sites (delta confirm, immediate confirm, batch
336
+ // success, permanent error, max_retries_exhausted, …). Subscribing
337
+ // once here keeps every emit site intact and guarantees the call-site
338
+ // promise always settles, regardless of which path produced the
339
+ // terminal state.
340
+ this.on('transaction:completed', (tx) => {
341
+ const r = this.confirmationResolvers.get(tx.id);
342
+ if (r) {
343
+ this.confirmationResolvers.delete(tx.id);
344
+ r.resolve();
345
+ }
346
+ });
347
+ this.on('transaction:failed', ({ transaction, error }) => {
348
+ const r = this.confirmationResolvers.get(transaction.id);
349
+ if (r) {
350
+ this.confirmationResolvers.delete(transaction.id);
351
+ r.reject(error);
352
+ }
353
+ });
354
+ }
355
+ /**
356
+ * Look up the in-flight `confirmation` promise for a (model, id) pair.
357
+ * Returns the promise from the most-recent live transaction matching
358
+ * the given model+id, or `Promise.resolve()` if none is open (which
359
+ * means either "already confirmed" or "never staged" — both safe
360
+ * outcomes for the routing-helper grace-window use case).
361
+ *
362
+ * Looks across `pending`, `executing`, and `awaiting_delta` — these
363
+ * are the three non-terminal statuses where rollback is still
364
+ * possible. Skips `completed` (already settled) and `failed` /
365
+ * `rolled_back` (already rejected; the call site missed the
366
+ * `confirmation` window and should rely on `onMutationFailure` toast
367
+ * instead).
368
+ *
369
+ * Distinct from `tx.confirmation` on a known transaction — used by
370
+ * call sites that hold a Model reference (returned by
371
+ * `ablo.<model>.create()`) but never see the underlying transaction.
372
+ */
373
+ confirmationFor(modelName, modelId) {
374
+ const candidates = [
375
+ ...this.store.getByStatus('pending'),
376
+ ...this.store.getByStatus('executing'),
377
+ ...this.store.getByStatus('awaiting_delta'),
378
+ ].filter((tx) => tx.modelName === modelName && tx.modelId === modelId);
379
+ if (candidates.length === 0)
380
+ return Promise.resolve();
381
+ const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
382
+ return latest.confirmation ?? Promise.resolve();
383
+ }
384
+ /**
385
+ * Attach a hot `confirmation` promise to a freshly created transaction.
386
+ * Must be called BEFORE the transaction is staged so the call site can
387
+ * `await tx.confirmation` synchronously after the create/update/delete
388
+ * call returns. Idempotent: returns early if the tx already has one.
389
+ *
390
+ * The unhandled-rejection trap is mandatory — most call sites won't
391
+ * `await confirmation`, and Node/browser would otherwise crash on the
392
+ * rejection. Consumers who *do* want failure visibility just attach a
393
+ * `.then`/`.catch` and the trap becomes a no-op.
394
+ */
395
+ attachConfirmation(tx) {
396
+ if (tx.confirmation)
397
+ return;
398
+ tx.confirmation = new Promise((resolve, reject) => {
399
+ this.confirmationResolvers.set(tx.id, { resolve, reject });
400
+ });
401
+ tx.confirmation.catch(() => {
402
+ // Swallow unhandled rejections — explicit consumers attach their own
403
+ // handler; silent failure is the leak we're already fixing elsewhere.
404
+ });
405
+ }
406
+ /**
407
+ * Set connection state checker - prevents rollbacks during disconnection.
408
+ * When disconnected, timeouts re-schedule instead of rolling back.
409
+ */
410
+ setConnectionChecker(fn) {
411
+ this.isConnectedFn = fn;
412
+ }
413
+ /**
414
+ * Drive the offline-grace timer for in-flight commit-lane transactions.
415
+ *
416
+ * On `'disconnected'`: start a one-shot timer of
417
+ * `config.commitOfflineGraceMs`. If the timer fires (disconnect
418
+ * persisted past grace), iterate every commit-lane transaction with
419
+ * `status ∈ {'pending', 'executing'}` and emit
420
+ * `transaction:failed:${id}` with an `AbloConnectionError`. That
421
+ * lets `waitForCommitReceipt` reject in seconds instead of hanging
422
+ * forever — which is what wedged the 2026-05-15 subagent run.
423
+ *
424
+ * On `'connected'`: clear any pending grace timer. Brief blips are
425
+ * absorbed transparently; the existing reconnect-retry path in
426
+ * `processCommitLane` / `flushOfflineQueue` handles the resumption.
427
+ *
428
+ * Called from SyncClient's `setConnectionState` after the
429
+ * `'connection:disconnected'` / `'connection:established'` events.
430
+ */
431
+ setConnectionState(state) {
432
+ if (state === 'connected') {
433
+ if (this.commitOfflineGraceTimer !== null) {
434
+ clearTimeout(this.commitOfflineGraceTimer);
435
+ this.commitOfflineGraceTimer = null;
436
+ }
437
+ return;
438
+ }
439
+ // state === 'disconnected'
440
+ if (this.commitOfflineGraceTimer !== null)
441
+ return; // already armed
442
+ const graceMs = this.config.commitOfflineGraceMs;
443
+ this.commitOfflineGraceTimer = setTimeout(() => {
444
+ this.commitOfflineGraceTimer = null;
445
+ this.failInFlightCommitsOnOffline(graceMs);
446
+ }, graceMs);
447
+ }
448
+ failInFlightCommitsOnOffline(graceMs) {
449
+ const inFlight = [];
450
+ for (const [id, tx] of this.commitStore.entries()) {
451
+ if (tx.status === 'pending' || tx.status === 'executing') {
452
+ inFlight.push(id);
453
+ }
454
+ }
455
+ if (inFlight.length === 0)
456
+ return;
457
+ getContext().logger.warn(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
458
+ for (const id of inFlight) {
459
+ const tx = this.commitStore.get(id);
460
+ if (!tx)
461
+ continue;
462
+ const err = new AbloConnectionError(`commit ack abandoned after ${graceMs}ms offline`, { code: 'commit_offline_grace_expired' });
463
+ tx.status = 'failed';
464
+ tx.error = err;
465
+ this.emit(`transaction:failed:${id}`, { error: err });
466
+ }
467
+ }
468
+ /**
469
+ * Bind the executor for this queue instance. Called by the owning Ablo
470
+ * right after `BaseSyncedStore` is constructed so the executor's
471
+ * `storeHolder.store` closure resolves to *this* Ablo's WS — not whichever
472
+ * Ablo most recently called `initSyncEngine()`.
473
+ */
474
+ setMutationExecutor(executor) {
475
+ this._mutationExecutor = executor;
476
+ }
477
+ // ============================================================================
478
+ // LINEAR PATTERN: Microtask-based Transaction Staging
479
+ // ============================================================================
480
+ //
481
+ // All transactions first go to `createdTransactions` staging area.
482
+ // A microtask commits them all together with the same batchIndex.
483
+ // This ensures that bulk operations (like importing 100 layers) are batched efficiently.
484
+ //
485
+ // Flow:
486
+ // 1. create()/update()/delete() calls stageTransaction()
487
+ // 2. stageTransaction() adds to createdTransactions and schedules microtask
488
+ // 3. Microtask runs commitCreatedTransactions() after current sync code completes
489
+ // 4. All staged transactions get same batchIndex and move to executionQueue
490
+ // ============================================================================
491
+ /**
492
+ * Stage a transaction for commit (Linear pattern)
493
+ * Transactions staged in the same event loop tick will be committed together
494
+ */
495
+ stageTransaction(transaction) {
496
+ this.createdTransactions.push(transaction);
497
+ this.scheduleCommit();
498
+ }
499
+ /**
500
+ * Schedule commit of staged transactions via microtask
501
+ * This ensures all synchronous transaction creates are batched together
502
+ */
503
+ scheduleCommit() {
504
+ if (this.commitScheduled)
505
+ return;
506
+ this.commitScheduled = true;
507
+ // Use queueMicrotask to run after current sync code completes
508
+ // All transactions created in same event loop will be committed together
509
+ const schedule = typeof queueMicrotask === 'function'
510
+ ? queueMicrotask
511
+ : (cb) => Promise.resolve().then(cb);
512
+ schedule(() => {
513
+ this.commitCreatedTransactions();
514
+ });
515
+ }
516
+ /**
517
+ * Commit all staged transactions to the execution queue (Linear pattern)
518
+ * All transactions get the same batchIndex for efficient batching
519
+ */
520
+ commitCreatedTransactions() {
521
+ this.commitScheduled = false;
522
+ if (this.createdTransactions.length === 0)
523
+ return;
524
+ // Increment batch index - all transactions in this commit share it
525
+ this.batchIndex++;
526
+ const currentBatchIndex = this.batchIndex;
527
+ // Log batch commit for performance monitoring
528
+ getContext().logger.debug('[TransactionQueue] commitCreatedTransactions', {
529
+ count: this.createdTransactions.length,
530
+ batchIndex: currentBatchIndex,
531
+ types: this.createdTransactions.map((t) => `${t.type}:${t.modelName}`),
532
+ });
533
+ // Move all staged transactions to execution queue
534
+ const staged = this.createdTransactions;
535
+ this.createdTransactions = [];
536
+ for (const transaction of staged) {
537
+ // Assign batch ID based on current batch index
538
+ transaction.batchId = `batch_${currentBatchIndex}`;
539
+ this.enqueue(transaction);
540
+ }
541
+ }
542
+ // Batch flush all pending transactions via commit (fast path on reconnect)
543
+ async flushOfflineQueue() {
544
+ // Kick the commit lane too — pending atomic envelopes from
545
+ // `commits.create()` were left at the head of the lane while the WS
546
+ // was down. Fire-and-forget; processCommitLane self-serializes.
547
+ void this.processCommitLane();
548
+ // Collect pending transactions in created order
549
+ const pending = this.store.getByStatus('pending').sort((a, b) => a.createdAt - b.createdAt);
550
+ if (pending.length === 0)
551
+ return;
552
+ // Build operations list
553
+ const operations = pending.map((tx) => {
554
+ this.ensureDerivedFields(tx);
555
+ return applyStaleWriteOptions({
556
+ type: TX_TYPE_TO_MUTATION_OP[tx.type],
557
+ model: tx.modelKey,
558
+ id: tx.modelId,
559
+ input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
560
+ }, tx);
561
+ });
562
+ try {
563
+ const res = await this.mutationExecutor.commit(operations);
564
+ // Mark all as completed
565
+ for (const tx of pending) {
566
+ this.store.updateStatus(tx.id, 'completed');
567
+ this.emit('transaction:completed', tx);
568
+ this.emit(`transaction:completed:${tx.id}`, tx);
569
+ this.optimisticUpdates.delete(tx.id);
570
+ }
571
+ // Simple perf note
572
+ getContext().logger.debug('txn:commit', 0, {
573
+ count: pending.length,
574
+ lastSyncId: res?.lastSyncId,
575
+ });
576
+ }
577
+ catch (err) {
578
+ // If batch fails, fall back to normal processing
579
+ // Only log if we're online (if we're offline, this is expected)
580
+ const isOffline = !getContext().onlineStatus.isOnline();
581
+ const isNetworkError = err instanceof Error &&
582
+ (err.message.includes('Failed to fetch') ||
583
+ err.message.includes('Network request failed') ||
584
+ err.message.includes('NetworkError'));
585
+ if (!isOffline || !isNetworkError) {
586
+ getContext().observability.breadcrumb('Batch flush fallback failed', 'sync.transaction', 'warning', {
587
+ error: err instanceof Error ? err.message : String(err),
588
+ });
589
+ }
590
+ // Enqueue pending ones to executionQueue
591
+ for (const tx of pending) {
592
+ this.enqueue(tx);
593
+ }
594
+ }
595
+ }
596
+ /**
597
+ * Create operation with optimistic update
598
+ */
599
+ async create(model, context, writeOptions) {
600
+ const actualModelName = model.getModelName();
601
+ const transaction = {
602
+ id: this.generateId(),
603
+ type: 'create',
604
+ modelName: actualModelName,
605
+ modelId: model.id,
606
+ modelKey: normalizeModelKey(actualModelName),
607
+ priorityScore: this.computePriorityScore('create', actualModelName),
608
+ data: this.extractCreateData(model),
609
+ // CREATE rollback removes the row — there is no prior state to
610
+ // restore, so allocating a `toJSON()` snapshot here was waste.
611
+ previousData: null,
612
+ context,
613
+ status: 'pending',
614
+ createdAt: Date.now(),
615
+ attempts: 0,
616
+ priority: 'normal',
617
+ writeOptions,
618
+ };
619
+ this.attachConfirmation(transaction);
620
+ this.store.add(transaction);
621
+ if (this.config.enableOptimistic) {
622
+ this.applyOptimisticCreate(model, transaction);
623
+ }
624
+ // Microtask coalescer (`scheduleCommit`) collapses all creates in
625
+ // this tick into one wire commit with one `batchIndex` — see
626
+ // `commitCreatedTransactions`. No batch API needed at the call site.
627
+ this.stageTransaction(transaction);
628
+ this.emit('transaction:created', transaction);
629
+ return transaction;
630
+ }
631
+ /**
632
+ * Update operation with conflict detection
633
+ * @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
634
+ */
635
+ async update(model, context, precomputedChanges, writeOptions) {
636
+ const actualModelName = model.getModelName();
637
+ // Use pre-computed changes if provided, otherwise extract from model
638
+ const updateInput = precomputedChanges
639
+ ? this.mapChangesToInput(actualModelName, precomputedChanges)
640
+ : this.extractUpdateData(model);
641
+ const previousData = this.extractPreviousData(model, updateInput);
642
+ const modelKey = normalizeModelKey(actualModelName);
643
+ const priorityScore = this.computePriorityScore('update', actualModelName);
644
+ const transaction = {
645
+ id: this.generateId(),
646
+ type: 'update',
647
+ modelName: actualModelName,
648
+ modelId: model.id,
649
+ modelKey,
650
+ priorityScore,
651
+ data: updateInput,
652
+ previousData,
653
+ context,
654
+ status: 'pending',
655
+ createdAt: Date.now(),
656
+ attempts: 0,
657
+ priority: this.isReorderPayload(updateInput) ? 'high' : 'normal',
658
+ writeOptions,
659
+ };
660
+ this.attachConfirmation(transaction);
661
+ this.store.add(transaction);
662
+ // Apply optimistic update
663
+ if (this.config.enableOptimistic) {
664
+ this.applyOptimisticUpdate(model, transaction);
665
+ }
666
+ // LINEAR PATTERN: Stage transaction for microtask commit
667
+ // Multiple updates in same event loop will be batched together
668
+ // enqueue() will still apply its coalescing logic for same-entity updates
669
+ this.stageTransaction(transaction);
670
+ this.emit('transaction:created', transaction);
671
+ return transaction;
672
+ }
673
+ /**
674
+ * Delete operation with cascade handling
675
+ */
676
+ async delete(model, context, writeOptions) {
677
+ // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
678
+ const actualModelName = model.getModelName();
679
+ // Skip Activity delete transactions - activities are permanent audit records
680
+ if (actualModelName === 'Activity') {
681
+ getContext().logger.debug('TransactionQueue.delete() skipping Activity deletion - permanent audit records', { modelId: model.id });
682
+ const modelKey = normalizeModelKey(actualModelName);
683
+ const priorityScore = this.computePriorityScore('delete', actualModelName);
684
+ const mockTransaction = {
685
+ id: this.generateId(),
686
+ type: 'delete',
687
+ modelName: actualModelName,
688
+ modelId: model.id,
689
+ modelKey,
690
+ priorityScore,
691
+ previousData: model.toJSON ? model.toJSON() : { ...model },
692
+ context,
693
+ status: 'completed',
694
+ createdAt: Date.now(),
695
+ attempts: 0,
696
+ priority: 'high',
697
+ writeOptions,
698
+ // Activity deletes complete synchronously (audit-record skip path).
699
+ // Pre-resolved so consumers can still `await tx.confirmation` uniformly.
700
+ confirmation: Promise.resolve(),
701
+ };
702
+ // Apply optimistic delete for UI feedback
703
+ if (this.config.enableOptimistic) {
704
+ this.applyOptimisticDelete(model, mockTransaction);
705
+ }
706
+ this.emit('transaction:created', mockTransaction);
707
+ this.emit('transaction:completed', mockTransaction);
708
+ return mockTransaction;
709
+ }
710
+ const modelKey = normalizeModelKey(actualModelName);
711
+ const priorityScore = this.computePriorityScore('delete', actualModelName);
712
+ const transaction = {
713
+ id: this.generateId(),
714
+ type: 'delete',
715
+ modelName: actualModelName,
716
+ modelId: model.id,
717
+ modelKey,
718
+ priorityScore,
719
+ previousData: model.toJSON ? model.toJSON() : { ...model },
720
+ context,
721
+ status: 'pending',
722
+ createdAt: Date.now(),
723
+ attempts: 0,
724
+ priority: 'high', // Deletes are high priority
725
+ writeOptions,
726
+ };
727
+ this.attachConfirmation(transaction);
728
+ this.store.add(transaction);
729
+ // Cancel any pending/in-flight updates for this model to prevent "no rows" errors
730
+ // when the delete executes before the update (race condition fix)
731
+ this.cancelTransactionsForModel(model.id, 'update');
732
+ this.pendingMergeByModel.delete(`${actualModelName}:${model.id}`);
733
+ this.inFlightByModel.delete(`${actualModelName}:${model.id}`);
734
+ // Apply optimistic delete
735
+ if (this.config.enableOptimistic) {
736
+ this.applyOptimisticDelete(model, transaction);
737
+ }
738
+ // LINEAR PATTERN: Stage transaction for microtask commit
739
+ // All deletes in same event loop will be batched together
740
+ this.stageTransaction(transaction);
741
+ this.emit('transaction:created', transaction);
742
+ return transaction;
743
+ }
744
+ /**
745
+ * Upload attachment — delegates to attachment-uploader.ts
746
+ */
747
+ async uploadAttachment(_file, options, _context) {
748
+ return this.mutationExecutor.uploadAttachment?.(options.id, options) ?? null;
749
+ }
750
+ /**
751
+ * Batch upload attachments — delegates to MutationExecutor
752
+ */
753
+ async batchUploadAttachments(_files, items, _context) {
754
+ return this.mutationExecutor.batchUploadAttachments?.(items.map(i => ({ id: i.id, input: i }))) ?? [];
755
+ }
756
+ /**
757
+ * Archive operation
758
+ */
759
+ async archive(model, context, writeOptions) {
760
+ // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
761
+ const actualModelName = model.getModelName();
762
+ const modelKey = normalizeModelKey(actualModelName);
763
+ const priorityScore = this.computePriorityScore('archive', actualModelName);
764
+ const transaction = {
765
+ id: this.generateId(),
766
+ type: 'archive',
767
+ modelName: actualModelName,
768
+ modelId: model.id,
769
+ modelKey,
770
+ priorityScore,
771
+ previousData: model.toJSON ? model.toJSON() : { ...model },
772
+ context,
773
+ status: 'pending',
774
+ createdAt: Date.now(),
775
+ attempts: 0,
776
+ priority: 'normal',
777
+ writeOptions,
778
+ };
779
+ this.attachConfirmation(transaction);
780
+ this.store.add(transaction);
781
+ // LINEAR PATTERN: Stage transaction for microtask commit
782
+ this.stageTransaction(transaction);
783
+ this.emit('transaction:created', transaction);
784
+ return transaction;
785
+ }
786
+ /**
787
+ * Unarchive operation
788
+ */
789
+ async unarchive(model, context) {
790
+ // 🔧 FIXED: Use getModelName() instead of constructor.name (production-safe)
791
+ const actualModelName = model.getModelName();
792
+ const modelKey = normalizeModelKey(actualModelName);
793
+ const priorityScore = this.computePriorityScore('unarchive', actualModelName);
794
+ const transaction = {
795
+ id: this.generateId(),
796
+ type: 'unarchive',
797
+ modelName: actualModelName,
798
+ modelId: model.id,
799
+ modelKey,
800
+ priorityScore,
801
+ previousData: model.toJSON ? model.toJSON() : { ...model },
802
+ context,
803
+ status: 'pending',
804
+ createdAt: Date.now(),
805
+ attempts: 0,
806
+ priority: 'normal',
807
+ };
808
+ this.attachConfirmation(transaction);
809
+ this.store.add(transaction);
810
+ // LINEAR PATTERN: Stage transaction for microtask commit
811
+ this.stageTransaction(transaction);
812
+ this.emit('transaction:created', transaction);
813
+ return transaction;
814
+ }
815
+ /**
816
+ * Enqueue transaction for execution
817
+ */
818
+ enqueue(transaction) {
819
+ this.ensureDerivedFields(transaction);
820
+ const modelKey = `${transaction.modelName}:${transaction.modelId}`;
821
+ // LINEAR PATTERN: Simplified coalescing for updates
822
+ // Staging already batches all transactions in same event loop tick
823
+ // We only need to handle: (1) in-flight merging, (2) same-entity merging
824
+ if (transaction.type === 'update') {
825
+ const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
826
+ // If there is an in-flight update for this model, merge into post-flight buffer
827
+ if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
828
+ const prev = this.pendingMergeByModel.get(modelKey) || {};
829
+ const merged = this.mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
830
+ this.pendingMergeByModel.set(modelKey, merged);
831
+ this.store.remove(transaction.id);
832
+ return;
833
+ }
834
+ // If there's a pending update for same model in execution queue, merge into it
835
+ const pendingInQueue = this.executionQueue.find((t) => t.id !== transaction.id &&
836
+ t.type === 'update' &&
837
+ t.modelId === transaction.modelId &&
838
+ t.modelName === transaction.modelName &&
839
+ !hasStaleWriteOptions(t.writeOptions));
840
+ if (!preserveWatermark && pendingInQueue) {
841
+ pendingInQueue.data = this.mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
842
+ this.store.remove(transaction.id);
843
+ return;
844
+ }
845
+ }
846
+ // Add to execution queue based on priority
847
+ if (transaction.priority === 'high') {
848
+ this.executionQueue.unshift(transaction);
849
+ }
850
+ else {
851
+ this.executionQueue.push(transaction);
852
+ }
853
+ this.scheduleProcessing(transaction.priority === 'high');
854
+ }
855
+ scheduleProcessing(immediate = false) {
856
+ if (this.processScheduled)
857
+ return;
858
+ // BACKPRESSURE: Don't schedule if too many transactions are already executing
859
+ // This prevents overwhelming the server with concurrent requests (Linear pattern)
860
+ if (this.executingCount >= this.config.maxExecutingTransactions) {
861
+ getContext().logger.debug('[TransactionQueue] Backpressure: delaying batch, too many executing', {
862
+ executingCount: this.executingCount,
863
+ max: this.config.maxExecutingTransactions,
864
+ });
865
+ return;
866
+ }
867
+ this.processScheduled = true;
868
+ if (immediate || (this.config.batchDelay ?? 0) <= 0) {
869
+ const schedule = typeof queueMicrotask === 'function'
870
+ ? queueMicrotask
871
+ : (cb) => Promise.resolve().then(cb);
872
+ schedule(() => {
873
+ this.processScheduled = false;
874
+ void this.processBatch();
875
+ });
876
+ return;
877
+ }
878
+ const delay = Math.max(0, this.config.batchDelay);
879
+ this.processTimer = setTimeout(() => {
880
+ this.processTimer = undefined;
881
+ this.processScheduled = false;
882
+ void this.processBatch();
883
+ }, delay);
884
+ }
885
+ /**
886
+ * Process batch of transactions using LINEAR-style unified batch execution.
887
+ *
888
+ * Key optimization: Instead of making separate calls per operation type/model,
889
+ * we collect ALL batchable operations and send them in a SINGLE commit call.
890
+ * The sync-server handles mixed types atomically inside one transaction.
891
+ *
892
+ * This reduces N round-trips to 1, dramatically improving batch latency.
893
+ */
894
+ async processBatch() {
895
+ const batchStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
896
+ if (this.isProcessing || this.executionQueue.length === 0) {
897
+ return;
898
+ }
899
+ this.isProcessing = true;
900
+ // Declare batch outside try so it's accessible in finally for backpressure tracking
901
+ let batch = [];
902
+ await getContext().observability.startSpanAsync('sync.batch', 'sync.transaction.batch', async () => {
903
+ try {
904
+ // Sort executionQueue by FK priority before batch selection
905
+ // This ensures parent entities (Layout, SlideLayout) are always processed
906
+ // before their children (SlideLayoutLayer) across batch boundaries
907
+ this.executionQueue.sort((a, b) => {
908
+ // Ensure derived fields exist (covers restored/persisted transactions)
909
+ this.ensureDerivedFields(a);
910
+ this.ensureDerivedFields(b);
911
+ return a.priorityScore - b.priorityScore;
912
+ });
913
+ // Get batch (now guaranteed to have parent entities before children)
914
+ batch = this.executionQueue.splice(0, this.config.maxBatchSize);
915
+ // Track executing count for backpressure
916
+ this.executingCount += batch.length;
917
+ // Mark all as executing
918
+ for (const tx of batch) {
919
+ const key = `${tx.modelName}:${tx.modelId}`;
920
+ if (tx.type === 'update')
921
+ this.inFlightByModel.add(key);
922
+ this.store.updateStatus(tx.id, 'executing');
923
+ }
924
+ // Build ALL operations for unified commit (SINGLE WS round-trip)
925
+ const batchOps = [];
926
+ for (const tx of batch) {
927
+ // Per-op `transactionId` carries the local tx UUID through
928
+ // the wire so the server can stamp it on the resulting
929
+ // sync delta. The receive path (`SyncClient.applyDeltaBatchToPool`)
930
+ // matches it via `OptimisticEchoTracker.consumeEcho` to suppress
931
+ // double-applying optimistic mutations. Distinct from the
932
+ // batch-level idempotency key in mutation_log.
933
+ const op = applyStaleWriteOptions({
934
+ type: TX_TYPE_TO_MUTATION_OP[tx.type],
935
+ model: tx.modelKey,
936
+ id: tx.modelId,
937
+ input: tx.type === 'create' || tx.type === 'update' ? tx.data || {} : undefined,
938
+ transactionId: tx.id,
939
+ }, tx);
940
+ batchOps.push({ tx, op });
941
+ }
942
+ // Execute unified commit for ALL operations (SINGLE WS round-trip)
943
+ if (batchOps.length > 0) {
944
+ const operations = batchOps.map(({ op }) => op);
945
+ try {
946
+ // LINEAR PATTERN: Capture lastSyncId from server response for threshold-based confirmation
947
+ //
948
+ // Idempotency note: the default HTTP executor derives a
949
+ // stable `Idempotency-Key` from the operations array
950
+ // itself (sorted sha256), so retries of the SAME batch
951
+ // hit the server's `mutation_log` replay path without
952
+ // requiring us to thread a key through the microtask
953
+ // boundary here. Keeping this path await-free preserves
954
+ // the coalescing test's tight bound on batch count.
955
+ const result = await this.mutationExecutor.commit(operations);
956
+ const lastSyncId = result?.lastSyncId ?? 0;
957
+ // Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
958
+ if (lastSyncId === 0) {
959
+ getContext().observability.captureCommitZeroSyncId({
960
+ operationCount: operations.length,
961
+ operations: operations.map((op) => `${op.type}:${op.model}:${op.id?.slice(0, 8) ?? '?'}`),
962
+ });
963
+ }
964
+ // LINEAR PATTERN: Mark as awaiting_delta with syncId threshold
965
+ // Transactions will be confirmed when any delta with id >= lastSyncId arrives
966
+ for (const { tx } of batchOps) {
967
+ tx.syncIdNeededForCompletion = lastSyncId;
968
+ // Safety net: when lastSyncId is 0, DELETE transactions should be confirmed
969
+ // immediately. DELETEs are idempotent — if no delta was emitted, the entity
970
+ // is already gone and the intent was achieved. Parking DELETEs in awaiting_delta
971
+ // with threshold 0 causes 30s reconciliation delays.
972
+ if (lastSyncId === 0 && tx.type === 'delete') {
973
+ this.store.updateStatus(tx.id, 'completed');
974
+ this.emit('transaction:completed', tx);
975
+ this.emit(`transaction:completed:${tx.id}`, tx);
976
+ this.optimisticUpdates.delete(tx.id);
977
+ getContext().logger.debug('tx:confirm_delete_zero_syncid', {
978
+ txId: tx.id.slice(0, 8),
979
+ model: tx.modelName,
980
+ reason: 'delete_idempotent_no_delta',
981
+ });
982
+ continue;
983
+ }
984
+ // FIX: Check if delta already arrived before HTTP response (race condition)
985
+ // WebSocket can be faster than HTTP, so the delta might already be here
986
+ // Guard: only do immediate confirm if lastSyncId > 0 (valid server response)
987
+ if (lastSyncId > 0 && this.lastSeenSyncId >= lastSyncId) {
988
+ // Delta already arrived! Confirm immediately without timeout
989
+ this.store.updateStatus(tx.id, 'completed');
990
+ this.emit('transaction:completed', tx);
991
+ this.emit(`transaction:completed:${tx.id}`, tx);
992
+ this.optimisticUpdates.delete(tx.id);
993
+ getContext().logger.debug('tx:confirm_immediate', {
994
+ txId: tx.id.slice(0, 8),
995
+ model: tx.modelName,
996
+ neededSyncId: lastSyncId,
997
+ lastSeenSyncId: this.lastSeenSyncId,
998
+ reason: 'delta_arrived_before_http',
999
+ });
1000
+ }
1001
+ else {
1002
+ // Delta hasn't arrived yet, wait for it
1003
+ this.store.updateStatus(tx.id, 'awaiting_delta');
1004
+ getContext().logger.debug('tx:awaiting_delta', {
1005
+ txId: tx.id.slice(0, 8),
1006
+ model: tx.modelName,
1007
+ neededSyncId: lastSyncId,
1008
+ lastSeenSyncId: this.lastSeenSyncId,
1009
+ gap: lastSyncId - this.lastSeenSyncId,
1010
+ });
1011
+ // Schedule timeout-based rollback for unconfirmed transactions
1012
+ this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
1013
+ }
1014
+ }
1015
+ }
1016
+ catch (error) {
1017
+ const errorMessage = error.message || '';
1018
+ // Surface the raw server rejection for the whole batch so
1019
+ // cascaded failures (e.g. Layout FK violation that rolls
1020
+ // back a 6-op transaction) are attributable to a specific
1021
+ // cause instead of each op showing as a generic permanent
1022
+ // error downstream.
1023
+ const abloErr = error instanceof AbloError ? error : undefined;
1024
+ // SyncWebSocket attaches a `diagnostics` snapshot to its
1025
+ // "not connected" / "closed while in flight" rejections.
1026
+ // Surface it here so the warn line attributes the drop to
1027
+ // a specific cause (handshake reject, heartbeat zombie,
1028
+ // session expiry, …) instead of just "AbloConnectionError".
1029
+ const readDiagnostics = (e) => {
1030
+ let cur = e;
1031
+ // Walk up to 3 wrap layers (current err → its cause → its
1032
+ // cause's cause) so diagnostics survive AbloConnectionError
1033
+ // wrapping in Ablo.commit() and any future wrappers.
1034
+ for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
1035
+ if ('diagnostics' in cur && cur.diagnostics) {
1036
+ return cur.diagnostics;
1037
+ }
1038
+ cur = cur.cause;
1039
+ }
1040
+ return undefined;
1041
+ };
1042
+ const diagnostics = readDiagnostics(error);
1043
+ getContext().logger.warn('[TransactionQueue] Batch commit rejected', {
1044
+ batchSize: batchOps.length,
1045
+ models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
1046
+ errorType: abloErr?.type ?? error?.name,
1047
+ errorCode: abloErr?.code,
1048
+ httpStatus: abloErr?.httpStatus,
1049
+ requestId: abloErr?.requestId,
1050
+ message: errorMessage,
1051
+ diagnostics,
1052
+ });
1053
+ // LINEAR PATTERN: Handle "no rows in result set" gracefully
1054
+ // This error means the entity was already deleted - for UPDATE/DELETE ops, this is success
1055
+ // The intent was achieved (the data doesn't exist), so treat as completed
1056
+ if (errorMessage.includes('no rows in result set')) {
1057
+ getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
1058
+ batchSize: batchOps.length,
1059
+ });
1060
+ for (const { tx, op } of batchOps) {
1061
+ if (op.type === 'UPDATE' || op.type === 'DELETE') {
1062
+ // Entity gone = intent achieved, mark as completed
1063
+ this.store.updateStatus(tx.id, 'completed');
1064
+ this.emit('transaction:completed', tx);
1065
+ getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
1066
+ txId: tx.id.slice(0, 12),
1067
+ model: tx.modelName,
1068
+ type: op.type,
1069
+ });
1070
+ }
1071
+ else {
1072
+ // CREATE operations on non-existent parent are real failures
1073
+ await this.handleFailure(tx, error);
1074
+ }
1075
+ }
1076
+ }
1077
+ else {
1078
+ // Handle other batch failures - mark all as failed
1079
+ for (const { tx } of batchOps) {
1080
+ await this.handleFailure(tx, error);
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ // Handle post-execution merge for updates
1086
+ for (const tx of batch) {
1087
+ const key = `${tx.modelName}:${tx.modelId}`;
1088
+ if (tx.type === 'update') {
1089
+ this.inFlightByModel.delete(key);
1090
+ const pending = this.pendingMergeByModel.get(key);
1091
+ if (pending && Object.keys(pending).length > 0) {
1092
+ // Create a single merged follow-up transaction
1093
+ const followUp = {
1094
+ id: this.generateId(),
1095
+ type: 'update',
1096
+ modelName: tx.modelName,
1097
+ modelId: tx.modelId,
1098
+ modelKey: tx.modelKey ?? normalizeModelKey(tx.modelName),
1099
+ data: pending,
1100
+ previousData: undefined,
1101
+ context: tx.context,
1102
+ status: 'pending',
1103
+ createdAt: Date.now(),
1104
+ attempts: 0,
1105
+ priority: 'normal',
1106
+ priorityScore: this.computePriorityScore('update', tx.modelName),
1107
+ };
1108
+ this.pendingMergeByModel.delete(key);
1109
+ this.store.add(followUp);
1110
+ this.enqueue(followUp);
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+ finally {
1116
+ this.isProcessing = false;
1117
+ // Decrement executing count for backpressure tracking
1118
+ this.executingCount -= batch.length;
1119
+ // Process next batch if needed
1120
+ if (this.executionQueue.length > 0) {
1121
+ this.scheduleProcessing(true);
1122
+ }
1123
+ const batchEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
1124
+ getContext().logger.debug('txn:batch', batchEnd - batchStart, {
1125
+ maxBatchSize: this.config.maxBatchSize,
1126
+ remaining: this.executionQueue.length,
1127
+ executingCount: this.executingCount,
1128
+ });
1129
+ }
1130
+ }, { batchSize: this.executionQueue.length + (batch?.length ?? 0) });
1131
+ }
1132
+ /**
1133
+ * LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
1134
+ * This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
1135
+ * @param syncId - The sync ID of the received delta
1136
+ */
1137
+ onDeltaReceived(syncId) {
1138
+ const prevLastSeen = this.lastSeenSyncId;
1139
+ // Track highest syncId seen (fixes race: delta arrives before HTTP response)
1140
+ if (syncId > this.lastSeenSyncId) {
1141
+ this.lastSeenSyncId = syncId;
1142
+ getContext().logger.debug('tx:highwater_update', {
1143
+ prev: prevLastSeen,
1144
+ new: syncId,
1145
+ delta: syncId - prevLastSeen,
1146
+ });
1147
+ }
1148
+ const awaitingTxs = this.store.getByStatus('awaiting_delta');
1149
+ const executingTxs = this.store.getByStatus('executing');
1150
+ // Debug: Show state when delta arrives
1151
+ if (awaitingTxs.length > 0 || executingTxs.length > 0) {
1152
+ getContext().logger.debug('tx:delta_received', {
1153
+ syncId,
1154
+ lastSeenSyncId: this.lastSeenSyncId,
1155
+ awaitingCount: awaitingTxs.length,
1156
+ executingCount: executingTxs.length,
1157
+ awaitingThresholds: awaitingTxs.map((tx) => ({
1158
+ txId: tx.id.slice(0, 8),
1159
+ model: tx.modelName,
1160
+ needed: tx.syncIdNeededForCompletion,
1161
+ willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
1162
+ })),
1163
+ });
1164
+ }
1165
+ // Fast path: no awaiting transactions
1166
+ if (awaitingTxs.length === 0)
1167
+ return;
1168
+ let confirmedCount = 0;
1169
+ for (const tx of awaitingTxs) {
1170
+ // Confirm if this delta's ID meets or exceeds the threshold
1171
+ if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
1172
+ this.cancelDeltaConfirmationTimeout(tx.id);
1173
+ this.store.updateStatus(tx.id, 'completed');
1174
+ this.emit('transaction:completed', tx);
1175
+ this.emit(`transaction:completed:${tx.id}`, tx);
1176
+ this.optimisticUpdates.delete(tx.id);
1177
+ confirmedCount++;
1178
+ getContext().logger.debug('tx:confirm_via_delta', {
1179
+ txId: tx.id.slice(0, 8),
1180
+ model: tx.modelName,
1181
+ neededSyncId: tx.syncIdNeededForCompletion,
1182
+ receivedSyncId: syncId,
1183
+ });
1184
+ }
1185
+ }
1186
+ // Log batch summary only if we confirmed something
1187
+ if (confirmedCount > 0) {
1188
+ // Use warn for staging visibility when transactions confirm
1189
+ getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
1190
+ count: confirmedCount,
1191
+ syncId,
1192
+ remainingAwaiting: awaitingTxs.length - confirmedCount,
1193
+ });
1194
+ }
1195
+ }
1196
+ // REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry + reconciliation
1197
+ // Instead of rolling back on timeout (which destroys confirmed server state),
1198
+ // retry with exponential backoff and request reconciliation to catch up on missed deltas.
1199
+ // Only rollback on explicit server rejection, never on timeout.
1200
+ scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1201
+ // Cancel any existing timeout for this transaction
1202
+ this.cancelDeltaConfirmationTimeout(tx.id);
1203
+ const timeoutHandle = setTimeout(async () => {
1204
+ const currentTx = this.store.get(tx.id);
1205
+ if (!currentTx || currentTx.status !== 'awaiting_delta') {
1206
+ this.deltaConfirmationRetries.delete(tx.id);
1207
+ return; // Already confirmed or failed
1208
+ }
1209
+ // If disconnected, re-schedule with same timeout (no backoff while offline)
1210
+ if (!this.isConnectedFn()) {
1211
+ getContext().logger.warn('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
1212
+ txId: tx.id.slice(0, 8),
1213
+ model: tx.modelName,
1214
+ });
1215
+ this.deltaConfirmationTimeouts.delete(tx.id);
1216
+ this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1217
+ return;
1218
+ }
1219
+ const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
1220
+ const diagnosis = this.lastSeenSyncId === 0
1221
+ ? 'No deltas received - delta pipeline may be broken'
1222
+ : currentTx.syncIdNeededForCompletion &&
1223
+ this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
1224
+ ? 'Delta not yet received - may be lost or delayed'
1225
+ : 'Delta should have confirmed - possible race condition';
1226
+ getContext().observability.captureReconciliation({
1227
+ reason: 'delta_timeout',
1228
+ model: tx.modelName,
1229
+ modelId: tx.modelId,
1230
+ syncIdNeeded: currentTx.syncIdNeededForCompletion,
1231
+ lastSeenSyncId: this.lastSeenSyncId,
1232
+ retryCount,
1233
+ connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
1234
+ });
1235
+ if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
1236
+ // RETRY: Request reconciliation and re-schedule with exponential backoff
1237
+ // The server already committed this mutation — we just need the delta to arrive
1238
+ this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
1239
+ this.deltaConfirmationTimeouts.delete(tx.id);
1240
+ // Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
1241
+ const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
1242
+ // Emit reconciliation request so SyncedStore can cycle the WebSocket
1243
+ // to trigger delta catch-up from the server
1244
+ this.emit('reconciliation:needed', {
1245
+ reason: 'delta_confirmation_timeout',
1246
+ txId: tx.id,
1247
+ model: tx.modelName,
1248
+ modelId: tx.modelId,
1249
+ syncIdNeeded: currentTx.syncIdNeededForCompletion,
1250
+ lastSeenSyncId: this.lastSeenSyncId,
1251
+ retryCount: retryCount + 1,
1252
+ });
1253
+ getContext().logger.warn('[TransactionQueue] Re-scheduling with backoff', {
1254
+ txId: tx.id.slice(0, 8),
1255
+ model: tx.modelName,
1256
+ nextTimeoutMs: nextTimeout,
1257
+ retry: retryCount + 1,
1258
+ });
1259
+ this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
1260
+ }
1261
+ else {
1262
+ // LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
1263
+ // The transaction succeeded on the server (HTTP 200), so the data exists server-side.
1264
+ // Persist the awaiting state so it survives tab close. On next session, the WebSocket
1265
+ // reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
1266
+ this.deltaConfirmationRetries.delete(tx.id);
1267
+ this.deltaConfirmationTimeouts.delete(tx.id);
1268
+ getContext().observability.captureDeltaRetryExhausted({
1269
+ txId: tx.id,
1270
+ model: tx.modelName,
1271
+ modelId: tx.modelId,
1272
+ retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1273
+ syncIdNeeded: currentTx.syncIdNeededForCompletion,
1274
+ });
1275
+ // Emit persist event — SyncClient handles the IDB write
1276
+ this.emit('transaction:persist_awaiting', {
1277
+ txId: tx.id,
1278
+ model: tx.modelName,
1279
+ modelId: tx.modelId,
1280
+ operationType: tx.type,
1281
+ syncIdNeeded: currentTx.syncIdNeededForCompletion,
1282
+ });
1283
+ // Also request one final reconciliation cycle
1284
+ this.emit('reconciliation:needed', {
1285
+ reason: 'delta_retries_exhausted',
1286
+ txId: tx.id,
1287
+ model: tx.modelName,
1288
+ modelId: tx.modelId,
1289
+ syncIdNeeded: currentTx.syncIdNeededForCompletion,
1290
+ lastSeenSyncId: this.lastSeenSyncId,
1291
+ retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1292
+ });
1293
+ }
1294
+ }, timeoutMs);
1295
+ this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
1296
+ }
1297
+ // Cancel a pending delta confirmation timeout and clean up retry tracking
1298
+ cancelDeltaConfirmationTimeout(id) {
1299
+ const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
1300
+ if (timeoutHandle) {
1301
+ clearTimeout(timeoutHandle);
1302
+ this.deltaConfirmationTimeouts.delete(id);
1303
+ }
1304
+ this.deltaConfirmationRetries.delete(id);
1305
+ }
1306
+ /**
1307
+ * Wait for a transaction to be confirmed via delta echo (Linear pattern)
1308
+ * Reuses existing timeout mechanism from scheduleDeltaConfirmationTimeout
1309
+ */
1310
+ waitForConfirmation(transactionId) {
1311
+ return new Promise((resolve, reject) => {
1312
+ // Check if already completed
1313
+ const tx = this.store.get(transactionId);
1314
+ if (tx?.status === 'completed') {
1315
+ resolve();
1316
+ return;
1317
+ }
1318
+ const onCompleted = () => {
1319
+ cleanup();
1320
+ resolve();
1321
+ };
1322
+ const onFailed = ({ error }) => {
1323
+ cleanup();
1324
+ reject(error);
1325
+ };
1326
+ const cleanup = () => {
1327
+ this.off(`transaction:completed:${transactionId}`, onCompleted);
1328
+ this.off(`transaction:failed:${transactionId}`, onFailed);
1329
+ };
1330
+ // Listen to existing events (timeout already handled by scheduleDeltaConfirmationTimeout)
1331
+ this.on(`transaction:completed:${transactionId}`, onCompleted);
1332
+ this.on(`transaction:failed:${transactionId}`, onFailed);
1333
+ });
1334
+ }
1335
+ // Public: check if a clientMutationId exists in this queue (helps identify self-echo deltas)
1336
+ hasClientMutationId(id) {
1337
+ return !!this.store.get(id) || this.commitStore.has(id);
1338
+ }
1339
+ /**
1340
+ * Enqueue a raw multi-op atomic commit envelope (the `ablo.commits.create`
1341
+ * path). Operations are pre-built by the caller; the queue's job is
1342
+ * retry-on-reconnect + idempotent dedup, NOT optimistic apply or FK
1343
+ * ordering. Same idempotency key (clientTxId) is dropped on the floor
1344
+ * if already in flight — server-side `mutation_log` handles cross-session
1345
+ * dedup; this guard handles same-session double-enqueue.
1346
+ */
1347
+ enqueueCommit(clientTxId, operations, options = {}) {
1348
+ if (this.commitStore.has(clientTxId))
1349
+ return;
1350
+ const tx = {
1351
+ id: clientTxId,
1352
+ kind: 'commit',
1353
+ operations: [...operations],
1354
+ causedByTaskId: options.causedByTaskId ?? null,
1355
+ status: 'pending',
1356
+ createdAt: Date.now(),
1357
+ attempts: 0,
1358
+ };
1359
+ this.commitStore.set(clientTxId, tx);
1360
+ this.commitLane.push(tx);
1361
+ void this.processCommitLane();
1362
+ }
1363
+ /**
1364
+ * Drain pending commit-lane envelopes serially. Transient failures
1365
+ * (network, ws_not_ready) leave the head-of-queue tx in `pending` and
1366
+ * break — reconnect handler re-kicks via `flushOfflineQueue`.
1367
+ * Permanent failures emit `transaction:failed:<id>` and drop the tx.
1368
+ */
1369
+ async processCommitLane() {
1370
+ if (this.commitProcessing)
1371
+ return;
1372
+ this.commitProcessing = true;
1373
+ try {
1374
+ while (this.commitLane.length > 0) {
1375
+ const tx = this.commitLane[0];
1376
+ if (tx.status !== 'pending') {
1377
+ this.commitLane.shift();
1378
+ continue;
1379
+ }
1380
+ tx.status = 'executing';
1381
+ tx.attempts += 1;
1382
+ try {
1383
+ const result = await this.mutationExecutor.commit(tx.operations, {
1384
+ idempotencyKey: tx.id,
1385
+ causedByTaskId: tx.causedByTaskId ?? undefined,
1386
+ });
1387
+ tx.lastSyncId = result?.lastSyncId ?? 0;
1388
+ tx.status = 'completed';
1389
+ this.commitLane.shift();
1390
+ this.emit('transaction:completed', tx);
1391
+ this.emit(`transaction:completed:${tx.id}`, tx);
1392
+ }
1393
+ catch (err) {
1394
+ const error = err instanceof Error ? err : new Error(String(err));
1395
+ if (!this.isPermanentError(error)) {
1396
+ // Transient — leave at head, retry on next kick (reconnect or
1397
+ // next enqueueCommit). Don't tight-loop while WS is down.
1398
+ tx.status = 'pending';
1399
+ getContext().logger.debug('[TransactionQueue] commit lane transient', {
1400
+ txId: tx.id.slice(0, 12),
1401
+ attempts: tx.attempts,
1402
+ message: error.message,
1403
+ });
1404
+ break;
1405
+ }
1406
+ tx.status = 'failed';
1407
+ tx.error = error;
1408
+ this.commitLane.shift();
1409
+ getContext().logger.warn('[TransactionQueue] commit lane permanent error', {
1410
+ txId: tx.id.slice(0, 12),
1411
+ attempts: tx.attempts,
1412
+ message: error.message,
1413
+ });
1414
+ this.emit('transaction:failed', { transaction: tx, error, permanent: true });
1415
+ this.emit(`transaction:failed:${tx.id}`, { error });
1416
+ }
1417
+ }
1418
+ }
1419
+ finally {
1420
+ this.commitProcessing = false;
1421
+ }
1422
+ }
1423
+ /**
1424
+ * Promise-based confirmation for a commit-lane transaction. Resolves
1425
+ * with the server-side `lastSyncId` once `mutation_result` lands;
1426
+ * rejects on permanent failure. Backs the `wait: 'confirmed'` semantics
1427
+ * of `ablo.commits.create()`.
1428
+ */
1429
+ waitForCommitReceipt(clientTxId) {
1430
+ return new Promise((resolve, reject) => {
1431
+ const existing = this.commitStore.get(clientTxId);
1432
+ if (existing?.status === 'completed') {
1433
+ resolve({ lastSyncId: existing.lastSyncId ?? 0 });
1434
+ return;
1435
+ }
1436
+ if (existing?.status === 'failed' && existing.error) {
1437
+ reject(existing.error);
1438
+ return;
1439
+ }
1440
+ const onCompleted = (tx) => {
1441
+ cleanup();
1442
+ resolve({ lastSyncId: tx.lastSyncId ?? 0 });
1443
+ };
1444
+ const onFailed = ({ error }) => {
1445
+ cleanup();
1446
+ reject(error);
1447
+ };
1448
+ const cleanup = () => {
1449
+ this.off(`transaction:completed:${clientTxId}`, onCompleted);
1450
+ this.off(`transaction:failed:${clientTxId}`, onFailed);
1451
+ };
1452
+ this.on(`transaction:completed:${clientTxId}`, onCompleted);
1453
+ this.on(`transaction:failed:${clientTxId}`, onFailed);
1454
+ });
1455
+ }
1456
+ isReorderPayload(data) {
1457
+ if (!data || typeof data !== 'object')
1458
+ return false;
1459
+ return 'order' in data || 'orderKey' in data || 'position' in data;
1460
+ }
1461
+ /**
1462
+ * Determine if an error is transient (retryable) vs permanent (non-retryable).
1463
+ *
1464
+ * IMPORTANT: Uses a BLOCKLIST approach for safety - only retry on known transient errors.
1465
+ * Any unknown error type defaults to permanent (don't retry) to prevent infinite loops.
1466
+ *
1467
+ * Transient errors (will retry):
1468
+ * - Network failures, connection errors, timeouts
1469
+ * - Server errors (5xx status codes)
1470
+ * - Rate limiting (429)
1471
+ *
1472
+ * Permanent errors (won't retry - includes but not limited to):
1473
+ * - Validation errors, constraint violations
1474
+ * - Not found, unauthorized, forbidden
1475
+ * - Any other business logic error from the server
1476
+ */
1477
+ isPermanentError(error) {
1478
+ // Typed connection error (e.g. ws_not_ready, transport timeout) is
1479
+ // always transient — the message text varies ("SyncWebSocket not
1480
+ // connected", "commit timed out after ...") and string-matching them
1481
+ // is brittle. Class identity is the right signal.
1482
+ if (error instanceof AbloConnectionError) {
1483
+ return false;
1484
+ }
1485
+ const message = error?.message?.toLowerCase() || '';
1486
+ // Network/connection errors are transient - retry these
1487
+ const isNetworkError = message.includes('failed to fetch') ||
1488
+ message.includes('network error') ||
1489
+ message.includes('networkerror') ||
1490
+ message.includes('connection refused') ||
1491
+ message.includes('connection reset') ||
1492
+ message.includes('timeout') ||
1493
+ message.includes('econnrefused') ||
1494
+ message.includes('econnreset') ||
1495
+ message.includes('etimedout') ||
1496
+ message.includes('socket hang up');
1497
+ if (isNetworkError) {
1498
+ return false; // Transient - retry
1499
+ }
1500
+ // Check HTTP status codes
1501
+ const status = extractStatusCode(error);
1502
+ // 5xx server errors and 429 rate limiting are transient - retry
1503
+ if (status !== undefined) {
1504
+ if (status >= 500 || status === 429) {
1505
+ return false; // Transient - retry
1506
+ }
1507
+ // Any other status code (4xx except 429) is permanent
1508
+ return true;
1509
+ }
1510
+ // GraphQL errors with HTTP 200 but error payload are permanent
1511
+ // These are validation/business logic errors that won't change on retry
1512
+ const responseErrors = asTransportError(error).response?.errors;
1513
+ if (Array.isArray(responseErrors) && responseErrors.length > 0) {
1514
+ return true; // Permanent - don't retry
1515
+ }
1516
+ // Default: treat unknown errors as permanent to prevent infinite loops
1517
+ // This is the safe default - better to fail fast than retry forever
1518
+ return true;
1519
+ }
1520
+ /**
1521
+ * Handle transaction failure
1522
+ */
1523
+ async handleFailure(transaction, error) {
1524
+ transaction.attempts++;
1525
+ // Check if this is a permanent error that should NOT be retried
1526
+ if (this.isPermanentError(error)) {
1527
+ // Elevated to warn — permanent errors mean user writes were rejected
1528
+ // by the server, so the user should be able to see WHY in the
1529
+ // console (not just via Sentry). Include the typed AbloError fields
1530
+ // so the cause is visible: `type`/`code`/`httpStatus` are what
1531
+ // distinguish e.g. FK-violation (AbloValidationError) from auth
1532
+ // expiry (AbloAuthenticationError).
1533
+ try {
1534
+ const abloErr = error instanceof AbloError ? error : undefined;
1535
+ getContext().logger.warn('[TransactionQueue] Permanent error - rolling back', {
1536
+ txId: transaction.id.slice(0, 8),
1537
+ type: transaction.type,
1538
+ model: transaction.modelName,
1539
+ modelId: transaction.modelId.slice(0, 12),
1540
+ errorType: abloErr?.type ?? error?.name,
1541
+ errorCode: abloErr?.code,
1542
+ httpStatus: abloErr?.httpStatus,
1543
+ requestId: abloErr?.requestId,
1544
+ message: error?.message,
1545
+ inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
1546
+ });
1547
+ }
1548
+ catch { }
1549
+ // Mark as failed immediately and rollback
1550
+ this.store.updateStatus(transaction.id, 'failed');
1551
+ if (this.config.enableOptimistic) {
1552
+ await this.rollbackOptimistic(transaction, 'permanent_error', error);
1553
+ }
1554
+ this.emit('transaction:failed', { transaction, error, permanent: true });
1555
+ return;
1556
+ }
1557
+ if (transaction.attempts < this.config.maxRetries) {
1558
+ // Backoff for retryable server responses (HTTP 429/503).
1559
+ // Exponential with jitter, capped — tunable via
1560
+ // `TransactionQueueConfig.retryBackoff`.
1561
+ try {
1562
+ const status = extractStatusCode(error);
1563
+ if (status === 429 || status === 503) {
1564
+ const { baseMs, capMs } = this.config.retryBackoff;
1565
+ const delay = Math.min(capMs, Math.floor(baseMs * Math.pow(2, transaction.attempts - 1)));
1566
+ const jitter = Math.floor(Math.random() * 100);
1567
+ await new Promise((r) => setTimeout(r, delay + jitter));
1568
+ }
1569
+ }
1570
+ catch { }
1571
+ // Retry
1572
+ this.store.updateStatus(transaction.id, 'pending');
1573
+ this.enqueue(transaction);
1574
+ }
1575
+ else {
1576
+ // Mark as failed and rollback
1577
+ this.store.updateStatus(transaction.id, 'failed');
1578
+ if (this.config.enableOptimistic) {
1579
+ await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
1580
+ }
1581
+ this.emit('transaction:failed', { transaction, error });
1582
+ }
1583
+ }
1584
+ /**
1585
+ * Conflict resolution
1586
+ */
1587
+ async handleConflict(transaction, serverData) {
1588
+ const { strategy, resolver } = this.config.conflictResolution;
1589
+ switch (strategy) {
1590
+ case 'last-write-wins':
1591
+ // Server wins, cancel transaction
1592
+ this.store.updateStatus(transaction.id, 'rolled_back');
1593
+ await this.rollbackOptimistic(transaction, 'conflict_server_wins');
1594
+ break;
1595
+ case 'merge':
1596
+ // Merge changes
1597
+ const merged = this.mergeData(transaction.data, serverData);
1598
+ transaction.data = merged;
1599
+ this.enqueue(transaction);
1600
+ break;
1601
+ case 'reject':
1602
+ // Client wins, re-execute
1603
+ this.enqueue(transaction);
1604
+ break;
1605
+ case 'custom':
1606
+ if (resolver) {
1607
+ const resolved = resolver(transaction.data, serverData);
1608
+ transaction.data = resolved;
1609
+ this.enqueue(transaction);
1610
+ }
1611
+ break;
1612
+ }
1613
+ }
1614
+ /**
1615
+ * Optimistic updates
1616
+ */
1617
+ applyOptimisticCreate(model, transaction) {
1618
+ this.optimisticUpdates.set(transaction.id, {
1619
+ model,
1620
+ previousState: null,
1621
+ transaction,
1622
+ });
1623
+ this.emit('optimistic:create', { model, transaction });
1624
+ }
1625
+ applyOptimisticUpdate(model, transaction) {
1626
+ this.optimisticUpdates.set(transaction.id, {
1627
+ model,
1628
+ previousState: transaction.previousData,
1629
+ transaction,
1630
+ });
1631
+ this.emit('optimistic:update', { model, transaction });
1632
+ }
1633
+ applyOptimisticDelete(model, transaction) {
1634
+ this.optimisticUpdates.set(transaction.id, {
1635
+ model,
1636
+ previousState: transaction.previousData,
1637
+ transaction,
1638
+ });
1639
+ this.emit('optimistic:delete', { model, transaction });
1640
+ }
1641
+ async rollbackOptimistic(transaction, reason, error) {
1642
+ const optimistic = this.optimisticUpdates.get(transaction.id);
1643
+ if (!optimistic)
1644
+ return;
1645
+ this.emit('optimistic:rollback', {
1646
+ model: optimistic.model,
1647
+ previousState: optimistic.previousState,
1648
+ transaction,
1649
+ reason: reason ?? 'unknown',
1650
+ error,
1651
+ });
1652
+ this.optimisticUpdates.delete(transaction.id);
1653
+ }
1654
+ /**
1655
+ * Execute individual transaction via the unified commit path
1656
+ */
1657
+ async executeTransaction(transaction) {
1658
+ const { type, modelName, modelId, data } = transaction;
1659
+ const schemaName = stripModelSuffix(modelName);
1660
+ const mutationType = TX_TYPE_TO_MUTATION_OP[type];
1661
+ const model = normalizeModelKey(modelName);
1662
+ const input = (type === 'create' || type === 'update') ? data : undefined;
1663
+ try {
1664
+ await this.mutationExecutor.commit([
1665
+ applyStaleWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
1666
+ ]);
1667
+ }
1668
+ catch (error) {
1669
+ handleMutationError(error, `${type}-mutation`, schemaName, modelId);
1670
+ }
1671
+ }
1672
+ /**
1673
+ * Persistence
1674
+ */
1675
+ async loadPersistedTransactions(database) {
1676
+ if (!this.config.enablePersistence)
1677
+ return;
1678
+ try {
1679
+ const persisted = await database.getPersistedTransactions();
1680
+ for (const data of persisted) {
1681
+ const transaction = this.deserializeTransaction(data);
1682
+ this.store.add(transaction);
1683
+ this.enqueue(transaction);
1684
+ }
1685
+ }
1686
+ catch (error) {
1687
+ getContext().observability.captureTransactionFailure({
1688
+ context: 'load-persisted-transactions',
1689
+ error: error instanceof Error ? error : String(error),
1690
+ });
1691
+ }
1692
+ }
1693
+ deserializeTransaction(data) {
1694
+ return { ...data, status: 'pending' };
1695
+ }
1696
+ /**
1697
+ * Cancel transactions for a specific model
1698
+ */
1699
+ cancelTransactionsForModel(modelId, transactionType) {
1700
+ const cancelledTransactions = [];
1701
+ const allTransactions = [
1702
+ ...this.store.getByStatus('pending'),
1703
+ ...this.store.getByStatus('executing'),
1704
+ ];
1705
+ for (const transaction of allTransactions) {
1706
+ if (transaction.modelId === modelId) {
1707
+ if (!transactionType || transaction.type === transactionType) {
1708
+ cancelledTransactions.push(transaction);
1709
+ this.store.updateStatus(transaction.id, 'rolled_back');
1710
+ this.rollbackOptimistic(transaction, 'model_cancelled');
1711
+ }
1712
+ }
1713
+ }
1714
+ return cancelledTransactions;
1715
+ }
1716
+ /**
1717
+ * LINEAR PATTERN: Cancel transactions for child entities by foreign key
1718
+ *
1719
+ * Used by SyncedStore for cascade cancellation when a parent is deleted.
1720
+ * This keeps FK relationship knowledge in ModelRegistry/SyncedStore,
1721
+ * while TransactionQueue just handles the cancellation mechanics.
1722
+ *
1723
+ * @param childModelName - The child model type (e.g., 'SlideLayer')
1724
+ * @param foreignKey - The FK property name (e.g., 'slideId')
1725
+ * @param parentId - The deleted parent's ID
1726
+ * @returns Number of transactions cancelled
1727
+ */
1728
+ cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
1729
+ let cancelled = 0;
1730
+ const allTransactions = [
1731
+ ...this.store.getByStatus('pending'),
1732
+ ...this.store.getByStatus('executing'),
1733
+ ...this.store.getByStatus('awaiting_delta'),
1734
+ ];
1735
+ for (const transaction of allTransactions) {
1736
+ if (transaction.modelName === childModelName) {
1737
+ // Check if this transaction's data contains the parent FK
1738
+ const fkValue = transaction.data?.[foreignKey];
1739
+ if (fkValue === parentId) {
1740
+ this.store.updateStatus(transaction.id, 'rolled_back');
1741
+ this.rollbackOptimistic(transaction, 'cascade_parent_deleted');
1742
+ cancelled++;
1743
+ getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
1744
+ txId: transaction.id.slice(0, 12),
1745
+ model: childModelName,
1746
+ foreignKey,
1747
+ parentId: parentId.slice(0, 12),
1748
+ });
1749
+ }
1750
+ }
1751
+ }
1752
+ return cancelled;
1753
+ }
1754
+ /**
1755
+ * Get count of outstanding transactions
1756
+ */
1757
+ getOutstandingTransactionCount() {
1758
+ return this.store.getByStatus('pending').length + this.store.getByStatus('executing').length;
1759
+ }
1760
+ /**
1761
+ * Utilities
1762
+ */
1763
+ generateId() {
1764
+ return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1765
+ }
1766
+ mergeData(local, remote) {
1767
+ return { ...(remote || {}), ...(local || {}) };
1768
+ }
1769
+ extractCreateData(model) {
1770
+ return projectCommitPayload(model.getModelName(), model.toJSON(), { dropUndefined: false });
1771
+ }
1772
+ mapChangesToInput(modelName, changes) {
1773
+ return projectCommitPayload(modelName, changes, { dropUndefined: true });
1774
+ }
1775
+ extractUpdateData(model) {
1776
+ return projectCommitPayload(model.getModelName(), model.getChanges(), { dropUndefined: true });
1777
+ }
1778
+ buildUpdateInput(modelName, changes) {
1779
+ return projectCommitPayload(modelName, changes, { dropUndefined: true });
1780
+ }
1781
+ // Derive previous values for changed fields to support accurate rollback.
1782
+ //
1783
+ // The previous Slide-specific branch reaching into `_data` was removed:
1784
+ // the field name in the comment (`_localChanges`) didn't match the
1785
+ // code (`_data`), no `Slide` class still defines either field, and
1786
+ // hardcoded model-name checks don't belong in a generic queue. If a
1787
+ // model ever needs to surface previous-state outside `modifiedProperties`,
1788
+ // expose a typed `getPreviousData()` accessor on Model and call that.
1789
+ extractPreviousData(model, updateInput) {
1790
+ const prev = { id: model.id };
1791
+ if (model.modifiedProperties instanceof Map && model.modifiedProperties.size > 0) {
1792
+ for (const [key, change] of model.modifiedProperties) {
1793
+ // Only include keys that are part of this update if provided
1794
+ if (updateInput && !(key in updateInput))
1795
+ continue;
1796
+ prev[key] = change.old;
1797
+ }
1798
+ }
1799
+ return prev;
1800
+ }
1801
+ /**
1802
+ * Public API
1803
+ */
1804
+ getStats() {
1805
+ return {
1806
+ pending: this.store.getByStatus('pending').length,
1807
+ executing: this.store.getByStatus('executing').length,
1808
+ completed: this.store.getByStatus('completed').length,
1809
+ failed: this.store.getByStatus('failed').length,
1810
+ optimistic: this.optimisticUpdates.size,
1811
+ totalTransactions: this.store.getAll().length,
1812
+ batchIndex: this.batchIndex,
1813
+ config: { ...this.config },
1814
+ };
1815
+ }
1816
+ /**
1817
+ * Get detailed debug info for the sync debug page
1818
+ * Exposes internal state that helps diagnose delta confirmation issues
1819
+ */
1820
+ getDebugInfo() {
1821
+ const awaitingDelta = this.store.getByStatus('awaiting_delta');
1822
+ return {
1823
+ lastSeenSyncId: this.lastSeenSyncId,
1824
+ awaitingDeltaCount: awaitingDelta.length,
1825
+ awaitingDeltaTransactions: awaitingDelta.map((tx) => ({
1826
+ id: tx.id.slice(0, 8),
1827
+ type: tx.type,
1828
+ modelName: tx.modelName,
1829
+ modelId: tx.modelId.slice(0, 8),
1830
+ syncIdNeeded: tx.syncIdNeededForCompletion,
1831
+ createdAt: tx.createdAt,
1832
+ age: Date.now() - tx.createdAt,
1833
+ })),
1834
+ pendingTransactions: this.store.getByStatus('pending').map((tx) => ({
1835
+ id: tx.id.slice(0, 8),
1836
+ type: tx.type,
1837
+ modelName: tx.modelName,
1838
+ modelId: tx.modelId.slice(0, 8),
1839
+ })),
1840
+ executingTransactions: this.store.getByStatus('executing').map((tx) => ({
1841
+ id: tx.id.slice(0, 8),
1842
+ type: tx.type,
1843
+ modelName: tx.modelName,
1844
+ modelId: tx.modelId.slice(0, 8),
1845
+ })),
1846
+ };
1847
+ }
1848
+ /**
1849
+ * Set configuration
1850
+ */
1851
+ setConfig(config) {
1852
+ this.config = { ...this.config, ...config };
1853
+ }
1854
+ /**
1855
+ * Handle incoming sync delta - simplified for permanent IDs
1856
+ */
1857
+ handleSyncDelta(delta) {
1858
+ // With permanent IDs, no reconciliation needed!
1859
+ // Just emit the delta for ObjectPool to handle directly
1860
+ this.emit('sync:delta', {
1861
+ id: delta.id,
1862
+ modelName: delta.modelName,
1863
+ action: delta.action,
1864
+ data: delta.data,
1865
+ });
1866
+ return true;
1867
+ }
1868
+ /**
1869
+ * Cleanup and dispose resources
1870
+ */
1871
+ dispose() {
1872
+ // Cancel all active optimistic updates
1873
+ for (const [, optimistic] of this.optimisticUpdates) {
1874
+ this.emit('optimistic:rollback', {
1875
+ model: optimistic.model,
1876
+ previousState: optimistic.previousState,
1877
+ transaction: optimistic.transaction,
1878
+ reason: 'dispose',
1879
+ });
1880
+ }
1881
+ // Clear processing
1882
+ if (this.processTimer) {
1883
+ clearTimeout(this.processTimer);
1884
+ }
1885
+ // Clear store
1886
+ this.store.clear();
1887
+ this.optimisticUpdates.clear();
1888
+ this.executionQueue = [];
1889
+ // Clear event listeners
1890
+ this.removeAllListeners();
1891
+ // Reset state
1892
+ this.isProcessing = false;
1893
+ this.batchIndex = 0;
1894
+ }
1895
+ }