@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
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionState,
|
|
3
|
+
DeltaPacket,
|
|
4
|
+
RebaseOptions,
|
|
5
|
+
SyncAction,
|
|
6
|
+
SyncClientState,
|
|
7
|
+
Transaction,
|
|
8
|
+
} from "@strata-sync/core";
|
|
9
|
+
import {
|
|
10
|
+
applyDeltas,
|
|
11
|
+
getOrCreateClientId,
|
|
12
|
+
ModelRegistry,
|
|
13
|
+
rebaseTransactions,
|
|
14
|
+
} from "@strata-sync/core";
|
|
15
|
+
import type { IdentityMapRegistry } from "./identity-map";
|
|
16
|
+
import type { OutboxManager } from "./outbox-manager";
|
|
17
|
+
import type {
|
|
18
|
+
StorageAdapter,
|
|
19
|
+
StorageMeta,
|
|
20
|
+
SyncClientEvent,
|
|
21
|
+
SyncClientOptions,
|
|
22
|
+
TransportAdapter,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Orchestrates the sync state machine
|
|
27
|
+
*/
|
|
28
|
+
export class SyncOrchestrator {
|
|
29
|
+
private readonly storage: StorageAdapter;
|
|
30
|
+
private readonly transport: TransportAdapter;
|
|
31
|
+
private readonly identityMaps: IdentityMapRegistry;
|
|
32
|
+
private outboxManager: OutboxManager | null = null;
|
|
33
|
+
private readonly options: SyncClientOptions;
|
|
34
|
+
private readonly registry: ModelRegistry;
|
|
35
|
+
|
|
36
|
+
private _state: SyncClientState = "disconnected";
|
|
37
|
+
private _connectionState: ConnectionState = "disconnected";
|
|
38
|
+
private readonly stateListeners: Set<(state: SyncClientState) => void> =
|
|
39
|
+
new Set();
|
|
40
|
+
private readonly connectionListeners: Set<(state: ConnectionState) => void> =
|
|
41
|
+
new Set();
|
|
42
|
+
|
|
43
|
+
private readonly schemaHash: string;
|
|
44
|
+
private clientId = "";
|
|
45
|
+
private lastSyncId = 0;
|
|
46
|
+
private lastError: Error | null = null;
|
|
47
|
+
private firstSyncId = 0;
|
|
48
|
+
private groups: string[] = [];
|
|
49
|
+
|
|
50
|
+
private deltaSubscription: AsyncIterator<DeltaPacket> | null = null;
|
|
51
|
+
private running = false;
|
|
52
|
+
private readonly emitEvent?: (event: SyncClientEvent) => void;
|
|
53
|
+
private onTransactionConflict?: (tx: Transaction) => void;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
options: SyncClientOptions,
|
|
57
|
+
identityMaps: IdentityMapRegistry,
|
|
58
|
+
emitEvent?: (event: SyncClientEvent) => void
|
|
59
|
+
) {
|
|
60
|
+
this.options = options;
|
|
61
|
+
this.storage = options.storage;
|
|
62
|
+
this.transport = options.transport;
|
|
63
|
+
this.identityMaps = identityMaps;
|
|
64
|
+
this.registry = new ModelRegistry(
|
|
65
|
+
options.schema ?? ModelRegistry.snapshot()
|
|
66
|
+
);
|
|
67
|
+
this.schemaHash = this.registry.getSchemaHash();
|
|
68
|
+
this.groups = options.groups ?? [];
|
|
69
|
+
this.emitEvent = emitEvent;
|
|
70
|
+
|
|
71
|
+
// Listen for transport connection changes
|
|
72
|
+
this.transport.onConnectionStateChange((state) => {
|
|
73
|
+
this.setConnectionState(state);
|
|
74
|
+
this.handleConnectionChange(state);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setOutboxManager(outboxManager: OutboxManager): void {
|
|
79
|
+
this.outboxManager = outboxManager;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setConflictHandler(handler: (tx: Transaction) => void): void {
|
|
83
|
+
this.onTransactionConflict = handler;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Gets the current sync state
|
|
88
|
+
*/
|
|
89
|
+
get state(): SyncClientState {
|
|
90
|
+
return this._state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Gets the current connection state
|
|
95
|
+
*/
|
|
96
|
+
get connectionState(): ConnectionState {
|
|
97
|
+
return this._connectionState;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Gets the client ID
|
|
102
|
+
*/
|
|
103
|
+
getClientId(): string {
|
|
104
|
+
return this.clientId;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gets the last sync ID
|
|
109
|
+
*/
|
|
110
|
+
getLastSyncId(): number {
|
|
111
|
+
return this.lastSyncId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Gets the first sync ID from the last full bootstrap
|
|
116
|
+
*/
|
|
117
|
+
getFirstSyncId(): number {
|
|
118
|
+
return this.firstSyncId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Gets the last error
|
|
123
|
+
*/
|
|
124
|
+
getLastError(): Error | null {
|
|
125
|
+
return this.lastError;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Starts the sync orchestrator
|
|
130
|
+
*/
|
|
131
|
+
async start(): Promise<void> {
|
|
132
|
+
if (this.running) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.running = true;
|
|
136
|
+
|
|
137
|
+
this.emitEvent?.({ type: "syncStart" });
|
|
138
|
+
this.setState("connecting");
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await this.openStorage();
|
|
142
|
+
const meta = await this.loadMetadata();
|
|
143
|
+
await this.configureGroups(meta);
|
|
144
|
+
await this.bootstrapIfNeeded(meta);
|
|
145
|
+
await this.applyPendingOutboxTransactions();
|
|
146
|
+
this.startDeltaSubscription();
|
|
147
|
+
await this.processOutboxTransactions();
|
|
148
|
+
this.setState("syncing");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
this.lastError =
|
|
151
|
+
error instanceof Error ? error : new Error(String(error));
|
|
152
|
+
this.emitEvent?.({ type: "syncError", error: this.lastError });
|
|
153
|
+
this.setState("error");
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async openStorage(): Promise<void> {
|
|
159
|
+
await this.storage.open({
|
|
160
|
+
name: this.options.dbName,
|
|
161
|
+
userId: this.options.userId,
|
|
162
|
+
version: this.options.version,
|
|
163
|
+
userVersion: this.options.userVersion,
|
|
164
|
+
schema: this.options.schema ?? this.registry.snapshot(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async loadMetadata(): Promise<StorageMeta> {
|
|
169
|
+
const meta = await this.storage.getMeta();
|
|
170
|
+
this.clientId =
|
|
171
|
+
meta.clientId ??
|
|
172
|
+
getOrCreateClientId(`${this.options.dbName ?? "sync-db"}_client_id`);
|
|
173
|
+
this.lastSyncId = meta.lastSyncId ?? 0;
|
|
174
|
+
this.firstSyncId = meta.firstSyncId ?? this.lastSyncId;
|
|
175
|
+
return meta;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async configureGroups(meta: StorageMeta): Promise<void> {
|
|
179
|
+
const storedGroups = meta.subscribedSyncGroups ?? [];
|
|
180
|
+
const configuredGroups = this.options.groups ?? storedGroups;
|
|
181
|
+
this.groups = configuredGroups.length > 0 ? configuredGroups : storedGroups;
|
|
182
|
+
|
|
183
|
+
if (this.areGroupsEqual(storedGroups, this.groups)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.firstSyncId = this.lastSyncId;
|
|
188
|
+
await this.storage.setMeta({
|
|
189
|
+
subscribedSyncGroups: this.groups,
|
|
190
|
+
firstSyncId: this.firstSyncId,
|
|
191
|
+
updatedAt: Date.now(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async bootstrapIfNeeded(meta: StorageMeta): Promise<void> {
|
|
196
|
+
const needsBootstrap = await this.shouldBootstrap(meta);
|
|
197
|
+
if (!needsBootstrap) {
|
|
198
|
+
await this.hydrateIdentityMaps();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await this.runBootstrapStrategy();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async shouldBootstrap(meta: StorageMeta): Promise<boolean> {
|
|
206
|
+
const bootstrapModels = this.registry.getBootstrapModelNames();
|
|
207
|
+
const arePersisted = await this.areModelsPersisted(bootstrapModels);
|
|
208
|
+
const hasSchemaMismatch = meta.schemaHash
|
|
209
|
+
? meta.schemaHash !== this.schemaHash
|
|
210
|
+
: false;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
meta.bootstrapComplete === false ||
|
|
214
|
+
hasSchemaMismatch ||
|
|
215
|
+
this.lastSyncId === 0 ||
|
|
216
|
+
!arePersisted
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async runBootstrapStrategy(): Promise<void> {
|
|
221
|
+
const bootstrapMode = this.options.bootstrapMode ?? "auto";
|
|
222
|
+
if (bootstrapMode === "local") {
|
|
223
|
+
await this.localBootstrap();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await this.bootstrap();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const canFallback =
|
|
231
|
+
bootstrapMode === "auto" && (await this.hasLocalData());
|
|
232
|
+
if (canFallback) {
|
|
233
|
+
await this.localBootstrap();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async applyPendingOutboxTransactions(): Promise<void> {
|
|
241
|
+
const pending = await this.getActiveOutboxTransactions();
|
|
242
|
+
await this.applyPendingTransactionsToIdentityMaps(pending);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async processOutboxTransactions(): Promise<void> {
|
|
246
|
+
if (!this.outboxManager) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await this.outboxManager.processPendingTransactions();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Stops the sync orchestrator
|
|
254
|
+
*/
|
|
255
|
+
async stop(): Promise<void> {
|
|
256
|
+
this.running = false;
|
|
257
|
+
|
|
258
|
+
if (this.deltaSubscription) {
|
|
259
|
+
await this.deltaSubscription.return?.();
|
|
260
|
+
this.deltaSubscription = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await this.transport.close();
|
|
264
|
+
await this.storage.close();
|
|
265
|
+
|
|
266
|
+
this.setState("disconnected");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Forces an immediate sync
|
|
271
|
+
*/
|
|
272
|
+
async syncNow(): Promise<void> {
|
|
273
|
+
// Fetch any missed deltas
|
|
274
|
+
const packet = await this.transport.fetchDeltas(this.lastSyncId);
|
|
275
|
+
await this.applyDeltaPacket(packet);
|
|
276
|
+
|
|
277
|
+
// Process pending outbox
|
|
278
|
+
if (this.outboxManager) {
|
|
279
|
+
await this.outboxManager.processPendingTransactions();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Subscribes to state changes
|
|
285
|
+
*/
|
|
286
|
+
onStateChange(callback: (state: SyncClientState) => void): () => void {
|
|
287
|
+
this.stateListeners.add(callback);
|
|
288
|
+
return () => {
|
|
289
|
+
this.stateListeners.delete(callback);
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Subscribes to connection state changes
|
|
295
|
+
*/
|
|
296
|
+
onConnectionStateChange(
|
|
297
|
+
callback: (state: ConnectionState) => void
|
|
298
|
+
): () => void {
|
|
299
|
+
this.connectionListeners.add(callback);
|
|
300
|
+
return () => {
|
|
301
|
+
this.connectionListeners.delete(callback);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Performs initial bootstrap
|
|
307
|
+
*/
|
|
308
|
+
private async bootstrap(): Promise<void> {
|
|
309
|
+
this.setState("bootstrapping");
|
|
310
|
+
|
|
311
|
+
// Clear existing data
|
|
312
|
+
await this.storage.clear();
|
|
313
|
+
this.identityMaps.clearAll();
|
|
314
|
+
|
|
315
|
+
// Stream bootstrap data
|
|
316
|
+
const iterator = this.transport.bootstrap({
|
|
317
|
+
type: "full",
|
|
318
|
+
schemaHash: this.schemaHash,
|
|
319
|
+
onlyModels: this.registry.getBootstrapModelNames(),
|
|
320
|
+
syncGroups: this.groups,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
let databaseVersion: number | undefined;
|
|
324
|
+
|
|
325
|
+
while (true) {
|
|
326
|
+
const { value, done } = await iterator.next();
|
|
327
|
+
if (done) {
|
|
328
|
+
if (!value) {
|
|
329
|
+
throw new Error("Bootstrap completed without metadata");
|
|
330
|
+
}
|
|
331
|
+
const metadata = value;
|
|
332
|
+
if (metadata.lastSyncId === undefined) {
|
|
333
|
+
throw new Error("Bootstrap metadata is missing lastSyncId");
|
|
334
|
+
}
|
|
335
|
+
this.lastSyncId = metadata.lastSyncId;
|
|
336
|
+
this.firstSyncId = metadata.lastSyncId;
|
|
337
|
+
this.groups =
|
|
338
|
+
(metadata.subscribedSyncGroups?.length ?? 0) > 0
|
|
339
|
+
? metadata.subscribedSyncGroups
|
|
340
|
+
: this.groups;
|
|
341
|
+
databaseVersion = metadata.databaseVersion;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const row = value;
|
|
346
|
+
const primaryKey = this.registry.getPrimaryKey(row.modelName);
|
|
347
|
+
const id = row.data[primaryKey] as string;
|
|
348
|
+
if (typeof id !== "string") {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Store in IndexedDB
|
|
352
|
+
await this.storage.put(row.modelName, row.data);
|
|
353
|
+
|
|
354
|
+
// Add to identity map
|
|
355
|
+
const map = this.identityMaps.getMap(row.modelName);
|
|
356
|
+
map.set(id, row.data);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Update metadata + persistence flags
|
|
360
|
+
const bootstrapModels = this.registry.getBootstrapModelNames();
|
|
361
|
+
|
|
362
|
+
for (const modelName of bootstrapModels) {
|
|
363
|
+
await this.storage.setModelPersistence(modelName, true);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await this.storage.setMeta({
|
|
367
|
+
lastSyncId: this.lastSyncId,
|
|
368
|
+
firstSyncId: this.firstSyncId,
|
|
369
|
+
subscribedSyncGroups: this.groups,
|
|
370
|
+
bootstrapComplete: true,
|
|
371
|
+
lastSyncAt: Date.now(),
|
|
372
|
+
schemaHash: this.schemaHash,
|
|
373
|
+
databaseVersion,
|
|
374
|
+
updatedAt: Date.now(),
|
|
375
|
+
});
|
|
376
|
+
this.emitEvent?.({ type: "syncComplete", lastSyncId: this.lastSyncId });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Performs a local-only bootstrap using existing storage data.
|
|
381
|
+
*/
|
|
382
|
+
private async localBootstrap(): Promise<void> {
|
|
383
|
+
this.setState("bootstrapping");
|
|
384
|
+
await this.hydrateIdentityMaps();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Checks whether any hydrated models exist in storage.
|
|
389
|
+
*/
|
|
390
|
+
private async hasLocalData(): Promise<boolean> {
|
|
391
|
+
for (const modelName of this.registry.getHydratedModelNames()) {
|
|
392
|
+
const count = await this.storage.count(modelName);
|
|
393
|
+
if (count > 0) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Loads existing data from storage into identity maps
|
|
402
|
+
*/
|
|
403
|
+
private async hydrateIdentityMaps(): Promise<void> {
|
|
404
|
+
for (const modelName of this.registry.getHydratedModelNames()) {
|
|
405
|
+
const rows =
|
|
406
|
+
await this.storage.getAll<Record<string, unknown>>(modelName);
|
|
407
|
+
const map = this.identityMaps.getMap(modelName);
|
|
408
|
+
const primaryKey = this.registry.getPrimaryKey(modelName);
|
|
409
|
+
|
|
410
|
+
for (const row of rows) {
|
|
411
|
+
const id = row[primaryKey] as string;
|
|
412
|
+
if (typeof id !== "string") {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
map.set(id, row);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async areModelsPersisted(modelNames: string[]): Promise<boolean> {
|
|
421
|
+
for (const modelName of modelNames) {
|
|
422
|
+
const persistence = await this.storage.getModelPersistence(modelName);
|
|
423
|
+
if (!persistence.persisted) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private areGroupsEqual(a: string[], b: string[]): boolean {
|
|
431
|
+
if (a.length !== b.length) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
const setA = new Set(a);
|
|
435
|
+
if (setA.size !== b.length) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
for (const value of b) {
|
|
439
|
+
if (!setA.has(value)) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Starts the delta subscription
|
|
448
|
+
*/
|
|
449
|
+
private startDeltaSubscription(): void {
|
|
450
|
+
const subscription = this.transport.subscribe({
|
|
451
|
+
afterSyncId: this.lastSyncId,
|
|
452
|
+
groups: this.groups,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
this.deltaSubscription = subscription[Symbol.asyncIterator]();
|
|
456
|
+
|
|
457
|
+
// Process deltas in background
|
|
458
|
+
this.processDeltaStream().catch(() => undefined);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Processes the delta stream
|
|
463
|
+
*/
|
|
464
|
+
private async processDeltaStream(): Promise<void> {
|
|
465
|
+
if (!this.deltaSubscription) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
while (this.running) {
|
|
471
|
+
const { value, done } = await this.deltaSubscription.next();
|
|
472
|
+
if (done) {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await this.applyDeltaPacket(value);
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (this.running) {
|
|
480
|
+
this.lastError =
|
|
481
|
+
error instanceof Error ? error : new Error(String(error));
|
|
482
|
+
this.emitEvent?.({ type: "syncError", error: this.lastError });
|
|
483
|
+
// Try to reconnect
|
|
484
|
+
this.setState("error");
|
|
485
|
+
setTimeout(() => {
|
|
486
|
+
if (this.running) {
|
|
487
|
+
this.startDeltaSubscription();
|
|
488
|
+
}
|
|
489
|
+
}, 5000);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Applies a delta packet to local state
|
|
496
|
+
*/
|
|
497
|
+
private async applyDeltaPacket(packet: DeltaPacket): Promise<void> {
|
|
498
|
+
if (packet.actions.length === 0) {
|
|
499
|
+
await this.handleEmptyPacket(packet);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await this.storage.addSyncActions(packet.actions);
|
|
504
|
+
|
|
505
|
+
const activeTransactions = await this.getActiveOutboxTransactions();
|
|
506
|
+
await this.rebasePendingTransactions(activeTransactions, packet.actions);
|
|
507
|
+
await this.handleCoverageActions(packet.actions);
|
|
508
|
+
await this.handleSyncGroupActions(packet.actions, packet.lastSyncId);
|
|
509
|
+
|
|
510
|
+
await this.applyPacketToStorage(packet);
|
|
511
|
+
await this.updateSyncMetadata(packet.lastSyncId);
|
|
512
|
+
await this.finishOutboxProcessing(packet.actions);
|
|
513
|
+
await this.applyPendingOutboxToIdentityMaps();
|
|
514
|
+
this.emitModelChangeEvents(packet.actions);
|
|
515
|
+
await this.emitOutboxCount();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private async handleEmptyPacket(packet: DeltaPacket): Promise<void> {
|
|
519
|
+
if (packet.lastSyncId > this.lastSyncId) {
|
|
520
|
+
await this.updateSyncMetadata(packet.lastSyncId);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private async getActiveOutboxTransactions(): Promise<Transaction[]> {
|
|
525
|
+
const outbox = await this.storage.getOutbox();
|
|
526
|
+
return outbox.filter(
|
|
527
|
+
(tx) =>
|
|
528
|
+
tx.state === "queued" ||
|
|
529
|
+
tx.state === "sent" ||
|
|
530
|
+
tx.state === "awaitingSync"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private createDeltaTarget() {
|
|
535
|
+
return {
|
|
536
|
+
put: async (
|
|
537
|
+
modelName: string,
|
|
538
|
+
id: string,
|
|
539
|
+
data: Record<string, unknown>
|
|
540
|
+
) => {
|
|
541
|
+
await this.storage.put(modelName, data);
|
|
542
|
+
const map = this.identityMaps.getMap(modelName);
|
|
543
|
+
map.merge(id, data);
|
|
544
|
+
},
|
|
545
|
+
delete: async (modelName: string, id: string) => {
|
|
546
|
+
await this.storage.delete(modelName, id);
|
|
547
|
+
const map = this.identityMaps.getMap(modelName);
|
|
548
|
+
map.delete(id);
|
|
549
|
+
},
|
|
550
|
+
patch: async (
|
|
551
|
+
modelName: string,
|
|
552
|
+
id: string,
|
|
553
|
+
changes: Record<string, unknown>
|
|
554
|
+
) => {
|
|
555
|
+
const existing = await this.storage.get<Record<string, unknown>>(
|
|
556
|
+
modelName,
|
|
557
|
+
id
|
|
558
|
+
);
|
|
559
|
+
if (!existing) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const updated = { ...existing, ...changes };
|
|
563
|
+
await this.storage.put(modelName, updated);
|
|
564
|
+
const map = this.identityMaps.getMap(modelName);
|
|
565
|
+
map.merge(id, updated);
|
|
566
|
+
},
|
|
567
|
+
get: (modelName: string, id: string) => {
|
|
568
|
+
return this.storage.get<Record<string, unknown>>(modelName, id);
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async applyPacketToStorage(packet: DeltaPacket): Promise<void> {
|
|
574
|
+
const deltaTarget = this.createDeltaTarget();
|
|
575
|
+
await applyDeltas(packet, deltaTarget, this.registry);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async updateSyncMetadata(lastSyncId: number): Promise<void> {
|
|
579
|
+
this.lastSyncId = lastSyncId;
|
|
580
|
+
await this.storage.setMeta({
|
|
581
|
+
lastSyncId: this.lastSyncId,
|
|
582
|
+
lastSyncAt: Date.now(),
|
|
583
|
+
updatedAt: Date.now(),
|
|
584
|
+
});
|
|
585
|
+
this.emitEvent?.({ type: "syncComplete", lastSyncId: this.lastSyncId });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async finishOutboxProcessing(actions: SyncAction[]): Promise<void> {
|
|
589
|
+
await this.removeConfirmedTransactions(actions);
|
|
590
|
+
await this.removeRedundantCreateTransactions(actions);
|
|
591
|
+
if (this.outboxManager) {
|
|
592
|
+
await this.outboxManager.completeUpToSyncId(this.lastSyncId);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async applyPendingOutboxToIdentityMaps(): Promise<void> {
|
|
597
|
+
const pending = await this.getActiveOutboxTransactions();
|
|
598
|
+
this.applyPendingTransactionsToIdentityMaps(pending);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private resolveModelChangeAction(
|
|
602
|
+
action: SyncAction["action"]
|
|
603
|
+
): "insert" | "update" | "delete" | "archive" | "unarchive" | null {
|
|
604
|
+
switch (action) {
|
|
605
|
+
case "I":
|
|
606
|
+
return "insert";
|
|
607
|
+
case "U":
|
|
608
|
+
return "update";
|
|
609
|
+
case "D":
|
|
610
|
+
return "delete";
|
|
611
|
+
case "A":
|
|
612
|
+
return "archive";
|
|
613
|
+
case "V":
|
|
614
|
+
return "unarchive";
|
|
615
|
+
default:
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private emitModelChangeEvents(actions: SyncAction[]): void {
|
|
621
|
+
for (const action of actions) {
|
|
622
|
+
const eventAction = this.resolveModelChangeAction(action.action);
|
|
623
|
+
if (!eventAction) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
this.emitEvent?.({
|
|
627
|
+
type: "modelChange",
|
|
628
|
+
modelName: action.modelName,
|
|
629
|
+
modelId: action.modelId,
|
|
630
|
+
action: eventAction,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private async emitOutboxCount(): Promise<void> {
|
|
636
|
+
if (!this.emitEvent) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const pendingCount = (await this.getActiveOutboxTransactions()).length;
|
|
640
|
+
this.emitEvent({ type: "outboxChange", pendingCount });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async rebasePendingTransactions(
|
|
644
|
+
pending: Transaction[],
|
|
645
|
+
actions: SyncAction[]
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
if (pending.length === 0) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const rebaseOptions: RebaseOptions = {
|
|
652
|
+
clientId: this.clientId,
|
|
653
|
+
defaultResolution: this.options.rebaseStrategy ?? "server-wins",
|
|
654
|
+
fieldLevelConflicts: this.options.fieldLevelConflicts ?? true,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const result = rebaseTransactions(pending, actions, rebaseOptions);
|
|
658
|
+
|
|
659
|
+
for (const conflict of result.conflicts) {
|
|
660
|
+
await this.handleConflict(conflict);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await this.updatePendingOriginals(result.pending, actions);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async handleConflict(
|
|
667
|
+
conflict: import("@strata-sync/core").RebaseConflict
|
|
668
|
+
): Promise<void> {
|
|
669
|
+
const { localTransaction: tx } = conflict;
|
|
670
|
+
const resolution =
|
|
671
|
+
conflict.resolution === "manual" ? "server-wins" : conflict.resolution;
|
|
672
|
+
|
|
673
|
+
if (resolution === "server-wins") {
|
|
674
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
675
|
+
this.onTransactionConflict?.(tx);
|
|
676
|
+
} else if (
|
|
677
|
+
(resolution === "client-wins" || resolution === "merge") &&
|
|
678
|
+
(tx.action === "U" || tx.action === "A" || tx.action === "V")
|
|
679
|
+
) {
|
|
680
|
+
const updatedOriginal = {
|
|
681
|
+
...(tx.original ?? {}),
|
|
682
|
+
...conflict.serverAction.data,
|
|
683
|
+
};
|
|
684
|
+
tx.original = updatedOriginal;
|
|
685
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
686
|
+
original: updatedOriginal,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.emitEvent?.({
|
|
691
|
+
type: "rebaseConflict",
|
|
692
|
+
modelName: tx.modelName,
|
|
693
|
+
modelId: tx.modelId,
|
|
694
|
+
conflictType: conflict.conflictType,
|
|
695
|
+
resolution,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private async updatePendingOriginals(
|
|
700
|
+
pending: Transaction[],
|
|
701
|
+
actions: SyncAction[]
|
|
702
|
+
): Promise<void> {
|
|
703
|
+
const actionsByKey = this.buildActionsByKey(actions);
|
|
704
|
+
|
|
705
|
+
for (const tx of pending) {
|
|
706
|
+
if (tx.action !== "U" && tx.action !== "A" && tx.action !== "V") {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const key = `${tx.modelName}:${tx.modelId}`;
|
|
710
|
+
const related = actionsByKey.get(key);
|
|
711
|
+
if (!related) {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const updatedOriginal = this.getUpdatedOriginal(tx, related);
|
|
715
|
+
if (!updatedOriginal) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
tx.original = updatedOriginal;
|
|
719
|
+
await this.storage.updateOutboxTransaction(tx.clientTxId, {
|
|
720
|
+
original: updatedOriginal,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private buildActionsByKey(actions: SyncAction[]): Map<string, SyncAction[]> {
|
|
726
|
+
const actionsByKey = new Map<string, SyncAction[]>();
|
|
727
|
+
for (const action of actions) {
|
|
728
|
+
const key = `${action.modelName}:${action.modelId}`;
|
|
729
|
+
const existing = actionsByKey.get(key) ?? [];
|
|
730
|
+
existing.push(action);
|
|
731
|
+
actionsByKey.set(key, existing);
|
|
732
|
+
}
|
|
733
|
+
return actionsByKey;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private shouldRebaseAction(action: SyncAction["action"]): boolean {
|
|
737
|
+
return action === "U" || action === "I" || action === "V" || action === "C";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private getUpdatedOriginal(
|
|
741
|
+
tx: Transaction,
|
|
742
|
+
related: SyncAction[]
|
|
743
|
+
): Record<string, unknown> | null {
|
|
744
|
+
const original = { ...(tx.original ?? {}) };
|
|
745
|
+
let updated = false;
|
|
746
|
+
|
|
747
|
+
for (const action of related) {
|
|
748
|
+
if (!this.shouldRebaseAction(action.action)) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
for (const field of Object.keys(tx.payload)) {
|
|
752
|
+
if (field in action.data) {
|
|
753
|
+
original[field] = action.data[field];
|
|
754
|
+
updated = true;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return updated ? original : null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private applyPendingTransactionsToIdentityMaps(pending: Transaction[]): void {
|
|
763
|
+
if (pending.length === 0) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const tx of pending) {
|
|
768
|
+
const map = this.identityMaps.getMap<Record<string, unknown>>(
|
|
769
|
+
tx.modelName
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
switch (tx.action) {
|
|
773
|
+
case "I":
|
|
774
|
+
case "U":
|
|
775
|
+
map.merge(tx.modelId, tx.payload);
|
|
776
|
+
break;
|
|
777
|
+
case "D":
|
|
778
|
+
map.delete(tx.modelId);
|
|
779
|
+
break;
|
|
780
|
+
case "A": {
|
|
781
|
+
const archivedAt =
|
|
782
|
+
(tx.payload as Record<string, unknown>).archivedAt ??
|
|
783
|
+
new Date().toISOString();
|
|
784
|
+
map.merge(tx.modelId, { archivedAt });
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
case "V": {
|
|
788
|
+
const existing = map.get(tx.modelId);
|
|
789
|
+
if (existing) {
|
|
790
|
+
const updated = { ...existing };
|
|
791
|
+
(updated as Record<string, unknown>).archivedAt = undefined;
|
|
792
|
+
map.set(tx.modelId, updated);
|
|
793
|
+
}
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
default:
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private async removeConfirmedTransactions(
|
|
803
|
+
actions: SyncAction[]
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
const clientTxIds = actions
|
|
806
|
+
.map((action) => action.clientTxId)
|
|
807
|
+
.filter((value): value is string => typeof value === "string");
|
|
808
|
+
|
|
809
|
+
if (clientTxIds.length === 0) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const outbox = await this.storage.getOutbox();
|
|
814
|
+
const known = new Set(outbox.map((tx) => tx.clientTxId));
|
|
815
|
+
for (const clientTxId of clientTxIds) {
|
|
816
|
+
if (known.has(clientTxId)) {
|
|
817
|
+
await this.storage.removeFromOutbox(clientTxId);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private async removeRedundantCreateTransactions(
|
|
823
|
+
actions: SyncAction[]
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
const createdIds = actions
|
|
826
|
+
.filter((action) => action.action === "I")
|
|
827
|
+
.map((action) => action.modelId);
|
|
828
|
+
|
|
829
|
+
if (createdIds.length === 0) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const createdSet = new Set(createdIds);
|
|
834
|
+
const outbox = await this.storage.getOutbox();
|
|
835
|
+
|
|
836
|
+
for (const tx of outbox) {
|
|
837
|
+
if (tx.action === "I" && createdSet.has(tx.modelId)) {
|
|
838
|
+
await this.storage.removeFromOutbox(tx.clientTxId);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private async handleCoverageActions(actions: SyncAction[]): Promise<void> {
|
|
844
|
+
for (const action of actions) {
|
|
845
|
+
if (action.action !== "C") {
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const indexedKey = (action.data as Record<string, unknown>).indexedKey;
|
|
849
|
+
const keyValue = (action.data as Record<string, unknown>).keyValue;
|
|
850
|
+
if (typeof indexedKey === "string" && typeof keyValue === "string") {
|
|
851
|
+
await this.storage.setPartialIndex(
|
|
852
|
+
action.modelName,
|
|
853
|
+
indexedKey,
|
|
854
|
+
keyValue
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private async handleSyncGroupActions(
|
|
861
|
+
actions: SyncAction[],
|
|
862
|
+
nextSyncId: number
|
|
863
|
+
): Promise<void> {
|
|
864
|
+
const groupUpdates: string[][] = [];
|
|
865
|
+
for (const action of actions) {
|
|
866
|
+
if (action.action !== "G" && action.action !== "S") {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const data = action.data as Record<string, unknown>;
|
|
870
|
+
const groups = data.subscribedSyncGroups;
|
|
871
|
+
if (Array.isArray(groups)) {
|
|
872
|
+
const filtered = groups.filter(
|
|
873
|
+
(group): group is string => typeof group === "string"
|
|
874
|
+
);
|
|
875
|
+
if (filtered.length > 0) {
|
|
876
|
+
groupUpdates.push(filtered);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (groupUpdates.length === 0) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const nextGroups = groupUpdates.at(-1);
|
|
886
|
+
if (!nextGroups || this.areGroupsEqual(this.groups, nextGroups)) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const currentSet = new Set(this.groups);
|
|
891
|
+
const nextSet = new Set(nextGroups);
|
|
892
|
+
const addedGroups = nextGroups.filter((group) => !currentSet.has(group));
|
|
893
|
+
const removedGroups = this.groups.filter((group) => !nextSet.has(group));
|
|
894
|
+
|
|
895
|
+
if (addedGroups.length > 0) {
|
|
896
|
+
await this.bootstrapSyncGroups(addedGroups, nextSyncId);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (removedGroups.length > 0) {
|
|
900
|
+
await this.removeSyncGroupData(removedGroups);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
this.groups = nextGroups;
|
|
904
|
+
this.firstSyncId = nextSyncId;
|
|
905
|
+
await this.storage.setMeta({
|
|
906
|
+
subscribedSyncGroups: this.groups,
|
|
907
|
+
firstSyncId: this.firstSyncId,
|
|
908
|
+
updatedAt: Date.now(),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private async bootstrapSyncGroups(
|
|
913
|
+
groups: string[],
|
|
914
|
+
firstSyncId: number
|
|
915
|
+
): Promise<void> {
|
|
916
|
+
const iterator = this.transport.bootstrap({
|
|
917
|
+
type: "partial",
|
|
918
|
+
schemaHash: this.schemaHash,
|
|
919
|
+
firstSyncId,
|
|
920
|
+
syncGroups: groups,
|
|
921
|
+
noSyncPackets: true,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const hydrated = new Set(this.registry.getHydratedModelNames());
|
|
925
|
+
|
|
926
|
+
while (true) {
|
|
927
|
+
const { value, done } = await iterator.next();
|
|
928
|
+
if (done) {
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const row = value;
|
|
933
|
+
const primaryKey = this.registry.getPrimaryKey(row.modelName);
|
|
934
|
+
const id = row.data[primaryKey] as string;
|
|
935
|
+
if (typeof id !== "string") {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
await this.storage.put(row.modelName, row.data);
|
|
939
|
+
|
|
940
|
+
if (hydrated.has(row.modelName)) {
|
|
941
|
+
const map = this.identityMaps.getMap(row.modelName);
|
|
942
|
+
map.merge(id, row.data);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private async removeSyncGroupData(groups: string[]): Promise<void> {
|
|
948
|
+
if (groups.length === 0) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
for (const model of this.registry.getAllModels()) {
|
|
953
|
+
const modelName = model.name ?? "";
|
|
954
|
+
const groupKey = model.groupKey;
|
|
955
|
+
if (!(modelName && groupKey)) {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const primaryKey = model.primaryKey ?? "id";
|
|
960
|
+
const map = this.identityMaps.getMap<Record<string, unknown>>(modelName);
|
|
961
|
+
|
|
962
|
+
for (const group of groups) {
|
|
963
|
+
const rows = await this.storage.getByIndex<Record<string, unknown>>(
|
|
964
|
+
modelName,
|
|
965
|
+
groupKey,
|
|
966
|
+
group
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
for (const row of rows) {
|
|
970
|
+
const id = row[primaryKey] as string;
|
|
971
|
+
if (typeof id !== "string") {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
await this.storage.delete(modelName, id);
|
|
975
|
+
map.delete(id);
|
|
976
|
+
this.emitEvent?.({
|
|
977
|
+
type: "modelChange",
|
|
978
|
+
modelName,
|
|
979
|
+
modelId: id,
|
|
980
|
+
action: "delete",
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Handles connection state changes
|
|
989
|
+
*/
|
|
990
|
+
private handleConnectionChange(state: ConnectionState): void {
|
|
991
|
+
if (state === "connected" && this._state === "error") {
|
|
992
|
+
// Reconnected - catch up on missed deltas
|
|
993
|
+
this.syncNow().catch(() => {
|
|
994
|
+
// Ignore errors, will retry
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Sets the sync state
|
|
1001
|
+
*/
|
|
1002
|
+
private setState(state: SyncClientState): void {
|
|
1003
|
+
if (this._state !== state) {
|
|
1004
|
+
this._state = state;
|
|
1005
|
+
if (state !== "error") {
|
|
1006
|
+
this.lastError = null;
|
|
1007
|
+
}
|
|
1008
|
+
this.emitEvent?.({ type: "stateChange", state });
|
|
1009
|
+
for (const listener of this.stateListeners) {
|
|
1010
|
+
listener(state);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Sets the connection state
|
|
1017
|
+
*/
|
|
1018
|
+
private setConnectionState(state: ConnectionState): void {
|
|
1019
|
+
if (this._connectionState !== state) {
|
|
1020
|
+
this._connectionState = state;
|
|
1021
|
+
this.emitEvent?.({ type: "connectionChange", state });
|
|
1022
|
+
for (const listener of this.connectionListeners) {
|
|
1023
|
+
listener(state);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Gets the model registry
|
|
1030
|
+
*/
|
|
1031
|
+
getRegistry(): ModelRegistry {
|
|
1032
|
+
return this.registry;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Gets the storage adapter
|
|
1037
|
+
*/
|
|
1038
|
+
getStorage(): StorageAdapter {
|
|
1039
|
+
return this.storage;
|
|
1040
|
+
}
|
|
1041
|
+
}
|