@abloatai/ablo 0.27.0 → 0.29.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.
- package/CHANGELOG.md +59 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +228 -28
- package/dist/Model.d.ts +17 -0
- package/dist/Model.js +32 -1
- package/dist/SyncClient.d.ts +10 -6
- package/dist/SyncClient.js +390 -75
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +29 -13
- package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
- package/dist/auth/schemas.d.ts +6 -0
- package/dist/auth/schemas.js +7 -0
- package/dist/cli.cjs +821 -402
- package/dist/client/Ablo.d.ts +53 -24
- package/dist/client/Ablo.js +12 -199
- package/dist/client/claimHeartbeatLoop.d.ts +1 -1
- package/dist/client/claimHeartbeatLoop.js +1 -1
- package/dist/client/createInternalComponents.d.ts +4 -0
- package/dist/client/createInternalComponents.js +3 -1
- package/dist/client/durableWrites.d.ts +21 -0
- package/dist/client/durableWrites.js +46 -0
- package/dist/client/httpClient.d.ts +21 -45
- package/dist/client/httpClient.js +104 -26
- package/dist/client/httpTransport.d.ts +8 -0
- package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +54 -7
- package/dist/client/options.js +3 -2
- package/dist/client/resourceTypes.d.ts +9 -85
- package/dist/client/resourceTypes.js +1 -1
- package/dist/client/sessionMint.d.ts +5 -6
- package/dist/client/sessionMint.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/coordination/schema.d.ts +30 -0
- package/dist/coordination/schema.js +14 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/errorCodes.js +6 -2
- package/dist/errors.d.ts +4 -40
- package/dist/errors.js +5 -5
- package/dist/index.d.ts +20 -8
- package/dist/index.js +9 -5
- package/dist/interfaces/index.d.ts +5 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/mutators/defineMutators.d.ts +18 -0
- package/dist/mutators/defineMutators.js +4 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/schema/select.d.ts +15 -0
- package/dist/schema/select.js +27 -3
- package/dist/source/connector.d.ts +3 -3
- package/dist/source/factory.d.ts +4 -6
- package/dist/source/factory.js +4 -7
- package/dist/source/index.d.ts +4 -4
- package/dist/source/index.js +2 -2
- package/dist/source/signing.d.ts +0 -3
- package/dist/source/types.d.ts +0 -26
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +33 -10
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +3 -0
- package/dist/sync/BootstrapFetcher.d.ts +10 -0
- package/dist/sync/BootstrapFetcher.js +27 -4
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/createClaimStream.d.ts +11 -2
- package/dist/sync/createClaimStream.js +4 -3
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
- package/dist/transactions/TransactionQueue.d.ts +66 -8
- package/dist/transactions/TransactionQueue.js +607 -89
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +15 -0
- package/dist/transactions/commitPayload.js +6 -0
- package/dist/transactions/durableWriteStore.d.ts +123 -0
- package/dist/transactions/durableWriteStore.js +30 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
- package/dist/transactions/httpCommitEnvelope.js +181 -0
- package/dist/transactions/idempotencyKey.d.ts +10 -0
- package/dist/transactions/idempotencyKey.js +9 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/types/global.d.ts +10 -29
- package/dist/types/global.js +4 -7
- package/dist/types/streams.d.ts +2 -28
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +6 -21
- package/dist/wire/frames.js +4 -4
- package/dist/wire/index.d.ts +6 -3
- package/dist/wire/index.js +3 -2
- package/dist/wire/protocolVersion.d.ts +30 -17
- package/dist/wire/protocolVersion.js +34 -18
- package/docs/agents.md +8 -3
- package/docs/api.md +16 -14
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +4 -5
- package/docs/data-sources.md +5 -8
- package/docs/guarantees.md +21 -0
- package/docs/integration-guide.md +1 -0
- package/docs/mcp.md +1 -1
- package/docs/migration.md +21 -1
- package/docs/quickstart.md +11 -0
- package/docs/react.md +0 -46
- package/examples/README.md +6 -5
- package/llms.txt +15 -15
- package/package.json +3 -3
- package/dist/client/ApiClient.d.ts +0 -177
package/dist/SyncClient.js
CHANGED
|
@@ -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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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,57 @@ export class SyncClient extends EventEmitter {
|
|
|
1102
1333
|
return; // Skip if offline
|
|
1103
1334
|
if (this.isDisposed)
|
|
1104
1335
|
return; // Skip if disposed
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
// the
|
|
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
|
-
|
|
1113
|
-
|
|
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
|
+
// Settle each mutation into an object that keeps the mutation bound to its
|
|
1355
|
+
// own outcome. Correlating by array index would force a `mutations[index]!`
|
|
1356
|
+
// non-null assertion (the lookup is `T | undefined` under
|
|
1357
|
+
// noUncheckedIndexedAccess); carrying the reference makes it unnecessary.
|
|
1358
|
+
const journalOutcomes = await Promise.all(mutations.map(async (mutation) => {
|
|
1359
|
+
try {
|
|
1360
|
+
await mutation.journaled;
|
|
1361
|
+
return { mutation, ok: true };
|
|
1362
|
+
}
|
|
1363
|
+
catch (reason) {
|
|
1364
|
+
return { mutation, ok: false, reason };
|
|
1365
|
+
}
|
|
1366
|
+
}));
|
|
1367
|
+
const journaledMutations = [];
|
|
1368
|
+
for (const outcome of journalOutcomes) {
|
|
1369
|
+
const { mutation } = outcome;
|
|
1370
|
+
if (outcome.ok) {
|
|
1371
|
+
journaledMutations.push(mutation);
|
|
1114
1372
|
continue;
|
|
1115
1373
|
}
|
|
1374
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1375
|
+
this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
|
|
1376
|
+
mutation.rejectStaged?.(outcome.reason);
|
|
1377
|
+
getContext().observability.captureTransactionFailure({
|
|
1378
|
+
context: 'persist-pending-mutation',
|
|
1379
|
+
error: outcome.reason instanceof Error
|
|
1380
|
+
? outcome.reason
|
|
1381
|
+
: new Error(String(outcome.reason)),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
// Stage every mutation synchronously within the same event-loop tick;
|
|
1385
|
+
// the transaction queue's microtask batches and sends them together.
|
|
1386
|
+
for (const mutation of journaledMutations) {
|
|
1116
1387
|
// Stage synchronously - TransactionQueue handles batching, retry, and errors
|
|
1117
1388
|
this.stageMutation(mutation);
|
|
1118
1389
|
}
|
|
@@ -1123,8 +1394,12 @@ export class SyncClient extends EventEmitter {
|
|
|
1123
1394
|
* @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
|
|
1124
1395
|
*/
|
|
1125
1396
|
stageMutation(mutation) {
|
|
1126
|
-
if (!this.userId || !this.organizationId)
|
|
1397
|
+
if (!this.userId || !this.organizationId) {
|
|
1398
|
+
// Nothing will stage this call; settle the waiter with the legacy
|
|
1399
|
+
// "silently dropped" semantics rather than hanging a `wait: 'confirmed'`.
|
|
1400
|
+
mutation.resolveStaged?.();
|
|
1127
1401
|
return;
|
|
1402
|
+
}
|
|
1128
1403
|
const ctx = { userId: this.userId, organizationId: this.organizationId };
|
|
1129
1404
|
// Settlement is delivered via transaction.confirmation, not this promise —
|
|
1130
1405
|
// it only rejects when staging itself throws (change extraction, optimistic
|
|
@@ -1137,16 +1412,17 @@ export class SyncClient extends EventEmitter {
|
|
|
1137
1412
|
modelId: mutation.model.id,
|
|
1138
1413
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
1139
1414
|
});
|
|
1415
|
+
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1140
1416
|
};
|
|
1141
|
-
|
|
1142
|
-
this.transactionQueue
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}
|
|
1417
|
+
const staging = mutation.type === 'update'
|
|
1418
|
+
? this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions, mutation.mutationId)
|
|
1419
|
+
: this.transactionQueue[mutation.type].bind(this.transactionQueue)(mutation.model, ctx, mutation.writeOptions, mutation.mutationId);
|
|
1420
|
+
staging
|
|
1421
|
+
.then(() => mutation.resolveStaged?.())
|
|
1422
|
+
.catch((error) => {
|
|
1423
|
+
captureStagingFailure(error);
|
|
1424
|
+
mutation.rejectStaged?.(error);
|
|
1425
|
+
});
|
|
1150
1426
|
}
|
|
1151
1427
|
/**
|
|
1152
1428
|
* Resolve a conflict between the local model and incoming server data,
|
|
@@ -1364,6 +1640,16 @@ export class SyncClient extends EventEmitter {
|
|
|
1364
1640
|
*/
|
|
1365
1641
|
markConnected() {
|
|
1366
1642
|
this.setConnectionState('connected');
|
|
1643
|
+
// Browser online state may have marked the client connected before the
|
|
1644
|
+
// WebSocket itself was ready. Always kick both durable lanes on the real
|
|
1645
|
+
// socket event, even when the high-level state did not change.
|
|
1646
|
+
void this.transactionQueue.flushOfflineQueue().catch((error) => {
|
|
1647
|
+
getContext().observability.captureTransactionFailure({
|
|
1648
|
+
context: 'restore-commit-outbox',
|
|
1649
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1650
|
+
});
|
|
1651
|
+
});
|
|
1652
|
+
void this.processPendingMutations();
|
|
1367
1653
|
}
|
|
1368
1654
|
/**
|
|
1369
1655
|
* Dispose and cleanup
|
|
@@ -1416,7 +1702,14 @@ export class SyncClient extends EventEmitter {
|
|
|
1416
1702
|
* Force sync now - process pending mutations
|
|
1417
1703
|
*/
|
|
1418
1704
|
async syncNow() {
|
|
1705
|
+
// Snapshot before draining: a concurrent drain may already have claimed
|
|
1706
|
+
// this caller's write, in which case processPendingMutations returns
|
|
1707
|
+
// without staging anything. `wait: 'confirmed'` resolves on finding no
|
|
1708
|
+
// in-flight work, so it must not run until every write queued before this
|
|
1709
|
+
// call has a real transaction in the queue or was definitively dropped.
|
|
1710
|
+
const queuedBeforeCall = this.pendingMutations.map((mutation) => mutation.staged);
|
|
1419
1711
|
await this.processPendingMutations();
|
|
1712
|
+
await Promise.allSettled(queuedBeforeCall);
|
|
1420
1713
|
}
|
|
1421
1714
|
/**
|
|
1422
1715
|
* Get sync statistics. Return type is inferred from the literal so
|
|
@@ -1479,6 +1772,18 @@ export class SyncClient extends EventEmitter {
|
|
|
1479
1772
|
*/
|
|
1480
1773
|
onLocalTransaction(listener) {
|
|
1481
1774
|
this.transactionQueue.on('transaction:created', listener);
|
|
1775
|
+
const snapshotsByCommit = new Map();
|
|
1776
|
+
const onCommitStaging = (payload) => {
|
|
1777
|
+
snapshotsByCommit.set(payload.clientTxId, payload.operations.map((operation) => {
|
|
1778
|
+
if (operation.type === 'CREATE')
|
|
1779
|
+
return undefined;
|
|
1780
|
+
const resident = this.objectPool.get(operation.id);
|
|
1781
|
+
return resident?.toJSON();
|
|
1782
|
+
}));
|
|
1783
|
+
};
|
|
1784
|
+
const onCommitSealFailed = (payload) => {
|
|
1785
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1786
|
+
};
|
|
1482
1787
|
// Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
|
|
1483
1788
|
// their own `commit:created` event: they have no optimistic pool apply,
|
|
1484
1789
|
// so they must not feed the echo tracker's `transaction:created` path.
|
|
@@ -1486,6 +1791,8 @@ export class SyncClient extends EventEmitter {
|
|
|
1486
1791
|
// (the queue is pool-free) and hand the synthesized transaction to the
|
|
1487
1792
|
// same listener, so undo observes every write door — one stream.
|
|
1488
1793
|
const onCommitCreated = (payload) => {
|
|
1794
|
+
const stagedSnapshots = snapshotsByCommit.get(payload.clientTxId);
|
|
1795
|
+
snapshotsByCommit.delete(payload.clientTxId);
|
|
1489
1796
|
const TYPE_BY_WIRE = {
|
|
1490
1797
|
CREATE: 'create',
|
|
1491
1798
|
UPDATE: 'update',
|
|
@@ -1497,8 +1804,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1497
1804
|
const type = TYPE_BY_WIRE[op.type];
|
|
1498
1805
|
if (!type || !op.id)
|
|
1499
1806
|
return;
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1807
|
+
const snapshot = type === 'create'
|
|
1808
|
+
? undefined
|
|
1809
|
+
: stagedSnapshots
|
|
1810
|
+
? stagedSnapshots[index]
|
|
1811
|
+
: this.objectPool.get(op.id)?.toJSON();
|
|
1502
1812
|
// A DELETE of a row the local graph never saw is not invertible —
|
|
1503
1813
|
// recording it would make undo "restore" an empty husk. Skip it.
|
|
1504
1814
|
if (type === 'delete' && !snapshot)
|
|
@@ -1529,10 +1839,15 @@ export class SyncClient extends EventEmitter {
|
|
|
1529
1839
|
});
|
|
1530
1840
|
});
|
|
1531
1841
|
};
|
|
1842
|
+
this.transactionQueue.on('commit:staging', onCommitStaging);
|
|
1843
|
+
this.transactionQueue.on('commit:seal_failed', onCommitSealFailed);
|
|
1532
1844
|
this.transactionQueue.on('commit:created', onCommitCreated);
|
|
1533
1845
|
return () => {
|
|
1534
1846
|
this.transactionQueue.off('transaction:created', listener);
|
|
1847
|
+
this.transactionQueue.off('commit:staging', onCommitStaging);
|
|
1848
|
+
this.transactionQueue.off('commit:seal_failed', onCommitSealFailed);
|
|
1535
1849
|
this.transactionQueue.off('commit:created', onCommitCreated);
|
|
1850
|
+
snapshotsByCommit.clear();
|
|
1536
1851
|
};
|
|
1537
1852
|
}
|
|
1538
1853
|
/**
|
|
@@ -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>[]>;
|