@abloatai/ablo 0.25.0 → 0.26.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 (244) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.d.ts +72 -195
  5. package/dist/BaseSyncedStore.js +240 -625
  6. package/dist/Database.d.ts +8 -9
  7. package/dist/Database.js +9 -14
  8. package/dist/LazyReferenceCollection.js +5 -0
  9. package/dist/Model.js +6 -8
  10. package/dist/ModelRegistry.d.ts +25 -6
  11. package/dist/ModelRegistry.js +12 -6
  12. package/dist/NetworkMonitor.js +1 -1
  13. package/dist/ObjectPool.js +9 -3
  14. package/dist/SyncClient.d.ts +15 -5
  15. package/dist/SyncClient.js +152 -66
  16. package/dist/SyncEngineContext.d.ts +1 -3
  17. package/dist/SyncEngineContext.js +1 -2
  18. package/dist/agent/Agent.d.ts +21 -8
  19. package/dist/agent/Agent.js +26 -13
  20. package/dist/ai-sdk/coordination-context.js +9 -3
  21. package/dist/ai-sdk/wrap.d.ts +1 -1
  22. package/dist/ai-sdk/wrap.js +1 -1
  23. package/dist/auth/credentialPolicy.d.ts +10 -2
  24. package/dist/auth/credentialSource.d.ts +1 -13
  25. package/dist/auth/credentialSource.js +6 -13
  26. package/dist/auth/index.js +3 -3
  27. package/dist/cli.cjs +328 -262
  28. package/dist/client/Ablo.d.ts +35 -764
  29. package/dist/client/Ablo.js +64 -688
  30. package/dist/client/ApiClient.d.ts +14 -1
  31. package/dist/client/ApiClient.js +63 -9
  32. package/dist/client/auth.d.ts +8 -17
  33. package/dist/client/auth.js +42 -6
  34. package/dist/client/consoleLogger.d.ts +36 -0
  35. package/dist/client/consoleLogger.js +45 -0
  36. package/dist/client/createInternalComponents.js +1 -1
  37. package/dist/client/createModelProxy.js +10 -6
  38. package/dist/client/credentialEndpoint.d.ts +63 -0
  39. package/dist/client/credentialEndpoint.js +87 -0
  40. package/dist/client/hostedEndpoints.d.ts +24 -0
  41. package/dist/client/hostedEndpoints.js +24 -0
  42. package/dist/client/httpClient.d.ts +1 -1
  43. package/dist/client/identity.js +2 -1
  44. package/dist/client/modelRegistration.d.ts +14 -0
  45. package/dist/client/modelRegistration.js +321 -0
  46. package/dist/client/options.d.ts +396 -0
  47. package/dist/client/options.js +10 -0
  48. package/dist/client/resourceTypes.d.ts +344 -0
  49. package/dist/client/resourceTypes.js +13 -0
  50. package/dist/client/schemaConfig.d.ts +56 -0
  51. package/dist/client/schemaConfig.js +188 -0
  52. package/dist/client/sessionMint.d.ts +1 -1
  53. package/dist/client/validateAbloOptions.js +1 -1
  54. package/dist/client/wsMutationExecutor.d.ts +33 -0
  55. package/dist/client/wsMutationExecutor.js +77 -0
  56. package/dist/context.js +0 -3
  57. package/dist/coordination/index.d.ts +8 -1
  58. package/dist/coordination/index.js +24 -1
  59. package/dist/coordination/trace.d.ts +0 -1
  60. package/dist/coordination/trace.js +0 -1
  61. package/dist/core/DatabaseManager.d.ts +0 -1
  62. package/dist/core/DatabaseManager.js +26 -24
  63. package/dist/core/QueryProcessor.js +7 -8
  64. package/dist/core/QueryView.d.ts +14 -2
  65. package/dist/core/QueryView.js +4 -5
  66. package/dist/core/StoreManager.d.ts +2 -2
  67. package/dist/core/StoreManager.js +5 -1
  68. package/dist/core/index.d.ts +1 -1
  69. package/dist/core/openIDBWithTimeout.js +15 -11
  70. package/dist/core/query-utils.js +1 -1
  71. package/dist/core/storeContract.d.ts +143 -0
  72. package/dist/core/storeContract.js +16 -0
  73. package/dist/errorCodes.d.ts +11 -2
  74. package/dist/errorCodes.js +155 -137
  75. package/dist/errors.d.ts +18 -7
  76. package/dist/errors.js +14 -1
  77. package/dist/index.js +5 -0
  78. package/dist/interfaces/index.d.ts +6 -20
  79. package/dist/keys/index.js +18 -9
  80. package/dist/mutators/UndoManager.js +6 -1
  81. package/dist/mutators/defineMutators.d.ts +1 -3
  82. package/dist/policy/types.d.ts +1 -1
  83. package/dist/query/client.d.ts +11 -0
  84. package/dist/query/client.js +81 -52
  85. package/dist/react/AbloProvider.d.ts +6 -10
  86. package/dist/react/AbloProvider.js +22 -15
  87. package/dist/react/context.d.ts +2 -118
  88. package/dist/react/useAblo.js +1 -1
  89. package/dist/react/useErrorListener.js +1 -1
  90. package/dist/react/useMutationFailureListener.js +1 -1
  91. package/dist/react/useMutators.js +7 -1
  92. package/dist/react/useReactive.js +0 -1
  93. package/dist/react/useUndoScope.js +2 -2
  94. package/dist/schema/ddlLock.d.ts +39 -0
  95. package/dist/schema/ddlLock.js +51 -0
  96. package/dist/schema/field.js +1 -1
  97. package/dist/schema/index.d.ts +4 -3
  98. package/dist/schema/index.js +15 -5
  99. package/dist/schema/model.d.ts +3 -3
  100. package/dist/schema/model.js +2 -2
  101. package/dist/schema/openapi.js +5 -1
  102. package/dist/schema/queries.d.ts +1 -1
  103. package/dist/schema/queries.js +1 -0
  104. package/dist/schema/residency.d.ts +35 -0
  105. package/dist/schema/residency.js +27 -0
  106. package/dist/schema/roles.d.ts +25 -0
  107. package/dist/schema/roles.js +37 -0
  108. package/dist/schema/schema.d.ts +3 -3
  109. package/dist/schema/schema.js +11 -7
  110. package/dist/schema/serialize.d.ts +8 -8
  111. package/dist/schema/sync-delta-row.d.ts +2 -2
  112. package/dist/schema/sync-delta-row.js +2 -2
  113. package/dist/server/storage-mode.d.ts +8 -1
  114. package/dist/server/storage-mode.js +14 -4
  115. package/dist/source/adapter.d.ts +1 -1
  116. package/dist/source/adapters/drizzle.js +4 -3
  117. package/dist/source/adapters/kysely.js +4 -3
  118. package/dist/source/adapters/memory.js +1 -1
  119. package/dist/source/adapters/prisma.js +5 -4
  120. package/dist/source/conformance.js +10 -8
  121. package/dist/source/connector.js +4 -2
  122. package/dist/source/factory.d.ts +106 -0
  123. package/dist/source/factory.js +273 -0
  124. package/dist/source/index.d.ts +11 -461
  125. package/dist/source/index.js +14 -416
  126. package/dist/source/next.d.ts +1 -1
  127. package/dist/source/next.js +1 -1
  128. package/dist/source/pushQueue.d.ts +1 -1
  129. package/dist/source/pushQueue.js +6 -3
  130. package/dist/source/signing.d.ts +59 -0
  131. package/dist/source/signing.js +142 -0
  132. package/dist/source/types.d.ts +334 -0
  133. package/dist/source/types.js +44 -0
  134. package/dist/stores/ObjectStore.d.ts +1 -1
  135. package/dist/stores/ObjectStore.js +23 -23
  136. package/dist/stores/SyncActionStore.d.ts +1 -1
  137. package/dist/stores/SyncActionStore.js +70 -35
  138. package/dist/surface.d.ts +1 -1
  139. package/dist/surface.js +1 -0
  140. package/dist/sync/AreaOfInterestManager.d.ts +2 -2
  141. package/dist/sync/AreaOfInterestManager.js +1 -1
  142. package/dist/sync/BootstrapHelper.d.ts +1 -3
  143. package/dist/sync/BootstrapHelper.js +29 -8
  144. package/dist/sync/ConnectionManager.d.ts +8 -0
  145. package/dist/sync/ConnectionManager.js +19 -4
  146. package/dist/sync/HydrationCoordinator.d.ts +12 -0
  147. package/dist/sync/HydrationCoordinator.js +25 -5
  148. package/dist/sync/NetworkProbe.d.ts +3 -2
  149. package/dist/sync/NetworkProbe.js +7 -6
  150. package/dist/sync/SyncWebSocket.d.ts +71 -120
  151. package/dist/sync/SyncWebSocket.js +324 -630
  152. package/dist/sync/awaitClaimGrant.js +27 -19
  153. package/dist/sync/bootstrapApply.d.ts +60 -0
  154. package/dist/sync/bootstrapApply.js +65 -0
  155. package/dist/sync/commitFrames.d.ts +43 -0
  156. package/dist/sync/commitFrames.js +94 -0
  157. package/dist/sync/createClaimStream.js +3 -2
  158. package/dist/sync/createSnapshot.d.ts +1 -3
  159. package/dist/sync/credentialLifecycle.d.ts +175 -0
  160. package/dist/sync/credentialLifecycle.js +329 -0
  161. package/dist/sync/deltaPipeline.d.ts +111 -0
  162. package/dist/sync/deltaPipeline.js +262 -0
  163. package/dist/sync/groupChange.d.ts +111 -0
  164. package/dist/sync/groupChange.js +246 -0
  165. package/dist/sync/heartbeat.d.ts +62 -0
  166. package/dist/sync/heartbeat.js +91 -0
  167. package/dist/sync/participants.d.ts +8 -8
  168. package/dist/sync/syncCursor.d.ts +44 -0
  169. package/dist/sync/syncCursor.js +59 -0
  170. package/dist/sync/syncPlan.d.ts +62 -0
  171. package/dist/sync/syncPlan.js +51 -0
  172. package/dist/sync/wsFrameHandlers.d.ts +117 -0
  173. package/dist/sync/wsFrameHandlers.js +378 -0
  174. package/dist/testing/fixtures/bootstrap.d.ts +9 -11
  175. package/dist/testing/fixtures/deltas.d.ts +1 -1
  176. package/dist/testing/fixtures/models.js +1 -0
  177. package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
  178. package/dist/testing/helpers/sync-engine-harness.js +2 -1
  179. package/dist/testing/index.d.ts +1 -1
  180. package/dist/testing/index.js +1 -1
  181. package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
  182. package/dist/testing/mocks/MockSyncContext.js +1 -32
  183. package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
  184. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  185. package/dist/transactions/TransactionQueue.d.ts +20 -56
  186. package/dist/transactions/TransactionQueue.js +101 -487
  187. package/dist/transactions/TransactionStore.d.ts +18 -0
  188. package/dist/transactions/TransactionStore.js +51 -0
  189. package/dist/transactions/coalesceRules.d.ts +34 -0
  190. package/dist/transactions/coalesceRules.js +117 -0
  191. package/dist/transactions/commitPayload.d.ts +134 -0
  192. package/dist/transactions/commitPayload.js +152 -0
  193. package/dist/transactions/deltaConfirmation.d.ts +60 -0
  194. package/dist/transactions/deltaConfirmation.js +223 -0
  195. package/dist/transactions/optimistic.d.ts +24 -0
  196. package/dist/transactions/optimistic.js +45 -0
  197. package/dist/transactions/persistedReplay.d.ts +93 -0
  198. package/dist/transactions/persistedReplay.js +105 -0
  199. package/dist/types/index.d.ts +3 -3
  200. package/dist/types/index.js +0 -5
  201. package/dist/types/modelData.d.ts +12 -0
  202. package/dist/types/modelData.js +11 -0
  203. package/dist/types/participant.d.ts +21 -0
  204. package/dist/types/participant.js +12 -0
  205. package/dist/types/streams.d.ts +8 -14
  206. package/dist/utils/mobx-setup.d.ts +1 -1
  207. package/dist/utils/mobx-setup.js +2 -2
  208. package/dist/wire/errorEnvelope.d.ts +11 -2
  209. package/dist/wire/errorEnvelope.js +14 -2
  210. package/dist/wire/frames.d.ts +90 -0
  211. package/dist/wire/frames.js +55 -1
  212. package/dist/wire/index.d.ts +4 -1
  213. package/dist/wire/index.js +20 -1
  214. package/dist/wire/protocol.d.ts +45 -0
  215. package/dist/wire/protocol.js +45 -0
  216. package/dist/wire/protocolVersion.d.ts +56 -0
  217. package/dist/wire/protocolVersion.js +63 -0
  218. package/docs/api-keys.md +4 -3
  219. package/docs/examples/existing-python-backend.md +3 -3
  220. package/docs/identity.md +4 -4
  221. package/docs/integration-guide.md +1 -1
  222. package/docs/react.md +1 -1
  223. package/docs/sessions.md +5 -7
  224. package/package.json +15 -13
  225. package/dist/client/index.d.ts +0 -36
  226. package/dist/client/index.js +0 -33
  227. package/dist/config/index.d.ts +0 -10
  228. package/dist/config/index.js +0 -12
  229. package/dist/interfaces/headless.d.ts +0 -95
  230. package/dist/interfaces/headless.js +0 -41
  231. package/dist/query/index.d.ts +0 -6
  232. package/dist/query/index.js +0 -5
  233. package/dist/realtime/index.d.ts +0 -10
  234. package/dist/realtime/index.js +0 -9
  235. package/dist/schema/plane.d.ts +0 -23
  236. package/dist/schema/plane.js +0 -19
  237. package/dist/server/next.d.ts +0 -51
  238. package/dist/server/next.js +0 -47
  239. package/dist/sync/OfflineFlush.d.ts +0 -9
  240. package/dist/sync/OfflineFlush.js +0 -22
  241. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  242. package/dist/sync/OfflineTransactionStore.js +0 -263
  243. package/dist/transactions/index.d.ts +0 -16
  244. package/dist/transactions/index.js +0 -7
