@abloatai/ablo 0.27.0 → 0.28.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 (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +225 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +379 -76
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +12 -0
  12. package/dist/cli.cjs +6 -2
  13. package/dist/client/Ablo.d.ts +24 -1
  14. package/dist/client/Ablo.js +4 -3
  15. package/dist/client/ApiClient.js +309 -43
  16. package/dist/client/createInternalComponents.d.ts +2 -0
  17. package/dist/client/createInternalComponents.js +1 -1
  18. package/dist/client/httpClient.d.ts +2 -0
  19. package/dist/client/modelRegistration.js +11 -0
  20. package/dist/client/options.d.ts +23 -0
  21. package/dist/client/wsMutationExecutor.d.ts +3 -2
  22. package/dist/client/wsMutationExecutor.js +3 -2
  23. package/dist/commit/contract.d.ts +493 -0
  24. package/dist/commit/contract.js +187 -0
  25. package/dist/commit/index.d.ts +6 -0
  26. package/dist/commit/index.js +5 -0
  27. package/dist/core/StoreManager.d.ts +2 -0
  28. package/dist/core/StoreManager.js +12 -0
  29. package/dist/errorCodes.js +6 -2
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +2 -0
  32. package/dist/interfaces/index.d.ts +2 -2
  33. package/dist/mutators/UndoManager.d.ts +2 -0
  34. package/dist/mutators/UndoManager.js +32 -0
  35. package/dist/react/useAblo.d.ts +6 -4
  36. package/dist/react/useAblo.js +25 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/schema.d.ts +31 -1
  39. package/dist/stores/ObjectStore.d.ts +14 -1
  40. package/dist/stores/ObjectStore.js +27 -4
  41. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  42. package/dist/surface.d.ts +1 -1
  43. package/dist/surface.js +2 -0
  44. package/dist/sync/SyncWebSocket.d.ts +2 -1
  45. package/dist/sync/persistedPrefix.d.ts +12 -0
  46. package/dist/sync/persistedPrefix.js +22 -0
  47. package/dist/testing/index.d.ts +2 -0
  48. package/dist/testing/index.js +1 -0
  49. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  50. package/dist/testing/mocks/FakeDatabase.js +10 -0
  51. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  52. package/dist/transactions/TransactionQueue.d.ts +66 -8
  53. package/dist/transactions/TransactionQueue.js +607 -89
  54. package/dist/transactions/commitEnvelope.d.ts +132 -0
  55. package/dist/transactions/commitEnvelope.js +139 -0
  56. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  57. package/dist/transactions/commitOutboxStore.js +26 -0
  58. package/dist/transactions/commitPayload.d.ts +15 -0
  59. package/dist/transactions/commitPayload.js +6 -0
  60. package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
  61. package/dist/transactions/httpCommitEnvelope.js +179 -0
  62. package/dist/transactions/replayValidation.d.ts +83 -0
  63. package/dist/transactions/replayValidation.js +46 -1
  64. package/dist/wire/bootstrapReason.d.ts +9 -0
  65. package/dist/wire/bootstrapReason.js +8 -0
  66. package/dist/wire/frames.d.ts +236 -0
  67. package/dist/wire/frames.js +21 -0
  68. package/dist/wire/index.d.ts +4 -2
  69. package/dist/wire/index.js +2 -1
  70. package/docs/api.md +10 -10
  71. package/docs/coordination.md +2 -2
  72. package/docs/mcp.md +1 -1
  73. package/package.json +7 -2
@@ -8,6 +8,7 @@
8
8
  * over the network.
9
9
  */
10
10
  import { runInAction } from 'mobx';
11
+ import { v4 as uuid } from 'uuid';
11
12
  import { InstanceCache, ModelScope } from './InstanceCache.js';
12
13
  import { Model } from './Model.js';
13
14
  // ModelRegistry instance accessed via this.objectPool.registry
@@ -17,9 +18,10 @@ import { AbloAuthenticationError, AbloError, AbloValidationError } from './error
17
18
  import { EventEmitter } from 'events';
18
19
  import { NetworkMonitor } from './NetworkMonitor.js';
19
20
  import { TransactionQueue } from './transactions/TransactionQueue.js';
20
- import { persistedMutationSchema } from './transactions/replayValidation.js';
21
+ import { legacyPendingMutationRecordSchema, PENDING_MUTATION_REPLAY_WINDOW_MS, pendingMutationRecordId, pendingMutationRecordSchema, persistedMutationSchema, } from './transactions/replayValidation.js';
21
22
  import { UnconfirmedWrites, } from './transactions/UnconfirmedWrites.js';
22
23
  import { SyncPosition } from './sync/syncPosition.js';
24
+ import { DatabaseCommitOutboxStore, } from './transactions/commitOutboxStore.js';
23
25
  /**
24
26
  * Reports whether an incoming snapshot record is strictly newer than the
25
27
  * model already in the pool. The comparison uses the server-stamped
@@ -67,6 +69,10 @@ export class SyncClient extends EventEmitter {
67
69
  database;
68
70
  get mutationExecutor() { return getContext().mutationExecutor; }
69
71
  networkMonitor;
72
+ /**
73
+ * @internal — test seam, stripped from the published declarations by
74
+ * `stripInternal`. Unit suites deliver queue lifecycle events directly.
75
+ */
70
76
  transactionQueue;
71
77
  observers = new Set();
72
78
  // Authentication context
@@ -74,6 +80,10 @@ export class SyncClient extends EventEmitter {
74
80
  organizationId = null;
75
81
  // Pending mutations queue
76
82
  pendingMutations = [];
83
+ stagedMutationIds = new Set();
84
+ pendingJournalBatch = [];
85
+ journalFlushScheduled = false;
86
+ commitOutboxNamespace;
77
87
  /**
78
88
  * Tracks the ids of transactions the client has applied optimistically but
79
89
  * the server has not yet confirmed. When a delta arrives, the receive path
@@ -99,10 +109,11 @@ export class SyncClient extends EventEmitter {
99
109
  * responses, and snapshots and claims read `readFloor`.
100
110
  */
101
111
  position = new SyncPosition();
102
- constructor(objectPool, database) {
112
+ constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
103
113
  super();
104
114
  this.objectPool = objectPool;
105
115
  this.database = database;
116
+ this.commitOutboxNamespace = commitOutboxNamespace;
106
117
  this.networkMonitor = new NetworkMonitor();
107
118
  // Initialize TransactionQueue with proper configuration
108
119
  this.transactionQueue = new TransactionQueue({
@@ -117,6 +128,53 @@ export class SyncClient extends EventEmitter {
117
128
  strategy: 'last-write-wins',
118
129
  },
119
130
  });
131
+ this.transactionQueue.setCommitOutbox(commitOutbox);
132
+ this.transactionQueue.on('commit:envelope_persisted', (event) => {
133
+ if (event.sourceMutationIds.length === 0)
134
+ return;
135
+ const consumed = new Set(event.sourceMutationIds);
136
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !consumed.has(mutation.mutationId));
137
+ for (const mutationId of consumed)
138
+ this.stagedMutationIds.delete(mutationId);
139
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
140
+ this.scheduleSync();
141
+ }
142
+ });
143
+ this.transactionQueue.on('transaction:completed', (transaction) => {
144
+ const completed = new Set(transaction.sourceMutationIds ?? []);
145
+ if (completed.size > 0) {
146
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !completed.has(mutation.mutationId));
147
+ }
148
+ for (const mutationId of transaction.sourceMutationIds ?? []) {
149
+ this.stagedMutationIds.delete(mutationId);
150
+ void this.database
151
+ .removeTransaction(pendingMutationRecordId(mutationId))
152
+ .catch(() => undefined);
153
+ }
154
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
155
+ this.scheduleSync();
156
+ }
157
+ });
158
+ this.transactionQueue.on('transaction:failed', ({ transaction }) => {
159
+ const failed = transaction.sourceMutationIds ?? [];
160
+ if (failed.length === 0)
161
+ return;
162
+ const failedSet = new Set(failed);
163
+ this.pendingMutations = this.pendingMutations.filter((mutation) => !failedSet.has(mutation.mutationId));
164
+ for (const mutationId of failed) {
165
+ this.stagedMutationIds.delete(mutationId);
166
+ // The queue has already rolled the model back, so replaying the
167
+ // journal row on the next boot would resurrect a rejected write.
168
+ void this.database
169
+ .removeTransaction(pendingMutationRecordId(mutationId))
170
+ .catch(() => undefined);
171
+ }
172
+ // Without this drain, terminally failed ids stayed claimed forever and
173
+ // the size guard in processPendingMutations stalled every later write.
174
+ if (this.stagedMutationIds.size === 0 && this.pendingMutations.length > 0) {
175
+ this.scheduleSync();
176
+ }
177
+ });
120
178
  // Provide connection state to TransactionQueue - prevents rollbacks during disconnection
