@stratasync/client 0.2.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/README.md +76 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +759 -0
- package/dist/client.js.map +1 -0
- package/dist/history-manager.d.ts +45 -0
- package/dist/history-manager.d.ts.map +1 -0
- package/dist/history-manager.js +266 -0
- package/dist/history-manager.js.map +1 -0
- package/dist/identity-map.d.ts +127 -0
- package/dist/identity-map.d.ts.map +1 -0
- package/dist/identity-map.js +295 -0
- package/dist/identity-map.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox-manager.d.ts +122 -0
- package/dist/outbox-manager.d.ts.map +1 -0
- package/dist/outbox-manager.js +373 -0
- package/dist/outbox-manager.js.map +1 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +36 -0
- package/dist/query.js.map +1 -0
- package/dist/sync-orchestrator.d.ts +208 -0
- package/dist/sync-orchestrator.d.ts.map +1 -0
- package/dist/sync-orchestrator.js +1287 -0
- package/dist/sync-orchestrator.js.map +1 -0
- package/dist/types.d.ts +309 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +26 -0
- package/dist/utils.js.map +1 -0
- package/package.json +41 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { captureArchiveState, createArchivePayload, createUnarchivePatch, createUnarchivePayload, generateUUID, getOrCreateClientId, ModelRegistry, readArchivedAt, } from "@stratasync/core";
|
|
2
|
+
import { YjsDocumentManager, YjsPresenceManager } from "@stratasync/yjs";
|
|
3
|
+
import { HistoryManager } from "./history-manager.js";
|
|
4
|
+
import { IdentityMapRegistry } from "./identity-map.js";
|
|
5
|
+
import { OutboxManager } from "./outbox-manager.js";
|
|
6
|
+
import { executeQuery } from "./query.js";
|
|
7
|
+
import { SyncOrchestrator } from "./sync-orchestrator.js";
|
|
8
|
+
import { getModelData, getModelKey, pickOriginal } from "./utils.js";
|
|
9
|
+
/**
|
|
10
|
+
* Default model factory: creates model instances using the constructor
|
|
11
|
+
* registered in ModelRegistry, falling back to plain data objects.
|
|
12
|
+
*/
|
|
13
|
+
const defaultModelFactory = ({ store }) => (modelName, data) => {
|
|
14
|
+
const ctor = ModelRegistry.getModelConstructor(modelName);
|
|
15
|
+
if (!ctor) {
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
18
|
+
const instance = new ctor();
|
|
19
|
+
const candidate = instance;
|
|
20
|
+
if ("store" in candidate) {
|
|
21
|
+
candidate.store = store;
|
|
22
|
+
}
|
|
23
|
+
if (typeof candidate._applyUpdate === "function") {
|
|
24
|
+
candidate._applyUpdate(data);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
Object.assign(instance, data);
|
|
28
|
+
}
|
|
29
|
+
return instance;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Resolves a ModelFactory or ModelFactoryFactory into a ModelFactory.
|
|
33
|
+
* A ModelFactoryFactory is a function that takes a store context and returns
|
|
34
|
+
* a ModelFactory. We distinguish by arity: factories take 1 arg (context),
|
|
35
|
+
* plain ModelFactory takes 2 (modelName, data).
|
|
36
|
+
*/
|
|
37
|
+
const resolveModelFactory = (factory, modelStore) => {
|
|
38
|
+
if (!factory) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (typeof factory === "function" && factory.length <= 1) {
|
|
42
|
+
return factory({ store: modelStore });
|
|
43
|
+
}
|
|
44
|
+
return factory;
|
|
45
|
+
};
|
|
46
|
+
const buildEffectiveUpdate = (existingData, changes) => {
|
|
47
|
+
const effectiveChanges = {};
|
|
48
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
49
|
+
if (!Object.is(existingData[key], value)) {
|
|
50
|
+
effectiveChanges[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
effectiveChangeRecord: effectiveChanges,
|
|
55
|
+
effectiveChanges,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Creates a sync client instance.
|
|
60
|
+
*
|
|
61
|
+
* Uses a closure to encapsulate mutable state (identity maps, outbox, history).
|
|
62
|
+
* The `clientRef` / `getClientRef` pattern exists because the client object
|
|
63
|
+
* literal needs to reference itself for history replay operations, but it
|
|
64
|
+
* hasn't been assigned yet at definition time.
|
|
65
|
+
*/
|
|
66
|
+
export const createSyncClient = (options) => {
|
|
67
|
+
const resolvedOptions = { ...options };
|
|
68
|
+
const identityMaps = new IdentityMapRegistry(resolvedOptions.reactivity, undefined, resolvedOptions.identityMapMaxSize);
|
|
69
|
+
const eventListeners = new Set();
|
|
70
|
+
const missingModels = new Set();
|
|
71
|
+
const pendingLoads = new Map();
|
|
72
|
+
const pendingIndexLoads = new Map();
|
|
73
|
+
const history = new HistoryManager();
|
|
74
|
+
const emitEvent = (event) => {
|
|
75
|
+
if (event.type === "modelChange") {
|
|
76
|
+
const key = getModelKey(event.modelName, event.modelId);
|
|
77
|
+
if (event.action === "delete") {
|
|
78
|
+
missingModels.add(key);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
missingModels.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const listener of eventListeners) {
|
|
85
|
+
listener(event);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const emitModelChange = (modelName, modelId, action) => {
|
|
89
|
+
emitEvent({
|
|
90
|
+
action,
|
|
91
|
+
modelId,
|
|
92
|
+
modelName,
|
|
93
|
+
type: "modelChange",
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const recordHistoryEntry = (entry, queuedTx) => {
|
|
97
|
+
if (!entry) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
history.record(entry, queuedTx?.clientTxId);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Rolls back a transaction by inverting its effect on the identity map.
|
|
104
|
+
* This intentionally differs from `applyDeltas` (which writes to storage)
|
|
105
|
+
* and `applyPendingTransactionsToIdentityMaps` (which re-applies forward).
|
|
106
|
+
*/
|
|
107
|
+
const rollbackTransaction = (tx) => {
|
|
108
|
+
const map = identityMaps.getMap(tx.modelName);
|
|
109
|
+
const existing = map.get(tx.modelId);
|
|
110
|
+
const { original } = tx;
|
|
111
|
+
switch (tx.action) {
|
|
112
|
+
case "I": {
|
|
113
|
+
if (existing) {
|
|
114
|
+
map.delete(tx.modelId);
|
|
115
|
+
emitModelChange(tx.modelName, tx.modelId, "delete");
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "D": {
|
|
120
|
+
if (original) {
|
|
121
|
+
map.set(tx.modelId, original);
|
|
122
|
+
emitModelChange(tx.modelName, tx.modelId, "insert");
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "U": {
|
|
127
|
+
if (!original) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (existing) {
|
|
131
|
+
map.update(tx.modelId, original);
|
|
132
|
+
emitModelChange(tx.modelName, tx.modelId, "update");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
map.set(tx.modelId, original);
|
|
136
|
+
emitModelChange(tx.modelName, tx.modelId, "insert");
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "A":
|
|
141
|
+
case "V": {
|
|
142
|
+
if (existing) {
|
|
143
|
+
map.update(tx.modelId, captureArchiveState(original));
|
|
144
|
+
emitModelChange(tx.modelName, tx.modelId, "update");
|
|
145
|
+
}
|
|
146
|
+
else if (original) {
|
|
147
|
+
map.set(tx.modelId, original);
|
|
148
|
+
emitModelChange(tx.modelName, tx.modelId, "insert");
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
default: {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const applyHistoryOperation = async (operation) => {
|
|
158
|
+
const client = getClientRef();
|
|
159
|
+
let txId;
|
|
160
|
+
const capture = (tx) => {
|
|
161
|
+
txId = tx.clientTxId;
|
|
162
|
+
};
|
|
163
|
+
switch (operation.action) {
|
|
164
|
+
case "I": {
|
|
165
|
+
await client.create(operation.modelName, operation.payload, {
|
|
166
|
+
onTransactionCreated: capture,
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "U": {
|
|
171
|
+
await client.update(operation.modelName, operation.modelId, operation.payload, {
|
|
172
|
+
onTransactionCreated: capture,
|
|
173
|
+
original: operation.original,
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "D": {
|
|
178
|
+
await client.delete(operation.modelName, operation.modelId, {
|
|
179
|
+
onTransactionCreated: capture,
|
|
180
|
+
original: operation.original,
|
|
181
|
+
});
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "A": {
|
|
185
|
+
await client.archive(operation.modelName, operation.modelId, {
|
|
186
|
+
archivedAt: readArchivedAt(operation.payload),
|
|
187
|
+
onTransactionCreated: capture,
|
|
188
|
+
original: operation.original,
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "V": {
|
|
193
|
+
await client.unarchive(operation.modelName, operation.modelId, {
|
|
194
|
+
onTransactionCreated: capture,
|
|
195
|
+
original: operation.original,
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
default: {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return txId;
|
|
204
|
+
};
|
|
205
|
+
const orchestrator = new SyncOrchestrator(resolvedOptions, identityMaps, emitEvent);
|
|
206
|
+
const getStorageOpenOptions = () => ({
|
|
207
|
+
name: resolvedOptions.dbName,
|
|
208
|
+
schema: resolvedOptions.schema ?? orchestrator.getRegistry().snapshot(),
|
|
209
|
+
userId: resolvedOptions.userId,
|
|
210
|
+
userVersion: resolvedOptions.userVersion,
|
|
211
|
+
version: resolvedOptions.version,
|
|
212
|
+
});
|
|
213
|
+
let yjsManagers;
|
|
214
|
+
if (resolvedOptions.yjsTransport) {
|
|
215
|
+
const clientId = getOrCreateClientId(`${resolvedOptions.dbName ?? "sync-db"}_client_id`);
|
|
216
|
+
const connId = generateUUID();
|
|
217
|
+
const documentManager = new YjsDocumentManager({
|
|
218
|
+
clientId,
|
|
219
|
+
connId,
|
|
220
|
+
});
|
|
221
|
+
const presenceManager = new YjsPresenceManager({
|
|
222
|
+
clientId,
|
|
223
|
+
connId,
|
|
224
|
+
});
|
|
225
|
+
// Presence must replay before document sync handshake on reconnect so
|
|
226
|
+
// the server sees the connection as viewing before yjs_sync_step1.
|
|
227
|
+
presenceManager.setTransport(resolvedOptions.yjsTransport);
|
|
228
|
+
documentManager.setTransport(resolvedOptions.yjsTransport);
|
|
229
|
+
yjsManagers = { documentManager, presenceManager };
|
|
230
|
+
}
|
|
231
|
+
let outboxManager = null;
|
|
232
|
+
let startPromise = null;
|
|
233
|
+
let hasStarted = false;
|
|
234
|
+
let lifecycleVersion = 0;
|
|
235
|
+
const buildOutboxOptions = () => ({
|
|
236
|
+
batchDelay: options.batchDelay,
|
|
237
|
+
batchMutations: options.batchMutations,
|
|
238
|
+
clientId: orchestrator.getClientId() || "temp",
|
|
239
|
+
onTransactionRejected: rollbackTransaction,
|
|
240
|
+
onTransactionStateChange: async () => {
|
|
241
|
+
try {
|
|
242
|
+
await emitPendingCount();
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
emitEvent({
|
|
246
|
+
error: error instanceof Error
|
|
247
|
+
? error
|
|
248
|
+
: new Error("Failed to emit pending count"),
|
|
249
|
+
type: "syncError",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
storage: options.storage,
|
|
254
|
+
transport: options.transport,
|
|
255
|
+
});
|
|
256
|
+
const createOutboxManager = () => {
|
|
257
|
+
const nextOutboxManager = new OutboxManager(buildOutboxOptions());
|
|
258
|
+
outboxManager = nextOutboxManager;
|
|
259
|
+
orchestrator.setOutboxManager(nextOutboxManager);
|
|
260
|
+
return nextOutboxManager;
|
|
261
|
+
};
|
|
262
|
+
const replaceOutboxManager = () => {
|
|
263
|
+
outboxManager?.dispose();
|
|
264
|
+
return createOutboxManager();
|
|
265
|
+
};
|
|
266
|
+
const clearOutboxManager = () => {
|
|
267
|
+
outboxManager?.dispose();
|
|
268
|
+
outboxManager = null;
|
|
269
|
+
};
|
|
270
|
+
const getPendingCountInternal = async (options) => {
|
|
271
|
+
if (!outboxManager) {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
if (options?.awaitStart !== false && startPromise) {
|
|
275
|
+
try {
|
|
276
|
+
await startPromise;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
if (!outboxManager) {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return outboxManager.getPendingCount();
|
|
286
|
+
};
|
|
287
|
+
const emitPendingCount = async (options) => {
|
|
288
|
+
if (!outboxManager) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const pendingCount = await getPendingCountInternal(options);
|
|
292
|
+
emitEvent({ pendingCount, type: "outboxChange" });
|
|
293
|
+
};
|
|
294
|
+
const clearYjsState = () => {
|
|
295
|
+
try {
|
|
296
|
+
const documentManager = yjsManagers?.documentManager;
|
|
297
|
+
documentManager?.destroyAll();
|
|
298
|
+
documentManager?.clearPersistedDocuments?.();
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Best-effort cleanup — don't abort clearAll if Yjs teardown fails
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const runWithStateLock = (operation) => orchestrator.runWithStateLock(operation);
|
|
305
|
+
const createBatchLoadStream = (requests) => options.transport.batchLoad({
|
|
306
|
+
firstSyncId: orchestrator.getFirstSyncId(),
|
|
307
|
+
requests,
|
|
308
|
+
});
|
|
309
|
+
const queueUpdateTransaction = async (modelName, id, effectiveChangeRecord, original, mutationOptions) => {
|
|
310
|
+
if (!outboxManager) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const queuedTx = await outboxManager.update(modelName, id, effectiveChangeRecord, original);
|
|
314
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
315
|
+
return queuedTx;
|
|
316
|
+
};
|
|
317
|
+
const processBatchLoadRow = async (row) => await runWithStateLock(async () => {
|
|
318
|
+
const rowPrimaryKey = orchestrator
|
|
319
|
+
.getRegistry()
|
|
320
|
+
.getPrimaryKey(row.modelName);
|
|
321
|
+
const rowId = row.data[rowPrimaryKey];
|
|
322
|
+
if (typeof rowId !== "string") {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await options.storage.put(row.modelName, row.data);
|
|
326
|
+
const rowMap = identityMaps.getMap(row.modelName);
|
|
327
|
+
const existed = rowMap.has(rowId);
|
|
328
|
+
rowMap.merge(rowId, row.data);
|
|
329
|
+
emitModelChange(row.modelName, rowId, existed ? "update" : "insert");
|
|
330
|
+
return rowId;
|
|
331
|
+
});
|
|
332
|
+
const ensureModelInternal = async (modelName, id) => {
|
|
333
|
+
const map = identityMaps.getMap(modelName);
|
|
334
|
+
const cached = map.get(id);
|
|
335
|
+
if (cached) {
|
|
336
|
+
return cached;
|
|
337
|
+
}
|
|
338
|
+
const stored = await options.storage.get(modelName, id);
|
|
339
|
+
if (stored) {
|
|
340
|
+
map.set(id, stored);
|
|
341
|
+
const key = getModelKey(modelName, id);
|
|
342
|
+
missingModels.delete(key);
|
|
343
|
+
return stored;
|
|
344
|
+
}
|
|
345
|
+
const model = orchestrator.getRegistry().getModelMetadata(modelName);
|
|
346
|
+
if (!model) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const loadStrategy = model.loadStrategy ?? "instant";
|
|
350
|
+
if (loadStrategy === "instant" || loadStrategy === "local") {
|
|
351
|
+
const key = getModelKey(modelName, id);
|
|
352
|
+
missingModels.add(key);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
const key = getModelKey(modelName, id);
|
|
356
|
+
const pending = pendingLoads.get(key);
|
|
357
|
+
if (pending) {
|
|
358
|
+
return pending;
|
|
359
|
+
}
|
|
360
|
+
const loadPromise = (async () => {
|
|
361
|
+
let found = null;
|
|
362
|
+
const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
|
|
363
|
+
const stream = createBatchLoadStream([
|
|
364
|
+
{
|
|
365
|
+
indexedKey: primaryKey,
|
|
366
|
+
keyValue: id,
|
|
367
|
+
modelName,
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
for await (const row of stream) {
|
|
371
|
+
const rowId = await processBatchLoadRow(row);
|
|
372
|
+
if (row.modelName === modelName && rowId === id) {
|
|
373
|
+
found = row.data;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (found) {
|
|
377
|
+
missingModels.delete(key);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
missingModels.add(key);
|
|
381
|
+
}
|
|
382
|
+
return found;
|
|
383
|
+
})();
|
|
384
|
+
pendingLoads.set(key, loadPromise);
|
|
385
|
+
try {
|
|
386
|
+
return await loadPromise;
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
pendingLoads.delete(key);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const loadByIndexInternal = async (modelName, indexedKey, keyValue) => {
|
|
393
|
+
const model = orchestrator.getRegistry().getModelMetadata(modelName);
|
|
394
|
+
const isPartial = model?.loadStrategy === "partial";
|
|
395
|
+
if (!isPartial) {
|
|
396
|
+
return options.storage.getByIndex(modelName, indexedKey, keyValue);
|
|
397
|
+
}
|
|
398
|
+
const hasIndex = await options.storage.hasPartialIndex(modelName, indexedKey, keyValue);
|
|
399
|
+
if (hasIndex) {
|
|
400
|
+
return options.storage.getByIndex(modelName, indexedKey, keyValue);
|
|
401
|
+
}
|
|
402
|
+
const loadKey = `${modelName}:${indexedKey}:${keyValue}`;
|
|
403
|
+
const pending = pendingIndexLoads.get(loadKey);
|
|
404
|
+
if (pending) {
|
|
405
|
+
return pending;
|
|
406
|
+
}
|
|
407
|
+
const loadPromise = (async () => {
|
|
408
|
+
const stream = createBatchLoadStream([
|
|
409
|
+
{
|
|
410
|
+
indexedKey,
|
|
411
|
+
keyValue,
|
|
412
|
+
modelName,
|
|
413
|
+
},
|
|
414
|
+
]);
|
|
415
|
+
for await (const row of stream) {
|
|
416
|
+
await processBatchLoadRow(row);
|
|
417
|
+
}
|
|
418
|
+
await options.storage.setPartialIndex(modelName, indexedKey, keyValue);
|
|
419
|
+
return options.storage.getByIndex(modelName, indexedKey, keyValue);
|
|
420
|
+
})();
|
|
421
|
+
pendingIndexLoads.set(loadKey, loadPromise);
|
|
422
|
+
try {
|
|
423
|
+
return await loadPromise;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
pendingIndexLoads.delete(loadKey);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
// clientRef / getClientRef: The client object literal references itself
|
|
430
|
+
// (via getClientRef) for history replay operations, but it hasn't been
|
|
431
|
+
// assigned yet at definition time. clientRef is set after the object is built.
|
|
432
|
+
let clientRef = null;
|
|
433
|
+
const getClientRef = () => {
|
|
434
|
+
if (!clientRef) {
|
|
435
|
+
throw new Error("Sync client is not initialized");
|
|
436
|
+
}
|
|
437
|
+
return clientRef;
|
|
438
|
+
};
|
|
439
|
+
const modelStore = {
|
|
440
|
+
archive: (modelName, id, archiveOpts) => {
|
|
441
|
+
const client = getClientRef();
|
|
442
|
+
return client.archive(modelName, id, archiveOpts);
|
|
443
|
+
},
|
|
444
|
+
create: (modelName, data) => {
|
|
445
|
+
const client = getClientRef();
|
|
446
|
+
return client.create(modelName, data);
|
|
447
|
+
},
|
|
448
|
+
delete: (modelName, id, deleteOpts) => {
|
|
449
|
+
const client = getClientRef();
|
|
450
|
+
return client.delete(modelName, id, deleteOpts);
|
|
451
|
+
},
|
|
452
|
+
get: ensureModelInternal,
|
|
453
|
+
getByIndex: (modelName, indexName, key) => options.storage.getByIndex(modelName, indexName, key),
|
|
454
|
+
hasPartialIndex: (modelName, indexName, key) => options.storage.hasPartialIndex(modelName, indexName, key),
|
|
455
|
+
loadByIndex: loadByIndexInternal,
|
|
456
|
+
setPartialIndex: (modelName, indexName, key) => options.storage.setPartialIndex(modelName, indexName, key),
|
|
457
|
+
unarchive: (modelName, id, unarchiveOpts) => {
|
|
458
|
+
const client = getClientRef();
|
|
459
|
+
return client.unarchive(modelName, id, unarchiveOpts);
|
|
460
|
+
},
|
|
461
|
+
update: (modelName, id, changes, updateOpts) => {
|
|
462
|
+
const client = getClientRef();
|
|
463
|
+
return client.update(modelName, id, changes, updateOpts);
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
const resolvedModelFactory = resolveModelFactory(resolvedOptions.modelFactory ?? defaultModelFactory, modelStore);
|
|
467
|
+
identityMaps.setModelFactory(resolvedModelFactory);
|
|
468
|
+
const client = {
|
|
469
|
+
async archive(modelName, id, mutationOptions) {
|
|
470
|
+
await runWithStateLock(async () => {
|
|
471
|
+
const map = identityMaps.getMap(modelName);
|
|
472
|
+
const existing = map.get(id);
|
|
473
|
+
if (!existing) {
|
|
474
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
475
|
+
}
|
|
476
|
+
const existingData = getModelData(existing);
|
|
477
|
+
const archived = createArchivePayload(mutationOptions?.archivedAt);
|
|
478
|
+
const original = mutationOptions?.original ?? captureArchiveState(existingData);
|
|
479
|
+
if (resolvedOptions.optimistic !== false) {
|
|
480
|
+
map.update(id, archived);
|
|
481
|
+
emitModelChange(modelName, id, "archive");
|
|
482
|
+
}
|
|
483
|
+
let queuedTx;
|
|
484
|
+
if (outboxManager) {
|
|
485
|
+
queuedTx = await outboxManager.archive(modelName, id, {
|
|
486
|
+
archivedAt: archived.archivedAt ?? undefined,
|
|
487
|
+
original,
|
|
488
|
+
});
|
|
489
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
490
|
+
}
|
|
491
|
+
recordHistoryEntry(history.buildEntry("A", modelName, id, archived, original), queuedTx);
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
canRedo() {
|
|
495
|
+
return history.canRedo();
|
|
496
|
+
},
|
|
497
|
+
canUndo() {
|
|
498
|
+
return history.canUndo();
|
|
499
|
+
},
|
|
500
|
+
async clearAll() {
|
|
501
|
+
const pendingStart = startPromise;
|
|
502
|
+
lifecycleVersion += 1;
|
|
503
|
+
startPromise = null;
|
|
504
|
+
hasStarted = false;
|
|
505
|
+
await outboxManager?.clear();
|
|
506
|
+
clearOutboxManager();
|
|
507
|
+
clearYjsState();
|
|
508
|
+
await orchestrator.reset();
|
|
509
|
+
identityMaps.clearAll();
|
|
510
|
+
await options.storage.close();
|
|
511
|
+
await options.storage.open(getStorageOpenOptions());
|
|
512
|
+
try {
|
|
513
|
+
await options.storage.clear();
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await options.storage.close();
|
|
517
|
+
}
|
|
518
|
+
pendingLoads.clear();
|
|
519
|
+
pendingIndexLoads.clear();
|
|
520
|
+
missingModels.clear();
|
|
521
|
+
history.clear();
|
|
522
|
+
await pendingStart?.catch(() => {
|
|
523
|
+
/* noop */
|
|
524
|
+
});
|
|
525
|
+
emitEvent({ pendingCount: 0, type: "outboxChange" });
|
|
526
|
+
},
|
|
527
|
+
get clientId() {
|
|
528
|
+
return orchestrator.getClientId();
|
|
529
|
+
},
|
|
530
|
+
get connectionState() {
|
|
531
|
+
return orchestrator.connectionState;
|
|
532
|
+
},
|
|
533
|
+
async create(modelName, data, mutationOptions) {
|
|
534
|
+
return await runWithStateLock(async () => {
|
|
535
|
+
const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
|
|
536
|
+
// Generate ID if not provided
|
|
537
|
+
const id = data[primaryKey] || generateUUID();
|
|
538
|
+
const fullData = { ...data, [primaryKey]: id };
|
|
539
|
+
if (resolvedOptions.optimistic !== false) {
|
|
540
|
+
const map = identityMaps.getMap(modelName);
|
|
541
|
+
map.set(id, fullData);
|
|
542
|
+
missingModels.delete(getModelKey(modelName, id));
|
|
543
|
+
emitModelChange(modelName, id, "insert");
|
|
544
|
+
}
|
|
545
|
+
let queuedTx;
|
|
546
|
+
if (outboxManager) {
|
|
547
|
+
queuedTx = await outboxManager.insert(modelName, id, fullData);
|
|
548
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
549
|
+
}
|
|
550
|
+
recordHistoryEntry(history.buildEntry("I", modelName, id, fullData), queuedTx);
|
|
551
|
+
return fullData;
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
async delete(modelName, id, mutationOptions) {
|
|
555
|
+
await runWithStateLock(async () => {
|
|
556
|
+
const map = identityMaps.getMap(modelName);
|
|
557
|
+
const existing = map.get(id);
|
|
558
|
+
if (!existing) {
|
|
559
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
560
|
+
}
|
|
561
|
+
if (resolvedOptions.optimistic !== false) {
|
|
562
|
+
map.delete(id);
|
|
563
|
+
emitModelChange(modelName, id, "delete");
|
|
564
|
+
}
|
|
565
|
+
const original = mutationOptions?.original ?? getModelData(existing);
|
|
566
|
+
let queuedTx;
|
|
567
|
+
if (outboxManager) {
|
|
568
|
+
queuedTx = await outboxManager.delete(modelName, id, original);
|
|
569
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
570
|
+
}
|
|
571
|
+
recordHistoryEntry(history.buildEntry("D", modelName, id, {}, original), queuedTx);
|
|
572
|
+
});
|
|
573
|
+
},
|
|
574
|
+
ensureModel(modelName, id) {
|
|
575
|
+
return ensureModelInternal(modelName, id);
|
|
576
|
+
},
|
|
577
|
+
async get(modelName, id) {
|
|
578
|
+
// Try identity map first
|
|
579
|
+
const map = identityMaps.getMap(modelName);
|
|
580
|
+
const cached = map.get(id);
|
|
581
|
+
if (cached) {
|
|
582
|
+
return cached;
|
|
583
|
+
}
|
|
584
|
+
// Fall back to storage
|
|
585
|
+
const stored = await options.storage.get(modelName, id);
|
|
586
|
+
if (stored) {
|
|
587
|
+
// Cache in identity map
|
|
588
|
+
map.set(id, stored);
|
|
589
|
+
}
|
|
590
|
+
return stored;
|
|
591
|
+
},
|
|
592
|
+
getAll(modelName, queryOptions) {
|
|
593
|
+
const map = identityMaps.getMap(modelName);
|
|
594
|
+
const result = executeQuery(map, queryOptions);
|
|
595
|
+
return Promise.resolve(result.data);
|
|
596
|
+
},
|
|
597
|
+
getCached(modelName, id) {
|
|
598
|
+
const map = identityMaps.getMap(modelName);
|
|
599
|
+
const cached = map.get(id);
|
|
600
|
+
return cached ? cached : null;
|
|
601
|
+
},
|
|
602
|
+
getIdentityMap(modelName) {
|
|
603
|
+
return identityMaps
|
|
604
|
+
.getMap(modelName)
|
|
605
|
+
.getRawMap();
|
|
606
|
+
},
|
|
607
|
+
getPendingCount() {
|
|
608
|
+
return getPendingCountInternal();
|
|
609
|
+
},
|
|
610
|
+
isModelMissing(modelName, id) {
|
|
611
|
+
return missingModels.has(getModelKey(modelName, id));
|
|
612
|
+
},
|
|
613
|
+
get lastError() {
|
|
614
|
+
return orchestrator.getLastError();
|
|
615
|
+
},
|
|
616
|
+
get lastSyncId() {
|
|
617
|
+
return orchestrator.getLastSyncId();
|
|
618
|
+
},
|
|
619
|
+
onConnectionStateChange(
|
|
620
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
621
|
+
callback) {
|
|
622
|
+
return orchestrator.onConnectionStateChange(callback);
|
|
623
|
+
},
|
|
624
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
625
|
+
onEvent(callback) {
|
|
626
|
+
eventListeners.add(callback);
|
|
627
|
+
return () => {
|
|
628
|
+
eventListeners.delete(callback);
|
|
629
|
+
};
|
|
630
|
+
},
|
|
631
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
|
|
632
|
+
onStateChange(callback) {
|
|
633
|
+
return orchestrator.onStateChange(callback);
|
|
634
|
+
},
|
|
635
|
+
query(modelName, queryOptions) {
|
|
636
|
+
const map = identityMaps.getMap(modelName);
|
|
637
|
+
return Promise.resolve(executeQuery(map, queryOptions));
|
|
638
|
+
},
|
|
639
|
+
async redo() {
|
|
640
|
+
await history.redo(applyHistoryOperation);
|
|
641
|
+
},
|
|
642
|
+
async runAsUndoGroup(operation) {
|
|
643
|
+
return await history.runAsGroup(operation);
|
|
644
|
+
},
|
|
645
|
+
start() {
|
|
646
|
+
if (startPromise) {
|
|
647
|
+
return startPromise;
|
|
648
|
+
}
|
|
649
|
+
if (hasStarted) {
|
|
650
|
+
return Promise.resolve();
|
|
651
|
+
}
|
|
652
|
+
const startVersion = lifecycleVersion;
|
|
653
|
+
let currentStartPromise = Promise.resolve();
|
|
654
|
+
currentStartPromise = (async () => {
|
|
655
|
+
replaceOutboxManager();
|
|
656
|
+
orchestrator.setConflictHandler(rollbackTransaction);
|
|
657
|
+
try {
|
|
658
|
+
await orchestrator.start();
|
|
659
|
+
if (startVersion !== lifecycleVersion) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// Recreate with correct client ID after orchestrator resolves it.
|
|
663
|
+
replaceOutboxManager();
|
|
664
|
+
hasStarted = true;
|
|
665
|
+
await emitPendingCount({ awaitStart: false });
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
if (startVersion === lifecycleVersion) {
|
|
669
|
+
clearOutboxManager();
|
|
670
|
+
hasStarted = false;
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
if (startPromise === currentStartPromise) {
|
|
676
|
+
startPromise = null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
})();
|
|
680
|
+
startPromise = currentStartPromise;
|
|
681
|
+
return currentStartPromise;
|
|
682
|
+
},
|
|
683
|
+
get state() {
|
|
684
|
+
return orchestrator.state;
|
|
685
|
+
},
|
|
686
|
+
async stop() {
|
|
687
|
+
const pendingStart = startPromise;
|
|
688
|
+
lifecycleVersion += 1;
|
|
689
|
+
startPromise = null;
|
|
690
|
+
hasStarted = false;
|
|
691
|
+
clearOutboxManager();
|
|
692
|
+
await orchestrator.stop();
|
|
693
|
+
await pendingStart?.catch(() => {
|
|
694
|
+
/* noop */
|
|
695
|
+
});
|
|
696
|
+
},
|
|
697
|
+
async syncNow() {
|
|
698
|
+
await orchestrator.syncNow();
|
|
699
|
+
},
|
|
700
|
+
async unarchive(modelName, id, mutationOptions) {
|
|
701
|
+
await runWithStateLock(async () => {
|
|
702
|
+
const map = identityMaps.getMap(modelName);
|
|
703
|
+
const existing = map.get(id);
|
|
704
|
+
if (!existing) {
|
|
705
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
706
|
+
}
|
|
707
|
+
const existingData = getModelData(existing);
|
|
708
|
+
const original = mutationOptions?.original ?? captureArchiveState(existingData);
|
|
709
|
+
const unarchivePatch = createUnarchivePatch();
|
|
710
|
+
if (resolvedOptions.optimistic !== false) {
|
|
711
|
+
map.update(id, unarchivePatch);
|
|
712
|
+
emitModelChange(modelName, id, "unarchive");
|
|
713
|
+
}
|
|
714
|
+
let queuedTx;
|
|
715
|
+
if (outboxManager) {
|
|
716
|
+
queuedTx = await outboxManager.unarchive(modelName, id, { original });
|
|
717
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
718
|
+
}
|
|
719
|
+
recordHistoryEntry(history.buildEntry("V", modelName, id, createUnarchivePayload(), original), queuedTx);
|
|
720
|
+
});
|
|
721
|
+
},
|
|
722
|
+
async undo() {
|
|
723
|
+
await history.undo(applyHistoryOperation);
|
|
724
|
+
},
|
|
725
|
+
async update(modelName, id, changes, mutationOptions) {
|
|
726
|
+
// Apply the optimistic update synchronously (outside the state lock)
|
|
727
|
+
// so MobX observers see it immediately and the identity map is updated
|
|
728
|
+
// before any concurrent delta processing can run. The outbox queue and
|
|
729
|
+
// history recording still use the lock to serialize with deltas.
|
|
730
|
+
const map = identityMaps.getMap(modelName);
|
|
731
|
+
const existing = map.get(id);
|
|
732
|
+
if (!existing) {
|
|
733
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
734
|
+
}
|
|
735
|
+
const existingData = getModelData(existing);
|
|
736
|
+
const { effectiveChanges, effectiveChangeRecord } = buildEffectiveUpdate(existingData, changes);
|
|
737
|
+
if (Object.keys(effectiveChangeRecord).length === 0) {
|
|
738
|
+
return existingData;
|
|
739
|
+
}
|
|
740
|
+
const originalSource = mutationOptions?.original ?? existingData;
|
|
741
|
+
const original = pickOriginal(originalSource, effectiveChangeRecord);
|
|
742
|
+
const updated = { ...existingData, ...effectiveChanges };
|
|
743
|
+
if (resolvedOptions.optimistic !== false) {
|
|
744
|
+
map.update(id, effectiveChanges);
|
|
745
|
+
missingModels.delete(getModelKey(modelName, id));
|
|
746
|
+
emitModelChange(modelName, id, "update");
|
|
747
|
+
}
|
|
748
|
+
await runWithStateLock(async () => {
|
|
749
|
+
const queuedTx = await queueUpdateTransaction(modelName, id, effectiveChangeRecord, original, mutationOptions);
|
|
750
|
+
recordHistoryEntry(history.buildEntry("U", modelName, id, effectiveChangeRecord, original), queuedTx);
|
|
751
|
+
});
|
|
752
|
+
return updated;
|
|
753
|
+
},
|
|
754
|
+
yjs: yjsManagers,
|
|
755
|
+
};
|
|
756
|
+
clientRef = client;
|
|
757
|
+
return client;
|
|
758
|
+
};
|
|
759
|
+
//# sourceMappingURL=client.js.map
|