@strata-sync/client 0.1.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/AGENTS.md +24 -0
- package/README.md +63 -0
- package/package.json +32 -0
- package/src/client.ts +1051 -0
- package/src/identity-map.ts +294 -0
- package/src/index.ts +33 -0
- package/src/outbox-manager.ts +399 -0
- package/src/query.ts +224 -0
- package/src/sync-orchestrator.ts +1041 -0
- package/src/types.ts +425 -0
- package/tests/rebase-integration.test.ts +920 -0
- package/tests/reverse-linear-sync-engine.test.ts +701 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +31 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionState,
|
|
3
|
+
SyncClientState,
|
|
4
|
+
SyncStore,
|
|
5
|
+
Transaction,
|
|
6
|
+
TransactionAction,
|
|
7
|
+
} from "@strata-sync/core";
|
|
8
|
+
import {
|
|
9
|
+
generateUUID,
|
|
10
|
+
getOrCreateClientId,
|
|
11
|
+
ModelRegistry,
|
|
12
|
+
} from "@strata-sync/core";
|
|
13
|
+
import { YjsDocumentManager, YjsPresenceManager } from "@strata-sync/yjs";
|
|
14
|
+
import { IdentityMapRegistry } from "./identity-map";
|
|
15
|
+
import { OutboxManager } from "./outbox-manager";
|
|
16
|
+
import { executeQuery } from "./query";
|
|
17
|
+
import { SyncOrchestrator } from "./sync-orchestrator";
|
|
18
|
+
import type {
|
|
19
|
+
ModelFactory,
|
|
20
|
+
ModelFactoryFactory,
|
|
21
|
+
ModelStore,
|
|
22
|
+
QueryOptions,
|
|
23
|
+
QueryResult,
|
|
24
|
+
SyncClient,
|
|
25
|
+
SyncClientEvent,
|
|
26
|
+
SyncClientOptions,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a sync client instance
|
|
31
|
+
*/
|
|
32
|
+
export function createSyncClient(options: SyncClientOptions): SyncClient {
|
|
33
|
+
const resolvedOptions: SyncClientOptions = { ...options };
|
|
34
|
+
|
|
35
|
+
const identityMaps = new IdentityMapRegistry(resolvedOptions.reactivity);
|
|
36
|
+
const eventListeners = new Set<(event: SyncClientEvent) => void>();
|
|
37
|
+
const missingModels = new Set<string>();
|
|
38
|
+
const pendingLoads = new Map<string, Promise<unknown | null>>();
|
|
39
|
+
const pendingIndexLoads = new Map<
|
|
40
|
+
string,
|
|
41
|
+
Promise<Record<string, unknown>[]>
|
|
42
|
+
>();
|
|
43
|
+
|
|
44
|
+
interface HistoryOperation {
|
|
45
|
+
action: TransactionAction;
|
|
46
|
+
modelName: string;
|
|
47
|
+
modelId: string;
|
|
48
|
+
payload: Record<string, unknown>;
|
|
49
|
+
original?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface HistoryEntry {
|
|
53
|
+
id?: string;
|
|
54
|
+
undo: HistoryOperation;
|
|
55
|
+
redo: HistoryOperation;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const undoStack: HistoryEntry[] = [];
|
|
59
|
+
const redoStack: HistoryEntry[] = [];
|
|
60
|
+
let suppressHistory = false;
|
|
61
|
+
|
|
62
|
+
const pushHistory = (entry: HistoryEntry | null): void => {
|
|
63
|
+
if (!entry || suppressHistory) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
undoStack.push(entry);
|
|
67
|
+
redoStack.length = 0;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const removeHistoryByTxId = (clientTxId: string): void => {
|
|
71
|
+
const removeMatches = (stack: HistoryEntry[]): void => {
|
|
72
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
73
|
+
if (stack[i]?.id === clientTxId) {
|
|
74
|
+
stack.splice(i, 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
removeMatches(undoStack);
|
|
79
|
+
removeMatches(redoStack);
|
|
80
|
+
};
|
|
81
|
+
const getModelKey = (modelName: string, id: string): string => {
|
|
82
|
+
return `${modelName}:${id}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getModelData = (
|
|
86
|
+
value: Record<string, unknown>
|
|
87
|
+
): Record<string, unknown> => {
|
|
88
|
+
const candidate = value as { toJSON?: () => Record<string, unknown> };
|
|
89
|
+
if (typeof candidate?.toJSON === "function") {
|
|
90
|
+
return candidate.toJSON();
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const pickOriginal = (
|
|
96
|
+
existing: Record<string, unknown>,
|
|
97
|
+
changes: Record<string, unknown>
|
|
98
|
+
): Record<string, unknown> => {
|
|
99
|
+
const original: Record<string, unknown> = {};
|
|
100
|
+
for (const key of Object.keys(changes)) {
|
|
101
|
+
original[key] = existing[key];
|
|
102
|
+
}
|
|
103
|
+
return original;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const buildHistoryEntry = (
|
|
107
|
+
action: TransactionAction,
|
|
108
|
+
modelName: string,
|
|
109
|
+
modelId: string,
|
|
110
|
+
payload: Record<string, unknown>,
|
|
111
|
+
original?: Record<string, unknown>
|
|
112
|
+
): HistoryEntry | null => {
|
|
113
|
+
const redo: HistoryOperation = {
|
|
114
|
+
action,
|
|
115
|
+
modelName,
|
|
116
|
+
modelId,
|
|
117
|
+
payload,
|
|
118
|
+
original,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let undo: HistoryOperation | null = null;
|
|
122
|
+
|
|
123
|
+
switch (action) {
|
|
124
|
+
case "I":
|
|
125
|
+
undo = {
|
|
126
|
+
action: "D",
|
|
127
|
+
modelName,
|
|
128
|
+
modelId,
|
|
129
|
+
payload: {},
|
|
130
|
+
original: payload,
|
|
131
|
+
};
|
|
132
|
+
break;
|
|
133
|
+
case "D":
|
|
134
|
+
if (original) {
|
|
135
|
+
undo = {
|
|
136
|
+
action: "I",
|
|
137
|
+
modelName,
|
|
138
|
+
modelId,
|
|
139
|
+
payload: original,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case "U":
|
|
144
|
+
if (original) {
|
|
145
|
+
undo = {
|
|
146
|
+
action: "U",
|
|
147
|
+
modelName,
|
|
148
|
+
modelId,
|
|
149
|
+
payload: original,
|
|
150
|
+
original: payload,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case "A": {
|
|
155
|
+
const archivedAt = payload.archivedAt as string | undefined;
|
|
156
|
+
undo = {
|
|
157
|
+
action: "V",
|
|
158
|
+
modelName,
|
|
159
|
+
modelId,
|
|
160
|
+
payload: {},
|
|
161
|
+
original: { archivedAt },
|
|
162
|
+
};
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "V": {
|
|
166
|
+
const archivedAt = original?.archivedAt as string | undefined;
|
|
167
|
+
undo = {
|
|
168
|
+
action: "A",
|
|
169
|
+
modelName,
|
|
170
|
+
modelId,
|
|
171
|
+
payload: { archivedAt: archivedAt ?? new Date().toISOString() },
|
|
172
|
+
original: { archivedAt: undefined },
|
|
173
|
+
};
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!undo) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return { undo, redo };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const emitEvent = (event: SyncClientEvent): void => {
|
|
187
|
+
if (event.type === "modelChange") {
|
|
188
|
+
const key = getModelKey(event.modelName, event.modelId);
|
|
189
|
+
if (event.action === "delete") {
|
|
190
|
+
missingModels.add(key);
|
|
191
|
+
} else {
|
|
192
|
+
missingModels.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const listener of eventListeners) {
|
|
197
|
+
listener(event);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const rollbackTransaction = (tx: Transaction): void => {
|
|
202
|
+
const map = identityMaps.getMap<Record<string, unknown>>(tx.modelName);
|
|
203
|
+
const existing = map.get(tx.modelId);
|
|
204
|
+
const original = tx.original;
|
|
205
|
+
|
|
206
|
+
switch (tx.action) {
|
|
207
|
+
case "I": {
|
|
208
|
+
if (existing) {
|
|
209
|
+
map.delete(tx.modelId);
|
|
210
|
+
emitEvent({
|
|
211
|
+
type: "modelChange",
|
|
212
|
+
modelName: tx.modelName,
|
|
213
|
+
modelId: tx.modelId,
|
|
214
|
+
action: "delete",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "D": {
|
|
220
|
+
if (original) {
|
|
221
|
+
map.set(tx.modelId, original as Record<string, unknown>);
|
|
222
|
+
emitEvent({
|
|
223
|
+
type: "modelChange",
|
|
224
|
+
modelName: tx.modelName,
|
|
225
|
+
modelId: tx.modelId,
|
|
226
|
+
action: "insert",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "U": {
|
|
232
|
+
if (!original) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (existing) {
|
|
236
|
+
map.update(tx.modelId, original as Record<string, unknown>);
|
|
237
|
+
emitEvent({
|
|
238
|
+
type: "modelChange",
|
|
239
|
+
modelName: tx.modelName,
|
|
240
|
+
modelId: tx.modelId,
|
|
241
|
+
action: "update",
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
map.set(tx.modelId, original as Record<string, unknown>);
|
|
245
|
+
emitEvent({
|
|
246
|
+
type: "modelChange",
|
|
247
|
+
modelName: tx.modelName,
|
|
248
|
+
modelId: tx.modelId,
|
|
249
|
+
action: "insert",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "A":
|
|
255
|
+
case "V": {
|
|
256
|
+
if (existing) {
|
|
257
|
+
const archivedAt =
|
|
258
|
+
(original as Record<string, unknown> | undefined)?.archivedAt ??
|
|
259
|
+
undefined;
|
|
260
|
+
map.update(tx.modelId, { archivedAt } as Record<string, unknown>);
|
|
261
|
+
emitEvent({
|
|
262
|
+
type: "modelChange",
|
|
263
|
+
modelName: tx.modelName,
|
|
264
|
+
modelId: tx.modelId,
|
|
265
|
+
action: "update",
|
|
266
|
+
});
|
|
267
|
+
} else if (original) {
|
|
268
|
+
map.set(tx.modelId, original as Record<string, unknown>);
|
|
269
|
+
emitEvent({
|
|
270
|
+
type: "modelChange",
|
|
271
|
+
modelName: tx.modelName,
|
|
272
|
+
modelId: tx.modelId,
|
|
273
|
+
action: "insert",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const applyHistoryOperation = async (
|
|
284
|
+
operation: HistoryOperation
|
|
285
|
+
): Promise<string | undefined> => {
|
|
286
|
+
const client = getClientRef();
|
|
287
|
+
let txId: string | undefined;
|
|
288
|
+
const capture = (tx: Transaction) => {
|
|
289
|
+
txId = tx.clientTxId;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
switch (operation.action) {
|
|
293
|
+
case "I":
|
|
294
|
+
await client.create(operation.modelName, operation.payload, {
|
|
295
|
+
onTransactionCreated: capture,
|
|
296
|
+
});
|
|
297
|
+
break;
|
|
298
|
+
case "U":
|
|
299
|
+
await client.update(
|
|
300
|
+
operation.modelName,
|
|
301
|
+
operation.modelId,
|
|
302
|
+
operation.payload,
|
|
303
|
+
{
|
|
304
|
+
original: operation.original,
|
|
305
|
+
onTransactionCreated: capture,
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
break;
|
|
309
|
+
case "D":
|
|
310
|
+
await client.delete(operation.modelName, operation.modelId, {
|
|
311
|
+
original: operation.original,
|
|
312
|
+
onTransactionCreated: capture,
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
case "A":
|
|
316
|
+
await client.archive(operation.modelName, operation.modelId, {
|
|
317
|
+
original: operation.original,
|
|
318
|
+
archivedAt: operation.payload.archivedAt as string | undefined,
|
|
319
|
+
onTransactionCreated: capture,
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
case "V":
|
|
323
|
+
await client.unarchive(operation.modelName, operation.modelId, {
|
|
324
|
+
original: operation.original,
|
|
325
|
+
onTransactionCreated: capture,
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
default:
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return txId;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const orchestrator = new SyncOrchestrator(
|
|
336
|
+
resolvedOptions,
|
|
337
|
+
identityMaps,
|
|
338
|
+
emitEvent
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
let yjsManagers: SyncClient["yjs"] | undefined;
|
|
342
|
+
if (resolvedOptions.yjsTransport) {
|
|
343
|
+
const clientId = getOrCreateClientId(
|
|
344
|
+
`${resolvedOptions.dbName ?? "sync-db"}_client_id`
|
|
345
|
+
);
|
|
346
|
+
const connId = generateUUID();
|
|
347
|
+
const documentManager = new YjsDocumentManager({
|
|
348
|
+
clientId,
|
|
349
|
+
connId,
|
|
350
|
+
});
|
|
351
|
+
const presenceManager = new YjsPresenceManager({
|
|
352
|
+
clientId,
|
|
353
|
+
connId,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
documentManager.setTransport(resolvedOptions.yjsTransport);
|
|
357
|
+
presenceManager.setTransport(resolvedOptions.yjsTransport);
|
|
358
|
+
|
|
359
|
+
yjsManagers = { documentManager, presenceManager };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let outboxManager: OutboxManager | null = null;
|
|
363
|
+
|
|
364
|
+
const buildOutboxOptions = () => ({
|
|
365
|
+
storage: options.storage,
|
|
366
|
+
transport: options.transport,
|
|
367
|
+
clientId: orchestrator.getClientId() || "temp",
|
|
368
|
+
batchMutations: options.batchMutations,
|
|
369
|
+
batchDelay: options.batchDelay,
|
|
370
|
+
onTransactionStateChange: () => {
|
|
371
|
+
emitPendingCount().catch((error) => {
|
|
372
|
+
console.error("Failed to emit pending count", error);
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
onTransactionRejected: (tx: Transaction) => {
|
|
376
|
+
rollbackTransaction(tx);
|
|
377
|
+
removeHistoryByTxId(tx.clientTxId);
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const emitPendingCount = async (): Promise<void> => {
|
|
382
|
+
if (!outboxManager) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const pendingCount = await outboxManager.getPendingCount();
|
|
386
|
+
emitEvent({ type: "outboxChange", pendingCount });
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const processBatchLoadRow = async (
|
|
390
|
+
row: import("@strata-sync/core").ModelRow
|
|
391
|
+
): Promise<string | undefined> => {
|
|
392
|
+
const rowPrimaryKey = orchestrator
|
|
393
|
+
.getRegistry()
|
|
394
|
+
.getPrimaryKey(row.modelName);
|
|
395
|
+
const rowId = row.data[rowPrimaryKey] as string;
|
|
396
|
+
if (typeof rowId !== "string") {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
await options.storage.put(row.modelName, row.data);
|
|
400
|
+
const rowMap = identityMaps.getMap<Record<string, unknown>>(row.modelName);
|
|
401
|
+
const existed = rowMap.has(rowId);
|
|
402
|
+
rowMap.merge(rowId, row.data);
|
|
403
|
+
emitEvent({
|
|
404
|
+
type: "modelChange",
|
|
405
|
+
modelName: row.modelName,
|
|
406
|
+
modelId: rowId,
|
|
407
|
+
action: existed ? "update" : "insert",
|
|
408
|
+
});
|
|
409
|
+
return rowId;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const ensureModelInternal = async <T>(
|
|
413
|
+
modelName: string,
|
|
414
|
+
id: string
|
|
415
|
+
): Promise<T | null> => {
|
|
416
|
+
const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
|
|
417
|
+
const cached = map.get(id);
|
|
418
|
+
if (cached) {
|
|
419
|
+
return cached as T;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const stored = await options.storage.get<T>(modelName, id);
|
|
423
|
+
if (stored) {
|
|
424
|
+
map.set(id, stored as T & Record<string, unknown>);
|
|
425
|
+
const key = getModelKey(modelName, id);
|
|
426
|
+
missingModels.delete(key);
|
|
427
|
+
return stored;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const model = orchestrator.getRegistry().getModelMetadata(modelName);
|
|
431
|
+
if (!model) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const loadStrategy = model.loadStrategy ?? "instant";
|
|
436
|
+
if (loadStrategy === "instant" || loadStrategy === "local") {
|
|
437
|
+
const key = getModelKey(modelName, id);
|
|
438
|
+
missingModels.add(key);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const key = getModelKey(modelName, id);
|
|
443
|
+
const pending = pendingLoads.get(key);
|
|
444
|
+
if (pending) {
|
|
445
|
+
return pending as Promise<T | null>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const loadPromise = (async () => {
|
|
449
|
+
let found: T | null = null;
|
|
450
|
+
const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
|
|
451
|
+
const stream = options.transport.batchLoad({
|
|
452
|
+
firstSyncId: orchestrator.getFirstSyncId(),
|
|
453
|
+
requests: [
|
|
454
|
+
{
|
|
455
|
+
modelName,
|
|
456
|
+
indexedKey: primaryKey,
|
|
457
|
+
keyValue: id,
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
for await (const row of stream) {
|
|
463
|
+
const rowId = await processBatchLoadRow(row);
|
|
464
|
+
if (row.modelName === modelName && rowId === id) {
|
|
465
|
+
found = row.data as T;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (found) {
|
|
470
|
+
missingModels.delete(key);
|
|
471
|
+
} else {
|
|
472
|
+
missingModels.add(key);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return found;
|
|
476
|
+
})();
|
|
477
|
+
|
|
478
|
+
pendingLoads.set(key, loadPromise);
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
return await loadPromise;
|
|
482
|
+
} finally {
|
|
483
|
+
pendingLoads.delete(key);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const loadByIndexInternal = async <T extends Record<string, unknown>>(
|
|
488
|
+
modelName: string,
|
|
489
|
+
indexedKey: string,
|
|
490
|
+
keyValue: string
|
|
491
|
+
): Promise<T[]> => {
|
|
492
|
+
const model = orchestrator.getRegistry().getModelMetadata(modelName);
|
|
493
|
+
const isPartial = model?.loadStrategy === "partial";
|
|
494
|
+
|
|
495
|
+
if (!isPartial) {
|
|
496
|
+
return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const hasIndex = await options.storage.hasPartialIndex(
|
|
500
|
+
modelName,
|
|
501
|
+
indexedKey,
|
|
502
|
+
keyValue
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (hasIndex) {
|
|
506
|
+
return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const loadKey = `${modelName}:${indexedKey}:${keyValue}`;
|
|
510
|
+
const pending = pendingIndexLoads.get(loadKey);
|
|
511
|
+
if (pending) {
|
|
512
|
+
return pending as Promise<T[]>;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const loadPromise = (async () => {
|
|
516
|
+
const stream = options.transport.batchLoad({
|
|
517
|
+
firstSyncId: orchestrator.getFirstSyncId(),
|
|
518
|
+
requests: [
|
|
519
|
+
{
|
|
520
|
+
modelName,
|
|
521
|
+
indexedKey,
|
|
522
|
+
keyValue,
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
for await (const row of stream) {
|
|
528
|
+
await processBatchLoadRow(row);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await options.storage.setPartialIndex(modelName, indexedKey, keyValue);
|
|
532
|
+
return options.storage.getByIndex<T>(modelName, indexedKey, keyValue);
|
|
533
|
+
})();
|
|
534
|
+
|
|
535
|
+
pendingIndexLoads.set(loadKey, loadPromise);
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
return await loadPromise;
|
|
539
|
+
} finally {
|
|
540
|
+
pendingIndexLoads.delete(loadKey);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
let clientRef: SyncClient | null = null;
|
|
545
|
+
|
|
546
|
+
const getClientRef = (): SyncClient => {
|
|
547
|
+
if (!clientRef) {
|
|
548
|
+
throw new Error("Sync client is not initialized");
|
|
549
|
+
}
|
|
550
|
+
return clientRef;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const modelStore: ModelStore & SyncStore = {
|
|
554
|
+
get: ensureModelInternal,
|
|
555
|
+
getByIndex: (modelName, indexName, key) =>
|
|
556
|
+
options.storage.getByIndex(modelName, indexName, key),
|
|
557
|
+
loadByIndex: loadByIndexInternal,
|
|
558
|
+
hasPartialIndex: (modelName, indexName, key) =>
|
|
559
|
+
options.storage.hasPartialIndex(modelName, indexName, key),
|
|
560
|
+
setPartialIndex: (modelName, indexName, key) =>
|
|
561
|
+
options.storage.setPartialIndex(modelName, indexName, key),
|
|
562
|
+
create: (modelName, data) => {
|
|
563
|
+
const client = getClientRef();
|
|
564
|
+
return client.create(modelName, data);
|
|
565
|
+
},
|
|
566
|
+
update: (modelName, id, changes, options) => {
|
|
567
|
+
const client = getClientRef();
|
|
568
|
+
return client.update(modelName, id, changes, options);
|
|
569
|
+
},
|
|
570
|
+
delete: (modelName, id, options) => {
|
|
571
|
+
const client = getClientRef();
|
|
572
|
+
return client.delete(modelName, id, options);
|
|
573
|
+
},
|
|
574
|
+
archive: (modelName, id, options) => {
|
|
575
|
+
const client = getClientRef();
|
|
576
|
+
return client.archive(modelName, id, options);
|
|
577
|
+
},
|
|
578
|
+
unarchive: (modelName, id, options) => {
|
|
579
|
+
const client = getClientRef();
|
|
580
|
+
return client.unarchive(modelName, id, options);
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const defaultModelFactory: ModelFactoryFactory = ({ store }) => {
|
|
585
|
+
return (modelName: string, data: Record<string, unknown>) => {
|
|
586
|
+
const ctor = ModelRegistry.getModelConstructor(modelName);
|
|
587
|
+
if (!ctor) {
|
|
588
|
+
return data;
|
|
589
|
+
}
|
|
590
|
+
const instance = new ctor() as Record<string, unknown>;
|
|
591
|
+
const candidate = instance as {
|
|
592
|
+
store?: SyncStore;
|
|
593
|
+
_applyUpdate?: (changes: Record<string, unknown>) => void;
|
|
594
|
+
};
|
|
595
|
+
if ("store" in candidate) {
|
|
596
|
+
candidate.store = store;
|
|
597
|
+
}
|
|
598
|
+
if (typeof candidate._applyUpdate === "function") {
|
|
599
|
+
candidate._applyUpdate(data);
|
|
600
|
+
} else {
|
|
601
|
+
Object.assign(instance, data);
|
|
602
|
+
}
|
|
603
|
+
return instance;
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const resolveModelFactory = (
|
|
608
|
+
factory: ModelFactory | ModelFactoryFactory | undefined
|
|
609
|
+
): ModelFactory | undefined => {
|
|
610
|
+
if (!factory) {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
if (typeof factory === "function" && factory.length <= 1) {
|
|
614
|
+
return (factory as ModelFactoryFactory)({ store: modelStore });
|
|
615
|
+
}
|
|
616
|
+
return factory as ModelFactory;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const resolvedModelFactory = resolveModelFactory(
|
|
620
|
+
resolvedOptions.modelFactory ?? defaultModelFactory
|
|
621
|
+
);
|
|
622
|
+
identityMaps.setModelFactory(resolvedModelFactory);
|
|
623
|
+
|
|
624
|
+
const client: SyncClient = {
|
|
625
|
+
get state(): SyncClientState {
|
|
626
|
+
return orchestrator.state;
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
get connectionState(): ConnectionState {
|
|
630
|
+
return orchestrator.connectionState;
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
get lastSyncId(): number {
|
|
634
|
+
return orchestrator.getLastSyncId();
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
get lastError(): Error | null {
|
|
638
|
+
return orchestrator.getLastError();
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
get clientId(): string {
|
|
642
|
+
return orchestrator.getClientId();
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
yjs: yjsManagers,
|
|
646
|
+
|
|
647
|
+
async start(): Promise<void> {
|
|
648
|
+
outboxManager = new OutboxManager(buildOutboxOptions());
|
|
649
|
+
orchestrator.setOutboxManager(outboxManager);
|
|
650
|
+
orchestrator.setConflictHandler((tx) => {
|
|
651
|
+
rollbackTransaction(tx);
|
|
652
|
+
removeHistoryByTxId(tx.clientTxId);
|
|
653
|
+
});
|
|
654
|
+
await orchestrator.start();
|
|
655
|
+
|
|
656
|
+
// Recreate with correct client ID after orchestrator resolves it
|
|
657
|
+
outboxManager = new OutboxManager(buildOutboxOptions());
|
|
658
|
+
orchestrator.setOutboxManager(outboxManager);
|
|
659
|
+
|
|
660
|
+
await emitPendingCount();
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
async stop(): Promise<void> {
|
|
664
|
+
await orchestrator.stop();
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
async get<T>(modelName: string, id: string): Promise<T | null> {
|
|
668
|
+
// Try identity map first
|
|
669
|
+
const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
|
|
670
|
+
const cached = map.get(id);
|
|
671
|
+
if (cached) {
|
|
672
|
+
return cached as T;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Fall back to storage
|
|
676
|
+
const stored = await options.storage.get<T>(modelName, id);
|
|
677
|
+
if (stored) {
|
|
678
|
+
// Cache in identity map
|
|
679
|
+
map.set(id, stored as T & Record<string, unknown>);
|
|
680
|
+
}
|
|
681
|
+
return stored;
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
getCached<T>(modelName: string, id: string): T | null {
|
|
685
|
+
const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
|
|
686
|
+
const cached = map.get(id);
|
|
687
|
+
return cached ? (cached as T) : null;
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
ensureModel<T>(modelName: string, id: string): Promise<T | null> {
|
|
691
|
+
return ensureModelInternal(modelName, id);
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
getAll<T>(modelName: string, queryOptions?: QueryOptions<T>): Promise<T[]> {
|
|
695
|
+
const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
|
|
696
|
+
const result = executeQuery(map, queryOptions);
|
|
697
|
+
return Promise.resolve(result.data as T[]);
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
query<T>(
|
|
701
|
+
modelName: string,
|
|
702
|
+
queryOptions?: QueryOptions<T>
|
|
703
|
+
): Promise<QueryResult<T>> {
|
|
704
|
+
const map = identityMaps.getMap<T & Record<string, unknown>>(modelName);
|
|
705
|
+
return Promise.resolve(executeQuery(map, queryOptions) as QueryResult<T>);
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async create<T extends Record<string, unknown>>(
|
|
709
|
+
modelName: string,
|
|
710
|
+
data: T,
|
|
711
|
+
mutationOptions?: { onTransactionCreated?: (tx: Transaction) => void }
|
|
712
|
+
): Promise<T> {
|
|
713
|
+
const primaryKey = orchestrator.getRegistry().getPrimaryKey(modelName);
|
|
714
|
+
|
|
715
|
+
// Generate ID if not provided
|
|
716
|
+
const id = (data[primaryKey] as string) || generateUUID();
|
|
717
|
+
const fullData = { ...data, [primaryKey]: id };
|
|
718
|
+
|
|
719
|
+
// Optimistic update
|
|
720
|
+
if (resolvedOptions.optimistic !== false) {
|
|
721
|
+
const map = identityMaps.getMap<T>(modelName);
|
|
722
|
+
map.set(id, fullData);
|
|
723
|
+
missingModels.delete(getModelKey(modelName, id));
|
|
724
|
+
emitEvent({
|
|
725
|
+
type: "modelChange",
|
|
726
|
+
modelName,
|
|
727
|
+
modelId: id,
|
|
728
|
+
action: "insert",
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let queuedTx: Transaction | undefined;
|
|
733
|
+
if (outboxManager) {
|
|
734
|
+
queuedTx = await outboxManager.insert(modelName, id, fullData);
|
|
735
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const historyEntry = buildHistoryEntry("I", modelName, id, fullData);
|
|
739
|
+
if (historyEntry) {
|
|
740
|
+
historyEntry.id = queuedTx?.clientTxId;
|
|
741
|
+
pushHistory(historyEntry);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return fullData;
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
async update<T extends Record<string, unknown>>(
|
|
748
|
+
modelName: string,
|
|
749
|
+
id: string,
|
|
750
|
+
changes: Partial<T>,
|
|
751
|
+
mutationOptions?: {
|
|
752
|
+
original?: Record<string, unknown>;
|
|
753
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
754
|
+
}
|
|
755
|
+
): Promise<T> {
|
|
756
|
+
const map = identityMaps.getMap<T>(modelName);
|
|
757
|
+
const existing = map.get(id);
|
|
758
|
+
|
|
759
|
+
if (!existing) {
|
|
760
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const existingData = getModelData(existing);
|
|
764
|
+
const original =
|
|
765
|
+
mutationOptions?.original ??
|
|
766
|
+
pickOriginal(existingData, changes as Record<string, unknown>);
|
|
767
|
+
const updated = { ...existingData, ...changes } as T;
|
|
768
|
+
|
|
769
|
+
// Optimistic update
|
|
770
|
+
if (resolvedOptions.optimistic !== false) {
|
|
771
|
+
map.update(id, changes);
|
|
772
|
+
missingModels.delete(getModelKey(modelName, id));
|
|
773
|
+
emitEvent({
|
|
774
|
+
type: "modelChange",
|
|
775
|
+
modelName,
|
|
776
|
+
modelId: id,
|
|
777
|
+
action: "update",
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let queuedTx: Transaction | undefined;
|
|
782
|
+
if (outboxManager) {
|
|
783
|
+
queuedTx = await outboxManager.update(
|
|
784
|
+
modelName,
|
|
785
|
+
id,
|
|
786
|
+
changes as Record<string, unknown>,
|
|
787
|
+
original
|
|
788
|
+
);
|
|
789
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const historyEntry = buildHistoryEntry(
|
|
793
|
+
"U",
|
|
794
|
+
modelName,
|
|
795
|
+
id,
|
|
796
|
+
changes as Record<string, unknown>,
|
|
797
|
+
original
|
|
798
|
+
);
|
|
799
|
+
if (historyEntry) {
|
|
800
|
+
historyEntry.id = queuedTx?.clientTxId;
|
|
801
|
+
pushHistory(historyEntry);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return updated;
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
async delete(
|
|
808
|
+
modelName: string,
|
|
809
|
+
id: string,
|
|
810
|
+
mutationOptions?: {
|
|
811
|
+
original?: Record<string, unknown>;
|
|
812
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
813
|
+
}
|
|
814
|
+
): Promise<void> {
|
|
815
|
+
const map = identityMaps.getMap(modelName);
|
|
816
|
+
const existing = map.get(id);
|
|
817
|
+
|
|
818
|
+
if (!existing) {
|
|
819
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Optimistic delete
|
|
823
|
+
if (resolvedOptions.optimistic !== false) {
|
|
824
|
+
map.delete(id);
|
|
825
|
+
emitEvent({
|
|
826
|
+
type: "modelChange",
|
|
827
|
+
modelName,
|
|
828
|
+
modelId: id,
|
|
829
|
+
action: "delete",
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const original = mutationOptions?.original ?? getModelData(existing);
|
|
834
|
+
let queuedTx: Transaction | undefined;
|
|
835
|
+
if (outboxManager) {
|
|
836
|
+
queuedTx = await outboxManager.delete(modelName, id, original);
|
|
837
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const historyEntry = buildHistoryEntry("D", modelName, id, {}, original);
|
|
841
|
+
if (historyEntry) {
|
|
842
|
+
historyEntry.id = queuedTx?.clientTxId;
|
|
843
|
+
pushHistory(historyEntry);
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
async archive(
|
|
848
|
+
modelName: string,
|
|
849
|
+
id: string,
|
|
850
|
+
mutationOptions?: {
|
|
851
|
+
original?: Record<string, unknown>;
|
|
852
|
+
archivedAt?: string;
|
|
853
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
854
|
+
}
|
|
855
|
+
): Promise<void> {
|
|
856
|
+
const map = identityMaps.getMap(modelName);
|
|
857
|
+
const existing = map.get(id);
|
|
858
|
+
|
|
859
|
+
if (!existing) {
|
|
860
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const existingData = getModelData(existing);
|
|
864
|
+
const archivedAt =
|
|
865
|
+
mutationOptions?.archivedAt ?? new Date().toISOString();
|
|
866
|
+
const original = mutationOptions?.original ?? {
|
|
867
|
+
archivedAt: existingData.archivedAt,
|
|
868
|
+
};
|
|
869
|
+
const archived = {
|
|
870
|
+
...existingData,
|
|
871
|
+
archivedAt,
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// Optimistic archive
|
|
875
|
+
if (resolvedOptions.optimistic !== false) {
|
|
876
|
+
map.update(id, { archivedAt: archived.archivedAt } as Record<
|
|
877
|
+
string,
|
|
878
|
+
unknown
|
|
879
|
+
>);
|
|
880
|
+
emitEvent({
|
|
881
|
+
type: "modelChange",
|
|
882
|
+
modelName,
|
|
883
|
+
modelId: id,
|
|
884
|
+
action: "archive",
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
let queuedTx: Transaction | undefined;
|
|
889
|
+
if (outboxManager) {
|
|
890
|
+
queuedTx = await outboxManager.archive(modelName, id, {
|
|
891
|
+
original,
|
|
892
|
+
archivedAt,
|
|
893
|
+
});
|
|
894
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const historyEntry = buildHistoryEntry(
|
|
898
|
+
"A",
|
|
899
|
+
modelName,
|
|
900
|
+
id,
|
|
901
|
+
{ archivedAt },
|
|
902
|
+
original
|
|
903
|
+
);
|
|
904
|
+
if (historyEntry) {
|
|
905
|
+
historyEntry.id = queuedTx?.clientTxId;
|
|
906
|
+
pushHistory(historyEntry);
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
|
|
910
|
+
async unarchive(
|
|
911
|
+
modelName: string,
|
|
912
|
+
id: string,
|
|
913
|
+
mutationOptions?: {
|
|
914
|
+
original?: Record<string, unknown>;
|
|
915
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
916
|
+
}
|
|
917
|
+
): Promise<void> {
|
|
918
|
+
const map = identityMaps.getMap(modelName);
|
|
919
|
+
const existing = map.get(id);
|
|
920
|
+
|
|
921
|
+
if (!existing) {
|
|
922
|
+
throw new Error(`Model ${modelName} with id ${id} not found`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const existingData = getModelData(existing);
|
|
926
|
+
const original = mutationOptions?.original ?? {
|
|
927
|
+
archivedAt: existingData.archivedAt,
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// Optimistic unarchive
|
|
931
|
+
if (resolvedOptions.optimistic !== false) {
|
|
932
|
+
map.update(id, { archivedAt: undefined } as Record<string, unknown>);
|
|
933
|
+
emitEvent({
|
|
934
|
+
type: "modelChange",
|
|
935
|
+
modelName,
|
|
936
|
+
modelId: id,
|
|
937
|
+
action: "unarchive",
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let queuedTx: Transaction | undefined;
|
|
942
|
+
if (outboxManager) {
|
|
943
|
+
queuedTx = await outboxManager.unarchive(modelName, id, { original });
|
|
944
|
+
mutationOptions?.onTransactionCreated?.(queuedTx);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const historyEntry = buildHistoryEntry("V", modelName, id, {}, original);
|
|
948
|
+
if (historyEntry) {
|
|
949
|
+
historyEntry.id = queuedTx?.clientTxId;
|
|
950
|
+
pushHistory(historyEntry);
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
canUndo(): boolean {
|
|
955
|
+
return undoStack.length > 0;
|
|
956
|
+
},
|
|
957
|
+
|
|
958
|
+
canRedo(): boolean {
|
|
959
|
+
return redoStack.length > 0;
|
|
960
|
+
},
|
|
961
|
+
|
|
962
|
+
async undo(): Promise<void> {
|
|
963
|
+
const entry = undoStack.pop();
|
|
964
|
+
if (!entry) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
suppressHistory = true;
|
|
969
|
+
try {
|
|
970
|
+
const txId = await applyHistoryOperation(entry.undo);
|
|
971
|
+
entry.id = txId;
|
|
972
|
+
redoStack.push(entry);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
undoStack.push(entry);
|
|
975
|
+
throw error;
|
|
976
|
+
} finally {
|
|
977
|
+
suppressHistory = false;
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
async redo(): Promise<void> {
|
|
982
|
+
const entry = redoStack.pop();
|
|
983
|
+
if (!entry) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
suppressHistory = true;
|
|
988
|
+
try {
|
|
989
|
+
const txId = await applyHistoryOperation(entry.redo);
|
|
990
|
+
entry.id = txId;
|
|
991
|
+
undoStack.push(entry);
|
|
992
|
+
} catch (error) {
|
|
993
|
+
redoStack.push(entry);
|
|
994
|
+
throw error;
|
|
995
|
+
} finally {
|
|
996
|
+
suppressHistory = false;
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
|
|
1000
|
+
onStateChange(callback: (state: SyncClientState) => void): () => void {
|
|
1001
|
+
return orchestrator.onStateChange(callback);
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
onConnectionStateChange(
|
|
1005
|
+
callback: (state: ConnectionState) => void
|
|
1006
|
+
): () => void {
|
|
1007
|
+
return orchestrator.onConnectionStateChange(callback);
|
|
1008
|
+
},
|
|
1009
|
+
|
|
1010
|
+
onEvent(callback: (event: SyncClientEvent) => void): () => void {
|
|
1011
|
+
eventListeners.add(callback);
|
|
1012
|
+
return () => {
|
|
1013
|
+
eventListeners.delete(callback);
|
|
1014
|
+
};
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
getPendingCount(): Promise<number> {
|
|
1018
|
+
if (!outboxManager) {
|
|
1019
|
+
return Promise.resolve(0);
|
|
1020
|
+
}
|
|
1021
|
+
return outboxManager.getPendingCount();
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
async syncNow(): Promise<void> {
|
|
1025
|
+
await orchestrator.syncNow();
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
async clearAll(): Promise<void> {
|
|
1029
|
+
identityMaps.clearAll();
|
|
1030
|
+
await options.storage.clear();
|
|
1031
|
+
pendingLoads.clear();
|
|
1032
|
+
missingModels.clear();
|
|
1033
|
+
undoStack.length = 0;
|
|
1034
|
+
redoStack.length = 0;
|
|
1035
|
+
emitEvent({ type: "outboxChange", pendingCount: 0 });
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
getIdentityMap<T>(modelName: string): Map<string, T> {
|
|
1039
|
+
return identityMaps
|
|
1040
|
+
.getMap<T & Record<string, unknown>>(modelName)
|
|
1041
|
+
.getRawMap() as Map<string, T>;
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
isModelMissing(modelName: string, id: string): boolean {
|
|
1045
|
+
return missingModels.has(getModelKey(modelName, id));
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
clientRef = client;
|
|
1050
|
+
return client;
|
|
1051
|
+
}
|