@@ -8,195 +8,17 @@
8
8
  * - Proper dependency injection (no singleton)
9
9
  */
10
10
  import { EventEmitter } from 'events';
11
+ import { Model } from '../Model.js';
11
12
  import { getContext } from '../context.js';
12
- import { getActiveRegistry } from '../ModelRegistry.js';
13
- import { MutationOperationType } from '../types/index.js';
14
13
  import { handleMutationError } from './mutation-error-handler.js';
15
14
  import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
16
15
  import { SyncPosition } from '../sync/syncPosition.js';
17
- /**
18
- * Framework-internal keys added by `Model.toJSON()` that must never
19
- * reach the wire. The server treats each top-level key as a target
20
- * column, so shipping these would blow up the INSERT/UPDATE.
21
- */
22
- const FRAMEWORK_KEYS = new Set(['__class', '__typename', 'clientId', 'syncStatus']);
23
- /**
24
- * Project a Model's serialized data onto its schema-declared fields
25
- * and return a wire-safe commit payload. Two jobs:
26
- *
27
- * 1. Drop framework internals (`__class`, `__typename`, `clientId`,
28
- * `syncStatus`) and anything not declared on the model's schema.
29
- * 2. JSON.stringify values typed as `field.json()` — TEXT columns
30
- * storing JSON need explicit stringification; postgres.js won't
31
- * auto-serialize for non-JSONB columns.
32
- *
33
- * For updates (`dropUndefined: true`), `undefined` values are also
34
- * stripped so they don't translate to `SET column = NULL` on the
35
- * server side.
36
- *
37
- * Fields are read from `ModelRegistry`, populated by
38
- * `registerModelsFromSchema` at SDK initialization. If the model
39
- * isn't registered with field metadata (edge case — e.g., tests or
40
- * manually registered models), projection falls back to identity and
41
- * the caller gets whatever the Model serialized.
42
- */
43
- function projectCommitPayload(modelName, source, opts) {
44
- const metadata = getActiveRegistry().getMetadata(modelName);
45
- const fields = metadata?.fields;
46
- const out = {};
47
- if (!fields) {
48
- // Unknown registration — strip framework keys and ship the rest.
49
- for (const [k, v] of Object.entries(source)) {
50
- if (FRAMEWORK_KEYS.has(k))
51
- continue;
52
- if (opts.dropUndefined && v === undefined)
53
- continue;
54
- out[k] = v;
55
- }
56
- return out;
57
- }
58
- for (const [key, meta] of Object.entries(fields)) {
59
- if (!(key in source))
60
- continue;
61
- const value = source[key];
62
- if (opts.dropUndefined && value === undefined)
63
- continue;
64
- // JSON-typed fields (`jsonb` on the server): ship as OBJECTS over
65
- // the wire, not pre-stringified strings. Previously we stringified
66
- // here, which round-tripped incorrectly:
67
- //
68
- // 1. Client stringifies `position: {x, y}` → `'{"x":...}'`
69
- // 2. Server writes to jsonb column (parses string → jsonb object, fine)
70
- // 3. Server's delta echoes `data: JSON.stringify(op.input)` where
71
- // `op.input.position` is still the STRING from step 1
72
- // 4. Client merges delta → `model.position = "{...}"` (STRING)
73
- // 5. Next drag: `{ ...layer.position, x, y }` spreads the STRING
74
- // char-by-char, producing corrupted char-indexed objects like
75
- // `{"0":"{","1":"\"","2":"x",...,"x":null,"y":null,...}`
76
- // 6. That corrupt object lands in the next commit, stored in jsonb.
77
- //
78
- // Sending objects avoids the round-trip mismatch: the wire carries
79
- // the object through delta + commit unchanged, and `postgres-js`
80
- // serializes JS objects to jsonb correctly via its own
81
- // `json.serialize` (triggered by Postgres's ParameterDescription
82
- // response identifying the column as type 3802 / jsonb).
83
- out[key] = value;
84
- }
85
- return out;
86
- }
87
- const normalizeModelKey = (modelName) => modelName.replace('Model', '').toLowerCase();
88
- const stripModelSuffix = (modelName) => modelName.replace('Model', '');
89
- /**
90
- * FK-ordered create priority.
91
- *
92
- * Reads `config.modelCreatePriority` out of the runtime SyncEngineContext —
93
- * this map is populated once at `createSyncEngine(...)` time by walking the
94
- * schema's `belongsTo` graph (see `computeFKDepthPriority` in
95
- * `client/createSyncEngine.ts`). The queue stays schema-agnostic: no model
96
- * names appear here, and consumer applications can override specific
97
- * priorities via `configOverrides.modelCreatePriority` without touching the
98
- * SDK.
99
- *
100
- * Non-create ops (update/delete/archive/unarchive) don't need FK ordering
101
- * because the row already exists, so they all share
102
- * `config.defaultNonCreatePriority`.
103
- */
104
- const computePriorityScore = (type, modelName) => {
105
- const { modelCreatePriority, defaultCreatePriority, defaultNonCreatePriority } = getContext().config;
106
- if (type !== 'create')
107
- return defaultNonCreatePriority;
108
- return modelCreatePriority.get(modelName) ?? defaultCreatePriority;
109
- };
110
- const TX_TYPE_TO_MUTATION_OP = {
111
- create: MutationOperationType.CREATE,
112
- update: MutationOperationType.UPDATE,
113
- delete: MutationOperationType.DELETE,
114
- archive: MutationOperationType.ARCHIVE,
115
- unarchive: MutationOperationType.UNARCHIVE,
116
- };
117
- function hasStaleWriteOptions(options) {
118
- return (options?.readAt !== undefined ||
119
- options?.onStale !== undefined);
120
- }
121
- /**
122
- * Project a transaction's `writeOptions` onto the wire operation. Stale
123
- * guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
124
- * ride in the op's `options` slot (`MutationOperation.options` — the
125
- * mutation_log cache key + audit tag). This is the single place the
126
- * caller-supplied write vocabulary crosses onto the wire.
127
- */
128
- function applyWriteOptions(op, transaction) {
129
- const operation = op;
130
- const writeOptions = transaction.writeOptions;
131
- if (!writeOptions)
132
- return operation;
133
- if (writeOptions.readAt !== undefined) {
134
- operation.readAt = writeOptions.readAt;
135
- }
136
- if (writeOptions.onStale !== undefined) {
137
- operation.onStale = writeOptions.onStale;
138
- }
139
- if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
140
- operation.options = {
141
- ...(writeOptions.idempotencyKey != null
142
- ? { idempotencyKey: writeOptions.idempotencyKey }
143
- : {}),
144
- ...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
145
- };
146
- }
147
- return operation;
148
- }
149
- function asTransportError(value) {
150
- return (value && typeof value === 'object' ? value : {});
151
- }
152
- function extractStatusCode(error) {
153
- return asTransportError(error).response?.status;
154
- }
155
- class TransactionStore {
156
- transactions = new Map();
157
- byStatus = new Map();
158
- add(transaction) {
159
- this.transactions.set(transaction.id, transaction);
160
- if (!this.byStatus.has(transaction.status)) {
161
- this.byStatus.set(transaction.status, new Set());
162
- }
163
- this.byStatus.get(transaction.status).add(transaction.id);
164
- }
165
- get(id) {
166
- return this.transactions.get(id);
167
- }
168
- updateStatus(id, newStatus) {
169
- const tx = this.transactions.get(id);
170
- if (!tx)
171
- return;
172
- this.byStatus.get(tx.status)?.delete(id);
173
- tx.status = newStatus;
174
- if (!this.byStatus.has(newStatus)) {
175
- this.byStatus.set(newStatus, new Set());
176
- }
177
- this.byStatus.get(newStatus).add(id);
178
- }
179
- getByStatus(status) {
180
- const ids = this.byStatus.get(status) || new Set();
181
- return Array.from(ids)
182
- .map((id) => this.transactions.get(id))
183
- .filter(Boolean);
184
- }
185
- remove(id) {
186
- const tx = this.transactions.get(id);
187
- if (!tx)
188
- return;
189
- this.transactions.delete(id);
190
- this.byStatus.get(tx.status)?.delete(id);
191
- }
192
- clear() {
193
- this.transactions.clear();
194
- this.byStatus.clear();
195
- }
196
- getAll() {
197
- return Array.from(this.transactions.values());
198
- }
199
- }
16
+ import { projectCommitPayload, computePriorityScore, normalizeModelKey, stripModelSuffix, hasStaleWriteOptions, applyWriteOptions, asTransportError, extractStatusCode, TX_TYPE_TO_MUTATION_OP, } from './commitPayload.js';
17
+ import { TransactionStore } from './TransactionStore.js';
18
+ import { entityKey, mergeUpdateData, takeUnsentCreateForModel, findCreateBarrierForDelete, deferDeleteUntilCreateSettles, releaseDeferredDeletesForCreate, } from './coalesceRules.js';
19
+ import { DeltaConfirmationTracker } from './deltaConfirmation.js';
20
+ import { deserializePersistedTransaction, isNonReplayablePersistedRow, } from './persistedReplay.js';
21
+ import { applyOptimisticCreate, applyOptimisticUpdate, applyOptimisticDelete, rollbackOptimistic, } from './optimistic.js';
200
22
  export class TransactionQueue extends EventEmitter {
201
23
  store = new TransactionStore();
202
24
  // Signature of the last permanent-error we logged at `warn`. A `create`
@@ -250,10 +72,7 @@ export class TransactionQueue extends EventEmitter {
250
72
  }
251
73
  }