121
179
  this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
122
180
  // Restore object-pool state when a transaction is rolled back. If the
@@ -388,8 +446,23 @@ export class SyncClient extends EventEmitter {
388
446
  this.userId = userId;
389
447
  this.organizationId = organizationId;
390
448
  getContext().observability.setContext(userId, organizationId);
391
- // Restore queued mutations from previous session
392
- await this.restoreMutationQueue();
449
+ this.transactionQueue.setCommitOutboxScope({
450
+ organizationId,
451
+ participantId: userId,
452
+ namespace: this.commitOutboxNamespace,
453
+ });
454
+ // Calls made during startup are allowed to queue before identity arrives,
455
+ // but they cannot be serialized with a trustworthy scope until now. Flush
456
+ // those already-created journal promises before any write can be staged.
457
+ if (this.pendingJournalBatch.length > 0) {
458
+ this.scheduleJournalFlush();
459
+ await Promise.all(this.pendingMutations.map((mutation) => mutation.journaled));
460
+ }
461
+ // Restore exact, already-sealed requests first. The returned source ids
462
+ // suppress any legacy queue entry left behind by an older non-atomic
463
+ // handoff.
464
+ const sealedMutationIds = await this.transactionQueue.restoreDurableCommits();
465
+ await this.restoreMutationQueue(sealedMutationIds);
393
466
  // Read the initial network status from the injected OnlineStatusProvider.
394
467
  // In the browser this reflects the host's connectivity signal; in Node it
395
468
  // reports online by default. NetworkMonitor drives the ongoing
@@ -403,6 +476,8 @@ export class SyncClient extends EventEmitter {
403
476
  this.setConnectionState('disconnected');
404
477
  this.emit('sync:offline');
405
478
  }
479
+ if (this.pendingMutations.length > 0)
480
+ this.scheduleSync();
406
481
  }