252
74
  entityKey(modelName, modelId) {
253
- return `${modelName}:${modelId}`;
254
- }
255
- isTransactionForModel(transaction, modelName, modelId) {
256
- return transaction.modelName === modelName && transaction.modelId === modelId;
75
+ return entityKey(modelName, modelId);
257
76
  }
258
77
  resolveConfirmation(transaction) {
259
78
  const resolver = this.confirmationResolvers.get(transaction.id);
@@ -263,19 +82,7 @@ export class TransactionQueue extends EventEmitter {
263
82
  resolver.resolve();
264
83
  }
265
84
  takeUnsentCreateForModel(modelName, modelId) {
266
- const isUnsentCreate = (tx) => tx.type === 'create' &&
267
- tx.status === 'pending' &&
268
- tx.attempts === 0 &&
269
- this.isTransactionForModel(tx, modelName, modelId);
270
- const stagedIndex = this.createdTransactions.findIndex(isUnsentCreate);
271
- if (stagedIndex >= 0) {
272
- return this.createdTransactions.splice(stagedIndex, 1)[0];
273
- }
274
- const queuedIndex = this.executionQueue.findIndex(isUnsentCreate);
275
- if (queuedIndex >= 0) {
276
- return this.executionQueue.splice(queuedIndex, 1)[0];
277
- }
278
- return this.store.getByStatus('pending').find(isUnsentCreate);
85
+ return takeUnsentCreateForModel(this.createdTransactions, this.executionQueue, this.store, modelName, modelId);
279
86
  }
280
87
  async cancelUnsentCreateForDelete(transaction) {
281
88
  this.store.updateStatus(transaction.id, 'rolled_back');
@@ -285,17 +92,7 @@ export class TransactionQueue extends EventEmitter {
285
92
  this.resolveConfirmation(transaction);
286
93
  }
287
94
  findCreateBarrierForDelete(modelName, modelId) {
288
- const liveCreates = [
289
- ...this.store.getByStatus('pending'),
290
- ...this.store.getByStatus('executing'),
291
- ...this.store.getByStatus('awaiting_delta'),
292
- ].filter((tx) => tx.type === 'create' &&
293
- this.isTransactionForModel(tx, modelName, modelId) &&
294
- // A never-attempted pending create can be cancelled instead. Once the
295
- // create has been sent, even a retry-pending state is a causal barrier:
296
- // the server may already have applied it and only the response was lost.
297
- (tx.status !== 'pending' || tx.attempts > 0));
298
- return liveCreates.sort((a, b) => b.createdAt - a.createdAt)[0];
95
+ return findCreateBarrierForDelete(this.store, modelName, modelId);
299
96
  }
300
97
  completeLocalDelete(model, context, writeOptions) {
301
98
  const actualModelName = model.getModelName();
@@ -328,69 +125,10 @@ export class TransactionQueue extends EventEmitter {
328
125
  return transaction;
329
126
  }
330
127
  deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
331
- const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
332
- const deferred = this.deferredDeletesByCreate.get(key) ?? [];
333
- deferred.push(deleteTransaction);
334
- this.deferredDeletesByCreate.set(key, deferred);
128
+ deferDeleteUntilCreateSettles(this.deferredDeletesByCreate, createTransaction, deleteTransaction);
335
129
  }
336
130
  releaseDeferredDeletesForCreate(createTransaction) {
337
- const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
338
- const deferred = this.deferredDeletesByCreate.get(key);
339
- if (!deferred || deferred.length === 0)
340
- return;
341
- this.deferredDeletesByCreate.delete(key);
342
- for (const deleteTransaction of deferred) {
343
- if (this.store.get(deleteTransaction.id)?.status !== 'pending')
344
- continue;
345
- this.enqueue(deleteTransaction);
346
- }
347
- }
348
- // Merge two GraphQL update payloads with special handling for metadata fields
349
- mergeUpdateData(left, right, _modelName) {
350
- const out = { ...(left || {}) };
351
- const src = right || {};
352
- for (const key of Object.keys(src)) {
353
- // Special case: metadata payloads may be JSON strings; merge objects instead of clobbering
354
- if (key === 'metadata') {
355
- const l = out.metadata;
356
- const r = src.metadata;
357
- // If both sides undefined/null, continue
358
- if (l == null && r == null) {
359
- continue;
360
- }
361
- // Normalize to objects
362
- const toObj = (v) => {
363
- if (v == null)
364
- return {};
365
- if (typeof v === 'string') {
366
- try {
367
- return JSON.parse(v);
368
- }
369
- catch {
370
- return {};
371
- }
372
- }
373
- if (typeof v === 'object')
374
- return v;
375
- return {};
376
- };
377
- const lobj = toObj(l);
378
- const robj = toObj(r);
379
- const merged = { ...lobj, ...robj };
380
- // Re-stringify to match schema input type
381
- try {
382
- out.metadata = JSON.stringify(merged);
383
- }
384
- catch {
385
- // Fallback to right-hand side if stringify fails
386
- out.metadata = typeof r === 'string' ? r : JSON.stringify(robj || {});
387
- }
388
- continue;
389
- }
390
- // Default: shallow overwrite with right-hand value
391
- out[key] = src[key];
392
- }
393
- return out;
131
+ releaseDeferredDeletesForCreate(this.deferredDeletesByCreate, this.store, (tx) => { this.enqueue(tx); }, createTransaction);
394
132
  }
395
133
  // Configuration - tuned for LINEAR-style batching
396
134
  // Higher batch size and delay allows more operations to coalesce into single HTTP call
@@ -413,17 +151,18 @@ export class TransactionQueue extends EventEmitter {
413
151
  };
414
152
  // Track executing transactions for backpressure
415
153
  executingCount = 0;
416
- // Optimistic update tracking
154
+ // Optimistic update tracking. The entry shape + apply/rollback rules live
155
+ // in `./optimistic.js`; the ledger itself stays host state because the
156
+ // completion paths, `getStats`, and `dispose` all read it.
417
157
  optimisticUpdates = new Map();
418
158
  // Stale-context notifications (CoAgent/MTPO notify-instead-of-abort) keyed by
419
159
  // transaction id, populated from the commit ack and drained by
420
160
  // `waitForCommitReceipt` so the receipt carries the self-heal signal.
421
161
  commitNotifications = new Map();
422
- // LINEAR PATTERN: Track delta confirmation timeouts for awaiting_delta transactions
423
- // Following Replicache/PowerSync pattern: retry with backoff instead of rolling back
424
- deltaConfirmationTimeouts = new Map();
425
- // Track retry attempts per transaction for exponential backoff
426
- deltaConfirmationRetries = new Map();
162
+ // Delta-confirmation tracking (ack watermark advance + the awaiting_delta
163
+ // timeout/retry maps) lives in `./deltaConfirmation.js`. Constructed in the
164
+ // constructor once `position` is bound.
165
+ deltaConfirmation;
427
166
  // Connection state check - set by SyncClient to prevent rollbacks during disconnection
428
167
  isConnectedFn = () => true;
429
168
  // Grace timer that, when fired, fails any commit-lane transaction
@@ -444,15 +183,8 @@ export class TransactionQueue extends EventEmitter {
444
183
  return this.position.applied;
445
184
  }
446
185
  noteAck(lastSyncId) {
447
- this.position.noteAck(lastSyncId);
448
- }
449
- // Delta confirmation retry config (Replicache-style exponential backoff)
450
- // Max retries before requesting full reconciliation
451
- static DELTA_MAX_RETRIES = 5;
452
- // Initial timeout (first attempt)
453
- static DELTA_INITIAL_TIMEOUT_MS = 30_000;
454
- // Max timeout cap (like Replicache's maxDelayMs of 60s)
455
- static DELTA_MAX_TIMEOUT_MS = 120_000;
186
+ this.deltaConfirmation.noteAck(lastSyncId);
187
+ }
456
188
  // Batch management
457
189
  batchIndex = 0;
458
190
  /**
@@ -466,6 +198,18 @@ export class TransactionQueue extends EventEmitter {
466
198
  constructor(config) {
467
199
  super();
468
200
  this.position = config?.position ?? new SyncPosition();
201
+ // Bind the confirmation tracker to this queue's store/ledger/events.
202
+ // `isConnected` closes over `isConnectedFn` so `setConnectionChecker`
203
+ // swaps stay visible to in-flight timeouts.
204
+ this.deltaConfirmation = new DeltaConfirmationTracker({
205
+ store: this.store,
206
+ optimisticUpdates: this.optimisticUpdates,
207
+ emit: (event, payload) => {
208
+ this.emit(event, payload);
209
+ },
210
+ isConnected: () => this.isConnectedFn(),
211
+ position: this.position,
212
+ });
469
213
  if (config) {
470
214
  this.config = { ...this.config, ...config };
471
215
  }
@@ -526,6 +270,8 @@ export class TransactionQueue extends EventEmitter {
526
270
  if (candidates.length === 0)
527
271
  return Promise.resolve();
528
272
  const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
273
+ if (!latest)
274
+ return Promise.resolve();
529
275
  return latest.confirmation ?? Promise.resolve();
530
276
  }
531
277
  /**
@@ -1007,7 +753,7 @@ export class TransactionQueue extends EventEmitter {
1007
753
  // If there is an in-flight update for this model, merge into post-flight buffer
1008
754
  if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
1009
755
  const prev = this.pendingMergeByModel.get(modelKey) || {};
1010
- const merged = this.mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
756
+ const merged = mergeUpdateData(prev, transaction.data || {}, transaction.modelName);
1011
757
  this.pendingMergeByModel.set(modelKey, merged);
1012
758
  this.store.remove(transaction.id);
1013
759
  return;
@@ -1019,7 +765,7 @@ export class TransactionQueue extends EventEmitter {
1019
765
  t.modelName === transaction.modelName &&
1020
766
  !hasStaleWriteOptions(t.writeOptions));
1021
767
  if (!preserveWatermark && pendingInQueue) {
1022
- pendingInQueue.data = this.mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
768
+ pendingInQueue.data = mergeUpdateData(pendingInQueue.data || {}, transaction.data || {}, transaction.modelName);
1023
769
  this.store.remove(transaction.id);
1024
770
  return;
1025
771
  }
@@ -1258,7 +1004,7 @@ export class TransactionQueue extends EventEmitter {
1258
1004
  // wrapping in Ablo.commit() and any future wrappers.
1259
1005
  for (let i = 0; i < 3 && cur && typeof cur === 'object'; i++) {
1260
1006
  if ('diagnostics' in cur && cur.diagnostics) {
1261
- return cur.diagnostics;
1007
+ return (cur).diagnostics;
1262
1008
  }
1263
1009
  cur = cur.cause;
1264
1010
  }
@@ -1362,173 +1108,16 @@ export class TransactionQueue extends EventEmitter {
1362
1108
  /**
1363
1109
  * LINEAR PATTERN: Confirm all awaiting transactions when delta with syncId >= threshold arrives.
1364
1110
  * This replaces clientMutationId echoing - transactions are confirmed by sync ID threshold.
1111
+ * Policy + timeout maps live in the `./deltaConfirmation.js` leaf.
1365
1112
  * @param syncId - The sync ID of the received delta
1366
1113
  */
1367
1114
  onDeltaReceived(syncId) {
1368
- // Cursor advancing happens where the delta is APPLIED (the store calls
1369
- // position.advanceApplied / advancePersisted); this hook only resolves
1370
- // confirmation thresholds against the incoming id.
1371
- const awaitingTxs = this.store.getByStatus('awaiting_delta');
1372
- const executingTxs = this.store.getByStatus('executing');
1373
- // Debug: Show state when delta arrives
1374
- if (awaitingTxs.length > 0 || executingTxs.length > 0) {
1375
- getContext().logger.debug('tx:delta_received', {
1376
- syncId,
1377
- lastSeenSyncId: this.lastSeenSyncId,
1378
- awaitingCount: awaitingTxs.length,
1379
- executingCount: executingTxs.length,
1380
- awaitingThresholds: awaitingTxs.map((tx) => ({
1381
- txId: tx.id.slice(0, 8),
1382
- model: tx.modelName,
1383
- needed: tx.syncIdNeededForCompletion,
1384
- willConfirm: tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion,
1385
- })),
1386
- });
1387
- }
1388
- // Fast path: no awaiting transactions
1389
- if (awaitingTxs.length === 0)
1390
- return;
1391
- let confirmedCount = 0;
1392
- for (const tx of awaitingTxs) {
1393
- // Confirm if this delta's ID meets or exceeds the threshold
1394
- if (tx.syncIdNeededForCompletion !== undefined && syncId >= tx.syncIdNeededForCompletion) {
1395
- this.cancelDeltaConfirmationTimeout(tx.id);
1396
- this.store.updateStatus(tx.id, 'completed');
1397
- this.emit('transaction:completed', tx);
1398
- this.emit(`transaction:completed:${tx.id}`, tx);
1399
- this.optimisticUpdates.delete(tx.id);
1400
- confirmedCount++;
1401
- getContext().logger.debug('tx:confirm_via_delta', {
1402
- txId: tx.id.slice(0, 8),
1403
- model: tx.modelName,
1404
- neededSyncId: tx.syncIdNeededForCompletion,
1405
- receivedSyncId: syncId,
1406
- });
1407
- }
1408
- }
1409
- // Log batch summary only if we confirmed something
1410
- if (confirmedCount > 0) {
1411
- // Use warn for staging visibility when transactions confirm
1412
- getContext().observability.breadcrumb('Transactions confirmed via delta', 'sync.transaction', 'info', {
1413
- count: confirmedCount,
1414
- syncId,
1415
- remainingAwaiting: awaitingTxs.length - confirmedCount,
1416
- });
1417
- }
1115
+ this.deltaConfirmation.onDeltaReceived(syncId);
1418
1116
  }
1419
- // REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry + reconciliation
1420
- // Instead of rolling back on timeout (which destroys confirmed server state),
1421
- // retry with exponential backoff and request reconciliation to catch up on missed deltas.
1422
- // Only rollback on explicit server rejection, never on timeout.
1117
+ // REPLICACHE/POWERSYNC PATTERN: Schedule delta confirmation with retry +
1118
+ // reconciliation see DeltaConfirmationTracker in `./deltaConfirmation.js`.
1423
1119
  scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1424
- // Cancel any existing timeout for this transaction
1425
- this.cancelDeltaConfirmationTimeout(tx.id);
1426
- const timeoutHandle = setTimeout(async () => {
1427
- const currentTx = this.store.get(tx.id);
1428
- if (!currentTx || currentTx.status !== 'awaiting_delta') {
1429
- this.deltaConfirmationRetries.delete(tx.id);
1430
- return; // Already confirmed or failed
1431
- }
1432
- // If disconnected, re-schedule with same timeout (no backoff while offline)
1433
- if (!this.isConnectedFn()) {
1434
- // Self-healing: re-schedule the confirmation wait while offline, no
1435
- // consumer action needed → debug.
1436
- getContext().logger.debug('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
1437
- txId: tx.id.slice(0, 8),
1438
- model: tx.modelName,
1439
- });
1440
- this.deltaConfirmationTimeouts.delete(tx.id);
1441
- this.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1442
- return;
1443
- }
1444
- const retryCount = this.deltaConfirmationRetries.get(tx.id) ?? 0;
1445
- const diagnosis = this.lastSeenSyncId === 0
1446
- ? 'No deltas received - delta pipeline may be broken'
1447
- : currentTx.syncIdNeededForCompletion &&
1448
- this.lastSeenSyncId < currentTx.syncIdNeededForCompletion
1449
- ? 'Delta not yet received - may be lost or delayed'
1450
- : 'Delta should have confirmed - possible race condition';
1451
- getContext().observability.captureReconciliation({
1452
- reason: 'delta_timeout',
1453
- model: tx.modelName,
1454
- modelId: tx.modelId,
1455
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1456
- lastSeenSyncId: this.lastSeenSyncId,
1457
- retryCount,
1458
- connectionState: this.isConnectedFn() ? 'connected' : 'disconnected',
1459
- });
1460
- if (retryCount < TransactionQueue.DELTA_MAX_RETRIES) {
1461
- // RETRY: Request reconciliation and re-schedule with exponential backoff
1462
- // The server already committed this mutation — we just need the delta to arrive
1463
- this.deltaConfirmationRetries.set(tx.id, retryCount + 1);
1464
- this.deltaConfirmationTimeouts.delete(tx.id);
1465
- // Exponential backoff: 30s → 60s → 120s → 120s → 120s (capped)
1466
- const nextTimeout = Math.min(timeoutMs * 2, TransactionQueue.DELTA_MAX_TIMEOUT_MS);
1467
- // Emit reconciliation request so SyncedStore can cycle the WebSocket
1468
- // to trigger delta catch-up from the server
1469
- this.emit('reconciliation:needed', {
1470
- reason: 'delta_confirmation_timeout',
1471
- txId: tx.id,
1472
- model: tx.modelName,
1473
- modelId: tx.modelId,
1474
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1475
- lastSeenSyncId: this.lastSeenSyncId,
1476
- retryCount: retryCount + 1,
1477
- });
1478
- // Self-healing retry with backoff — the server already committed; we're
1479
- // just waiting on the delta. No consumer action → debug.
1480
- getContext().logger.debug('[TransactionQueue] Re-scheduling with backoff', {
1481
- txId: tx.id.slice(0, 8),
1482
- model: tx.modelName,
1483
- nextTimeoutMs: nextTimeout,
1484
- retry: retryCount + 1,
1485
- });
1486
- this.scheduleDeltaConfirmationTimeout(tx, nextTimeout);
1487
- }
1488
- else {
1489
- // LINEAR PATTERN: Retries exhausted — persist to IndexedDB instead of rolling back.
1490
- // The transaction succeeded on the server (HTTP 200), so the data exists server-side.
1491
- // Persist the awaiting state so it survives tab close. On next session, the WebSocket
1492
- // reconnect + delta catch-up will naturally confirm it (like Linear's IndexedDB caching).
1493
- this.deltaConfirmationRetries.delete(tx.id);
1494
- this.deltaConfirmationTimeouts.delete(tx.id);
1495
- getContext().observability.captureDeltaRetryExhausted({
1496
- txId: tx.id,
1497
- model: tx.modelName,
1498
- modelId: tx.modelId,
1499
- retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1500
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1501
- });
1502
- // Emit persist event — SyncClient handles the IDB write
1503
- this.emit('transaction:persist_awaiting', {
1504
- txId: tx.id,
1505
- model: tx.modelName,
1506
- modelId: tx.modelId,
1507
- operationType: tx.type,
1508
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1509
- });
1510
- // Also request one final reconciliation cycle
1511
- this.emit('reconciliation:needed', {
1512
- reason: 'delta_retries_exhausted',
1513
- txId: tx.id,
1514
- model: tx.modelName,
1515
- modelId: tx.modelId,
1516
- syncIdNeeded: currentTx.syncIdNeededForCompletion,
1517
- lastSeenSyncId: this.lastSeenSyncId,
1518
- retryCount: TransactionQueue.DELTA_MAX_RETRIES,
1519
- });
1520
- }
1521
- }, timeoutMs);
1522
- this.deltaConfirmationTimeouts.set(tx.id, timeoutHandle);
1523
- }
1524
- // Cancel a pending delta confirmation timeout and clean up retry tracking
1525
- cancelDeltaConfirmationTimeout(id) {
1526
- const timeoutHandle = this.deltaConfirmationTimeouts.get(id);
1527
- if (timeoutHandle) {
1528
- clearTimeout(timeoutHandle);
1529
- this.deltaConfirmationTimeouts.delete(id);
1530
- }
1531
- this.deltaConfirmationRetries.delete(id);
1120
+ this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1532
1121
  }