407
482
  /**
408
483
  * The organization this client writes under (set by `initialize`).
@@ -846,6 +921,7 @@ export class SyncClient extends EventEmitter {
846
921
  */
847
922
  clearPendingMutationsForModel(modelId) {
848
923
  const beforeCount = this.pendingMutations.length;
924
+ const removed = this.pendingMutations.filter((mutation) => mutation.model.id === modelId);
849
925
  this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
850
926
  const afterCount = this.pendingMutations.length;
851
927
  if (beforeCount !== afterCount) {
@@ -854,8 +930,18 @@ export class SyncClient extends EventEmitter {
854
930
  clearedCount: beforeCount - afterCount,
855
931
  remainingCount: afterCount,
856
932
  });
857
- // Persist updated queue immediately
858
- void this.persistMutationQueue();
933
+ for (const mutation of removed) {
934
+ // Once staged, TransactionQueue owns cancellation and transfers this
935
+ // source id into the superseding delete envelope. Deleting the journal
936
+ // row here would make that valid atomic promotion look like a
937
+ // multi-tab loser. Truly unstaged work can be canceled locally.
938
+ if (this.stagedMutationIds.has(mutation.mutationId))
939
+ continue;
940
+ this.stagedMutationIds.delete(mutation.mutationId);
941
+ void mutation.journaled
942
+ .then(() => this.database.removeTransaction(pendingMutationRecordId(mutation.mutationId)))
943
+ .catch(() => undefined);
944
+ }
859
945
  }
860
946
  }
861
947
  /**
@@ -966,9 +1052,89 @@ export class SyncClient extends EventEmitter {
966
1052
  * avoid re-reading a model after pool operations that might clear them.
967
1053
  */
968
1054
  queueMutation(mutation) {
969
- this.pendingMutations.push(mutation);
1055
+ const mutationId = `mutation_${uuid()}`;
1056
+ const modelData = mutation.model.toJSON
1057
+ ? mutation.model.toJSON()
1058
+ : { ...mutation.model };
1059
+ let resolveJournal;
1060
+ let rejectJournal;
1061
+ const journaled = new Promise((resolve, reject) => {
1062
+ resolveJournal = resolve;
1063
+ rejectJournal = reject;
1064
+ });
1065
+ let resolveStaged;
1066
+ let rejectStaged;
1067
+ const staged = new Promise((resolve, reject) => {
1068
+ resolveStaged = resolve;
1069
+ rejectStaged = reject;
1070
+ });
1071
+ const pending = {
1072
+ ...mutation,
1073
+ mutationId,
1074
+ modelData,
1075
+ journaled,
1076
+ resolveJournal,
1077
+ rejectJournal,
1078
+ staged,
1079
+ resolveStaged,
1080
+ rejectStaged,
1081
+ };
1082
+ this.pendingJournalBatch.push(pending);
1083
+ this.scheduleJournalFlush();
1084
+ // Offline drains may not await this until much later. Observe rejection
1085
+ // immediately to avoid an unhandled-promise report while retaining the
1086
+ // original rejecting promise for fail-closed dispatch.
1087
+ void pending.journaled.catch(() => undefined);
1088
+ void pending.staged.catch(() => undefined);
1089
+ this.pendingMutations.push(pending);
970
1090
  this.scheduleSync();
971
1091
  }
1092
+ scheduleJournalFlush() {
1093
+ if (this.journalFlushScheduled)
1094
+ return;
1095
+ this.journalFlushScheduled = true;
1096
+ const schedule = typeof queueMicrotask === 'function'
1097
+ ? queueMicrotask
1098
+ : (callback) => { void Promise.resolve().then(callback); };
1099
+ schedule(() => {
1100
+ this.journalFlushScheduled = false;
1101
+ const batch = this.pendingJournalBatch;
1102
+ this.pendingJournalBatch = [];
1103
+ void this.flushPendingMutationJournal(batch);
1104
+ });
1105
+ }
1106
+ async flushPendingMutationJournal(batch) {
1107
+ if (batch.length === 0)
1108
+ return;
1109
+ if (!this.userId || !this.organizationId) {
1110
+ // Startup writes remain behind their unresolved journal promise. Identity
1111
+ // initialization re-kicks this batch once its durable scope is known.
1112
+ this.pendingJournalBatch = [...batch, ...this.pendingJournalBatch];
1113
+ return;
1114
+ }
1115
+ try {
1116
+ const records = batch.map((mutation) => this.pendingMutationRecord(mutation));
1117
+ const database = this.database;
1118
+ if (database.saveTransactions) {
1119
+ await database.saveTransactions(records);
1120
+ }
1121
+ else {
1122
+ await Promise.all(records.map((record) => database.saveTransaction(record)));
1123
+ }
1124
+ for (const mutation of batch)
1125
+ mutation.resolveJournal?.();
1126
+ }
1127
+ catch (error) {
1128
+ for (const mutation of batch)
1129
+ mutation.rejectJournal?.(error);
1130
+ }
1131
+ finally {
1132
+ for (const mutation of batch) {
1133
+ mutation.resolveJournal = undefined;
1134
+ mutation.rejectJournal = undefined;
1135
+ }
1136
+ }
1137
+ }
972
1138
  syncScheduled = false;