1533
1122
  /**
1534
1123
  * Wait for a transaction to be confirmed via delta echo (Linear pattern)
@@ -1607,6 +1196,8 @@ export class TransactionQueue extends EventEmitter {
1607
1196
  try {
1608
1197
  while (this.commitLane.length > 0) {
1609
1198
  const tx = this.commitLane[0];
1199
+ if (!tx)
1200
+ break;
1610
1201
  if (tx.status !== 'pending') {
1611
1202
  this.commitLane.shift();
1612
1203
  continue;
@@ -1932,44 +1523,20 @@ export class TransactionQueue extends EventEmitter {
1932
1523
  }
1933
1524
  }
1934
1525
  /**
1935
- * Optimistic updates
1526
+ * Optimistic updates — apply/rollback rules live in `./optimistic.js`;
1527
+ * these delegates bind the host-owned ledger + event emitter.
1936
1528
  */
1937
1529
  applyOptimisticCreate(model, transaction) {
1938
- this.optimisticUpdates.set(transaction.id, {
1939
- model,
1940
- previousState: null,
1941
- transaction,
1942
- });
1943
- this.emit('optimistic:create', { model, transaction });
1530
+ applyOptimisticCreate(this.optimisticUpdates, this, model, transaction);
1944
1531
  }
1945
1532
  applyOptimisticUpdate(model, transaction) {
1946
- this.optimisticUpdates.set(transaction.id, {
1947
- model,
1948
- previousState: transaction.previousData,
1949
- transaction,
1950
- });
1951
- this.emit('optimistic:update', { model, transaction });
1533
+ applyOptimisticUpdate(this.optimisticUpdates, this, model, transaction);
1952
1534
  }
1953
1535
  applyOptimisticDelete(model, transaction) {
1954
- this.optimisticUpdates.set(transaction.id, {
1955
- model,
1956
- previousState: transaction.previousData,
1957
- transaction,
1958
- });
1959
- this.emit('optimistic:delete', { model, transaction });
1536
+ applyOptimisticDelete(this.optimisticUpdates, this, model, transaction);
1960
1537
  }
1961
1538
  async rollbackOptimistic(transaction, reason, error) {
1962
- const optimistic = this.optimisticUpdates.get(transaction.id);
1963
- if (!optimistic)
1964
- return;
1965
- this.emit('optimistic:rollback', {
1966
- model: optimistic.model,
1967
- previousState: optimistic.previousState,
1968
- transaction,
1969
- reason: reason ?? 'unknown',
1970
- error,
1971
- });
1972
- this.optimisticUpdates.delete(transaction.id);
1539
+ await rollbackOptimistic(this.optimisticUpdates, this, transaction, reason, error);
1973
1540
  }
1974
1541
  /**
1975
1542
  * Execute individual transaction via the unified commit path
@@ -1999,6 +1566,8 @@ export class TransactionQueue extends EventEmitter {
1999
1566
  const persisted = await database.getPersistedTransactions();
2000
1567
  for (const data of persisted) {
2001
1568
  const transaction = this.deserializeTransaction(data);
1569
+ if (!transaction)
1570
+ continue;
2002
1571
  this.store.add(transaction);
2003
1572
  this.enqueue(transaction);
2004
1573
  }
@@ -2010,8 +1579,31 @@ export class TransactionQueue extends EventEmitter {
2010
1579
  });
2011
1580
  }
2012
1581
  }
1582
+ /**
1583
+ * Validate + rehydrate one persisted row (the T1.8 IDB replay boundary).
1584
+ * Rows written by other subsystems into the same store (`'queue'`,
1585
+ * `'awaiting_delta'`) are skipped silently; rows that fail the
1586
+ * persisted-transaction schema (older SDK versions, corruption) are
1587
+ * DROPPED with an observability capture instead of replayed as commits.
1588
+ */
2013
1589
  deserializeTransaction(data) {
2014
- return { ...data, status: 'pending' };
1590
+ if (isNonReplayablePersistedRow(data))
1591
+ return null;
1592
+ const transaction = deserializePersistedTransaction(data);
1593
+ if (!transaction) {
1594
+ const rowId = typeof data === 'object' && data !== null && typeof data.id === 'string'
1595
+ ? data.id
1596
+ : undefined;
1597
+ getContext().logger.debug('[TransactionQueue] Dropping malformed persisted transaction', {
1598
+ rowId,
1599
+ });
1600
+ getContext().observability.captureTransactionFailure({
1601
+ context: 'deserialize-persisted-transaction',
1602
+ error: `Persisted transaction failed schema validation${rowId ? ` (id: ${rowId})` : ''}`,
1603
+ });
1604
+ return null;
1605
+ }
1606
+ return transaction;
2015
1607
  }