973
1139
  scheduleSync() {
974
1140
  if (this.syncScheduled)
@@ -979,7 +1145,6 @@ export class SyncClient extends EventEmitter {
979
1145
  : (cb) => Promise.resolve().then(cb);
980
1146
  schedule(() => {
981
1147
  this.syncScheduled = false;
982
- void this.persistMutationQueue();
983
1148
  if (getContext().onlineStatus.isOnline()) {
984
1149
  this.processPendingMutations().catch((err) => {
985
1150
  getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
@@ -987,34 +1152,37 @@ export class SyncClient extends EventEmitter {
987
1152
  }
988
1153
  });
989
1154
  }
990
- /**
991
- * Persist mutation queue to IndexedDB
992
- */
993
- async persistMutationQueue() {
994
- if (!this.database || !this.userId)
995
- return;
996
- try {
997
- const serializedMutations = this.pendingMutations.map((m) => ({
998
- type: m.type,
999
- modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
1000
- modelName: m.model.getModelName(),
1001
- timestamp: m.timestamp.toISOString(),
1002
- writeOptions: m.writeOptions,
1003
- }));
1004
- await this.database.saveTransaction({
1005
- id: 'mutation-queue',
1006
- type: 'queue',
1007
- mutations: serializedMutations,
1008
- timestamp: Date.now(),
1009
- });
1010
- }
1011
- catch (error) {
1012
- // Best-effort persistence — the in-memory queue still processes; only
1013
- // a tab close before reconnect loses these. Forensic → debug.
1014
- getContext().logger.debug('[SyncClient] Failed to persist offline mutation queue', {
1015
- error: error instanceof Error ? error.message : String(error),
1016
- });
1155
+ pendingMutationRecord(mutation) {
1156
+ if (!this.userId || !this.organizationId) {
1157
+ throw new AbloValidationError('Cannot persist a mutation before participant scope is initialized', { code: 'write_options_invalid' });
1017
1158
  }
1159
+ return pendingMutationRecordSchema.parse({
1160
+ id: pendingMutationRecordId(mutation.mutationId),
1161
+ type: 'pending_mutation',
1162
+ storageVersion: 2,
1163
+ mutation: {
1164
+ mutationId: mutation.mutationId,
1165
+ type: mutation.type,
1166
+ modelData: mutation.modelData,
1167
+ modelName: mutation.model.getModelName(),
1168
+ timestamp: mutation.timestamp.toISOString(),
1169
+ ...(mutation.capturedChanges !== undefined
1170
+ ? { capturedChanges: mutation.capturedChanges }
1171
+ : {}),
1172
+ ...(mutation.writeOptions !== undefined
1173
+ ? { writeOptions: mutation.writeOptions }
1174
+ : {}),
1175
+ },
1176
+ scope: {
1177
+ organizationId: this.organizationId,
1178
+ participantId: this.userId,
1179
+ namespace: this.commitOutboxNamespace,
1180
+ },
1181
+ timestamp: mutation.timestamp.getTime(),
1182
+ });
1183
+ }
1184
+ async persistPendingMutation(mutation) {
1185
+ await this.database.saveTransaction(this.pendingMutationRecord(mutation));
1018
1186
  }
1019
1187
  /**
1020
1188
  * Restore the mutation queue from IndexedDB.
@@ -1025,32 +1193,95 @@ export class SyncClient extends EventEmitter {
1025
1193
  * never swallowed silently, because the survival of offline writes must be
1026
1194
  * observable.
1027
1195
  */
1028
- async restoreMutationQueue() {
1196
+ async restoreMutationQueue(sealedMutationIds = new Set()) {
1029
1197
  if (!this.database || !this.userId)
1030
1198
  return;
1031
1199
  try {
1032
1200
  const stored = await this.database.getPersistedTransactions();
1033
- const queue = stored.find((t) => t.id === 'mutation-queue');
1034
- if (queue?.mutations) {
1035
- for (const mutation of queue.mutations) {
1036
- const parsed = persistedMutationSchema.safeParse(mutation);
1037
- if (!parsed.success) {
1038
- getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1039
- issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1040
- });
1041
- continue;
1201
+ const restoredMutationIds = new Set();
1202
+ let heldForReview = 0;
1203
+ const restore = async (mutation, migrateLegacy, legacyMutationId) => {
1204
+ const parsed = persistedMutationSchema.safeParse(mutation);
1205
+ if (!parsed.success) {
1206
+ getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1207
+ issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1208
+ });
1209
+ return;
1210
+ }
1211
+ // The window is anchored to when the write was made, because a
1212
+ // record re-sealed on restore would otherwise reset its own expiry
1213
+ // clock. An unparseable timestamp is held rather than replayed.
1214
+ const writtenAt = Date.parse(parsed.data.timestamp);
1215
+ const age = Date.now() - writtenAt;
1216
+ if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
1217
+ heldForReview += 1;
1218
+ getContext().logger.warn('A saved local write is older than the server idempotency window and was held for review.');
1219
+ return;
1220
+ }
1221
+ const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
1222
+ if (sealedMutationIds.has(mutationId) ||
1223
+ restoredMutationIds.has(mutationId))
1224
+ return;
1225
+ const model = this.objectPool.createFromData(parsed.data.modelData);
1226
+ if (model) {
1227
+ const pending = {
1228
+ mutationId,
1229
+ type: parsed.data.type,
1230
+ model,
1231
+ modelData: parsed.data.modelData,
1232
+ timestamp: new Date(parsed.data.timestamp),
1233
+ ...(parsed.data.capturedChanges !== undefined
1234
+ ? { capturedChanges: parsed.data.capturedChanges }
1235
+ : {}),
1236
+ ...(parsed.data.writeOptions !== undefined
1237
+ ? { writeOptions: parsed.data.writeOptions }
1238
+ : {}),
1239
+ journaled: Promise.resolve(),
1240
+ // Restored mutations have no live `wait: 'confirmed'` caller, so
1241
+ // their staging needs no waiter handshake.
1242
+ staged: Promise.resolve(),
1243
+ };
1244
+ if (migrateLegacy) {
1245
+ pending.journaled = this.persistPendingMutation(pending);
1246
+ await pending.journaled;
1042
1247
  }
1043
- const model = this.objectPool.createFromData(parsed.data.modelData);
1044
- if (model) {
1045
- this.pendingMutations.push({
1046
- type: parsed.data.type,
1047
- model,
1048
- timestamp: new Date(parsed.data.timestamp),
1049
- ...(parsed.data.writeOptions !== undefined
1050
- ? { writeOptions: parsed.data.writeOptions }
1051
- : {}),
1052
- });
1248
+ this.pendingMutations.push(pending);
1249
+ restoredMutationIds.add(mutationId);
1250
+ }
1251
+ };
1252
+ for (const row of stored) {
1253
+ if (row.type !== 'pending_mutation')
1254
+ continue;
1255
+ const parsed = pendingMutationRecordSchema.safeParse(row);
1256
+ if (!parsed.success) {
1257
+ const legacy = legacyPendingMutationRecordSchema.safeParse(row);
1258
+ if (legacy.success) {
1259
+ await restore(legacy.data.mutation, true);
1260
+ continue;
1053
1261
  }
1262
+ getContext().logger.debug('[SyncClient] Dropping malformed pending mutation record', {
1263
+ rowId: row.id,
1264
+ });
1265
+ continue;
1266
+ }
1267
+ if (parsed.data.scope.organizationId !== this.organizationId ||
1268
+ parsed.data.scope.participantId !== this.userId ||
1269
+ parsed.data.scope.namespace !== this.commitOutboxNamespace) {
1270
+ getContext().logger.warn('A saved local write belongs to a different account or server and was held for review.');
1271
+ continue;
1272
+ }
1273
+ await restore(parsed.data.mutation, false);
1274
+ }
1275
+ const legacyQueue = stored.find((row) => row.id === 'mutation-queue');
1276
+ if (legacyQueue?.mutations) {
1277
+ const heldBefore = heldForReview;
1278
+ for (const [index, mutation] of legacyQueue.mutations.entries()) {
1279
+ await restore(mutation, true, `legacy_mutation_${index}`);
1280
+ }
1281
+ // Deleting the legacy row would discard any entry held for review, so
1282
+ // it is only removed once every entry has migrated.
1283
+ if (heldForReview === heldBefore) {
1284
+ await this.database.removeTransaction('mutation-queue');
1054
1285
  }
1055
1286
  }
1056
1287
  }
@@ -1102,17 +1333,45 @@ export class SyncClient extends EventEmitter {
1102
1333
  return; // Skip if offline
1103
1334
  if (this.isDisposed)
1104
1335
  return; // Skip if disposed
1105
- const mutations = this.pendingMutations;
1106
- this.pendingMutations = [];
1107
- // Clear persisted queue before processing
1108
- await this.persistMutationQueue();
1109
- // Stage every mutation synchronously within the same event-loop tick;
1110
- // the transaction queue's microtask batches and sends them together.
1336
+ if (this.stagedMutationIds.size > 0)
1337
+ return;
1338
+ const mutations = this.pendingMutations.filter((mutation) => !this.stagedMutationIds.has(mutation.mutationId)).slice(0, 500);
1339
+ if (mutations.length === 0)
1340
+ return;
1341
+ // Claim the batch BEFORE awaiting the journal. This method runs
1342
+ // concurrently — the scheduleSync microtask and a direct syncNow() caller
1343
+ // land in the same tick — and both would otherwise capture this same
1344
+ // batch, suspend on the identical `journaled` promises, and stage every
1345
+ // mutation twice (two transactions on the wire for one write). Claiming
1346
+ // synchronously makes the second caller hit the guard above and return.
1111
1347
  for (const mutation of mutations) {
1112
- // Skip mutations for deleted models (prevents "not found" errors)
1113
- if (mutation.type !== 'delete' && !this.objectPool.get(mutation.model.id)) {
1114
- continue;
1348
+ this.stagedMutationIds.add(mutation.mutationId);
1349
+ }
1350
+ // A journal rejection is permanent for that mutation (fail-closed: it can
1351
+ // never dispatch without its durable record), so drop it rather than
1352
+ // leaving it queued to poison every later pass. Healthy batch members
1353
+ // still stage.
1354
+ const journalResults = await Promise.allSettled(mutations.map((mutation) => mutation.journaled));
1355
+ const journaledMutations = [];
1356
+ journalResults.forEach((result, index) => {
1357
+ const mutation = mutations[index];
1358
+ if (result.status === 'fulfilled') {
1359
+ journaledMutations.push(mutation);
1360
+ return;
1115
1361
  }
1362
+ this.stagedMutationIds.delete(mutation.mutationId);
1363
+ this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
1364
+ mutation.rejectStaged?.(result.reason);
1365
+ getContext().observability.captureTransactionFailure({
1366
+ context: 'persist-pending-mutation',
1367
+ error: result.reason instanceof Error
1368
+ ? result.reason
1369
+ : new Error(String(result.reason)),
1370
+ });
1371
+ });
1372
+ // Stage every mutation synchronously within the same event-loop tick;
1373
+ // the transaction queue's microtask batches and sends them together.
1374
+ for (const mutation of journaledMutations) {
1116
1375
  // Stage synchronously - TransactionQueue handles batching, retry, and errors
1117
1376
  this.stageMutation(mutation);
1118
1377
  }
@@ -1123,8 +1382,12 @@ export class SyncClient extends EventEmitter {
1123
1382
  * @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
1124
1383
  */
1125
1384
  stageMutation(mutation) {
1126
- if (!this.userId || !this.organizationId)
1385
+ if (!this.userId || !this.organizationId) {
1386
+ // Nothing will stage this call; settle the waiter with the legacy
1387
+ // "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
1388
+ mutation.resolveStaged?.();
1127
1389
  return;
1390
+ }
1128
1391
  const ctx = { userId: this.userId, organizationId: this.organizationId };
1129
1392
  // Settlement is delivered via transaction.confirmation, not this promise —
1130
1393
  // it only rejects when staging itself throws (change extraction, optimistic
@@ -1137,16 +1400,17 @@ export class SyncClient extends EventEmitter {
1137
1400
  modelId: mutation.model.id,
1138
1401
  error: error instanceof Error ? error : new Error(String(error)),
1139
1402
  });
1403
+ this.stagedMutationIds.delete(mutation.mutationId);
1140
1404
  };
1141
- if (mutation.type === 'update') {
1142
- this.transactionQueue
1143
- .update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions)
1144
- .catch(captureStagingFailure);
1145
- }
1146
- else {
1147
- const handler = this.transactionQueue[mutation.type].bind(this.transactionQueue);
1148
- handler(mutation.model, ctx, mutation.writeOptions).catch(captureStagingFailure);
1149
- }
1405
+ const staging = mutation.type === 'update'
1406
+ ? this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
1407
+ : this.transactionQueue[mutation.type].bind(this.transactionQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
1408
+ staging
1409
+ .then(() => mutation.resolveStaged?.())
1410
+ .catch((error) => {
1411
+ captureStagingFailure(error);
1412
+ mutation.rejectStaged?.(error);
1413
+ });
1150
1414
  }
1151
1415
  /**
1152
1416
  * Resolve a conflict between the local model and incoming server data,
@@ -1364,6 +1628,16 @@ export class SyncClient extends EventEmitter {
1364
1628
  */
1365
1629
  markConnected() {
1366
1630
  this.setConnectionState('connected');
1631
+ // Browser online state may have marked the client connected before the
1632
+ // WebSocket itself was ready. Always kick both durable lanes on the real
1633
+ // socket event, even when the high-level state did not change.
1634
+ void this.transactionQueue.flushOfflineQueue().catch((error) => {
1635
+ getContext().observability.captureTransactionFailure({
1636
+ context: 'restore-commit-outbox',
1637
+ error: error instanceof Error ? error : new Error(String(error)),
1638
+ });
1639
+ });
1640
+ void this.processPendingMutations();
1367
1641
  }
1368
1642
  /**
1369
1643
  * Dispose and cleanup
@@ -1416,7 +1690,14 @@ export class SyncClient extends EventEmitter {
1416
1690
  * Force sync now - process pending mutations
1417
1691
  */
1418
1692
  async syncNow() {
1693
+ // Snapshot before draining: a concurrent drain may already have claimed
1694
+ // this caller's write, in which case processPendingMutations returns
1695
+ // without staging anything. `wait: 'confirmed'` resolves on finding no
1696
+ // in-flight work, so it must not run until every write queued before this
1697
+ // call has a real transaction in the queue or was definitively dropped.
1698
+ const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
1419
1699
  await this.processPendingMutations();
1700
+ await Promise.allSettled(queuedBeforeCall);
1420
1701
  }
1421
1702
  /**
1422
1703
  * Get sync statistics. Return type is inferred from the literal so
@@ -1479,6 +1760,18 @@ export class SyncClient extends EventEmitter {
1479
1760
  */
1480
1761
  onLocalTransaction(listener) {
1481
1762
  this.transactionQueue.on('transaction:created', listener);
1763
+ const snapshotsByCommit = new Map();
1764
+ const onCommitStaging = (payload) => {
1765
+ snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
1766
+ if (operation.type === 'CREATE')
1767
+ return undefined;
1768
+ const resident = this.objectPool.get(operation.id);
1769
+ return resident?.toJSON();
1770
+ }));
1771
+ };
1772
+ const onCommitSealFailed = (payload) => {
1773
+ snapshotsByCommit.delete(payload.clientTxId);
1774
+ };
1482
1775
  // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1483
1776
  // their own `commit:created` event: they have no optimistic pool apply,
1484
1777
  // so they must not feed the echo tracker's `transaction:created` path.
@@ -1486,6 +1779,8 @@ export class SyncClient extends EventEmitter {
1486
1779
  // (the queue is pool-free) and hand the synthesized transaction to the
1487
1780
  // same listener, so undo observes every write door — one stream.
1488
1781
  const onCommitCreated = (payload) => {
1782
+ const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
1783
+ snapshotsByCommit.delete(payload.clientTxId);
1489
1784
  const TYPE_BY_WIRE = {
1490
1785
  CREATE: 'create',
1491
1786
  UPDATE: 'update',
@@ -1497,8 +1792,11 @@ export class SyncClient extends EventEmitter {
1497
1792
  const type = TYPE_BY_WIRE[op.type];
1498
1793
  if (!type || !op.id)
1499
1794
  return;
1500
- const resident = this.objectPool.get(op.id);
1501
- const snapshot = type === 'create' ? undefined : resident?.toJSON();
1795
+ const snapshot = type === 'create'
1796
+ ? undefined
1797
+ : stagedSnapshots
1798
+ ? stagedSnapshots[index]
1799
+ : this.objectPool.get(op.id)?.toJSON();
1502
1800
  // A DELETE of a row the local graph never saw is not invertible —
1503
1801
  // recording it would make undo "restore" an empty husk. Skip it.
1504
1802
  if (type === 'delete' && !snapshot)
@@ -1529,10 +1827,15 @@ export class SyncClient extends EventEmitter {
1529
1827
  });
1530
1828
  });
1531
1829
  };
1830
+ this.transactionQueue.on('commit:staging', onCommitStaging);
1831
+ this.transactionQueue.on('commit:seal_failed', onCommitSealFailed);
1532
1832
  this.transactionQueue.on('commit:created', onCommitCreated);
1533
1833
  return () => {
1534
1834
  this.transactionQueue.off('transaction:created', listener);
1835
+ this.transactionQueue.off('commit:staging', onCommitStaging);
1836
+ this.transactionQueue.off('commit:seal_failed', onCommitSealFailed);
1535
1837
  this.transactionQueue.off('commit:created', onCommitCreated);
1838
+ snapshotsByCommit.clear();
1536
1839
  };
1537
1840
  }
1538
1841
  /**
@@ -17,6 +17,7 @@ export declare class InMemoryObjectStore implements ObjectStoreContract {
17
17
  private data;
18
18
  private indexes;
19
19
  constructor(modelName: string, storeName: string, indexNames?: string[]);
20
+ add(record: Record<string, unknown>): Promise<void>;
20
21
  put(record: Record<string, unknown>): Promise<void>;
21
22
  get(id: string): Promise<Record<string, unknown> | undefined>;
22
23
  getAll(): Promise<Record<string, unknown>[]>;
@@ -22,6 +22,18 @@ export class InMemoryObjectStore {
22
22
  this.indexes.set(name, new Map());
23
23
  }
24
24
  }
25
+ async add(record) {
26
+ const id = record.id;
27
+ if (!id)
28
+ throw new TypeError('In-memory record must carry an id');
29
+ if (this.data.has(id)) {
30
+ const error = new Error(`Record already exists: ${id}`);
31
+ error.name = 'ConstraintError';
32
+ throw error;
33
+ }
34
+ this.data.set(id, { ...record });
35
+ this.addToIndexes(id, record);
36
+ }
25
37
  async put(record) {
26
38
  const id = record.id;
27
39
  if (!id)