2016
1608
  /**
2017
1609
  * Cancel transactions for a specific model
@@ -2027,7 +1619,15 @@ export class TransactionQueue extends EventEmitter {
2027
1619
  if (!transactionType || transaction.type === transactionType) {
2028
1620
  cancelledTransactions.push(transaction);
2029
1621
  this.store.updateStatus(transaction.id, 'rolled_back');
2030
- this.rollbackOptimistic(transaction, 'model_cancelled');
1622
+ // Sync caller: a rejected rollback (throwing optimistic:rollback
1623
+ // listener) must surface, not vanish — the status flip above is
1624
+ // already committed either way.
1625
+ void this.rollbackOptimistic(transaction, 'model_cancelled').catch((error) => {
1626
+ getContext().observability.captureTransactionFailure({
1627
+ context: 'rollback-model-cancelled',
1628
+ error: error instanceof Error ? error : String(error),
1629
+ });
1630
+ });
2031
1631
  }
2032
1632
  }
2033
1633
  }
@@ -2058,7 +1658,12 @@ export class TransactionQueue extends EventEmitter {
2058
1658
  const fkValue = transaction.data?.[foreignKey];
2059
1659
  if (fkValue === parentId) {
2060
1660
  this.store.updateStatus(transaction.id, 'rolled_back');
2061
- this.rollbackOptimistic(transaction, 'cascade_parent_deleted');
1661
+ void this.rollbackOptimistic(transaction, 'cascade_parent_deleted').catch((error) => {
1662
+ getContext().observability.captureTransactionFailure({
1663
+ context: 'rollback-cascade-parent-deleted',
1664
+ error: error instanceof Error ? error : String(error),
1665
+ });
1666
+ });
2062
1667
  cancelled++;
2063
1668
  getContext().logger.debug('[TransactionQueue] Cascade cancelled orphaned transaction', {
2064
1669
  txId: transaction.id.slice(0, 12),
@@ -2205,6 +1810,15 @@ export class TransactionQueue extends EventEmitter {
2205
1810
  if (this.processTimer) {
2206
1811
  clearTimeout(this.processTimer);
2207
1812
  }
1813
+ // Clear every armed delta-confirmation timer (one per in-flight tx,
1814
+ // 30–120s each) — a disposed queue must not keep the process alive or
1815
+ // fire confirmation callbacks against the cleared store below.
1816
+ this.deltaConfirmation.dispose();
1817
+ // Clear the offline-grace timer armed by setConnectionState('disconnected').
1818
+ if (this.commitOfflineGraceTimer !== null) {
1819
+ clearTimeout(this.commitOfflineGraceTimer);
1820
+ this.commitOfflineGraceTimer = null;
1821
+ }
2208
1822
  // Clear store
2209
1823
  this.store.clear();
2210
1824
  this.optimisticUpdates.clear();