@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.
@@ -0,0 +1,701 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type BatchLoadOptions,
4
+ type BootstrapMetadata,
5
+ type BootstrapOptions,
6
+ type ConnectionState,
7
+ type DeltaPacket,
8
+ type DeltaSubscription,
9
+ type ModelRegistrySnapshot,
10
+ type ModelRow,
11
+ type MutateResult,
12
+ noopReactivityAdapter,
13
+ type SchemaDefinition,
14
+ type SubscribeOptions,
15
+ type SyncAction,
16
+ type Transaction,
17
+ type TransactionBatch,
18
+ } from "../../sync-core/src/index";
19
+ import { createSyncClient } from "../src/index";
20
+ import type {
21
+ ModelPersistenceMeta,
22
+ StorageAdapter,
23
+ StorageMeta,
24
+ TransportAdapter,
25
+ } from "../src/types";
26
+
27
+ class InMemoryStorage implements StorageAdapter {
28
+ private readonly data = new Map<
29
+ string,
30
+ Map<string, Record<string, unknown>>
31
+ >();
32
+ private meta: StorageMeta = { lastSyncId: 0 };
33
+ private readonly modelPersistence = new Map<string, boolean>();
34
+ private readonly outbox: Transaction[] = [];
35
+ private readonly partialIndexes = new Set<string>();
36
+ private readonly syncActions: SyncAction[] = [];
37
+
38
+ open(_options: {
39
+ name?: string;
40
+ userId?: string;
41
+ version?: number;
42
+ userVersion?: number;
43
+ schema?: SchemaDefinition | ModelRegistrySnapshot;
44
+ }): Promise<void> {
45
+ return Promise.resolve();
46
+ }
47
+
48
+ close(): Promise<void> {
49
+ return Promise.resolve();
50
+ }
51
+
52
+ private getModelStore(
53
+ modelName: string
54
+ ): Map<string, Record<string, unknown>> {
55
+ const existing = this.data.get(modelName);
56
+ if (existing) {
57
+ return existing;
58
+ }
59
+ const created = new Map<string, Record<string, unknown>>();
60
+ this.data.set(modelName, created);
61
+ return created;
62
+ }
63
+
64
+ get<T>(modelName: string, id: string): Promise<T | null> {
65
+ const store = this.data.get(modelName);
66
+ if (!store) {
67
+ return Promise.resolve(null);
68
+ }
69
+ return Promise.resolve((store.get(id) as T | undefined) ?? null);
70
+ }
71
+
72
+ getAll<T>(modelName: string): Promise<T[]> {
73
+ const store = this.data.get(modelName);
74
+ if (!store) {
75
+ return Promise.resolve([]);
76
+ }
77
+ return Promise.resolve(Array.from(store.values()) as T[]);
78
+ }
79
+
80
+ put<T extends Record<string, unknown>>(
81
+ modelName: string,
82
+ row: T
83
+ ): Promise<void> {
84
+ const id = row.id;
85
+ if (typeof id !== "string") {
86
+ throw new Error(`Missing id for model ${modelName}`);
87
+ }
88
+ const store = this.getModelStore(modelName);
89
+ store.set(id, { ...row });
90
+ return Promise.resolve();
91
+ }
92
+
93
+ delete(modelName: string, id: string): Promise<void> {
94
+ this.data.get(modelName)?.delete(id);
95
+ return Promise.resolve();
96
+ }
97
+
98
+ getByIndex<T>(
99
+ modelName: string,
100
+ indexName: string,
101
+ key: string
102
+ ): Promise<T[]> {
103
+ const store = this.data.get(modelName);
104
+ if (!store) {
105
+ return Promise.resolve([]);
106
+ }
107
+ const results: T[] = [];
108
+ for (const row of store.values()) {
109
+ if (row[indexName] === key) {
110
+ results.push(row as T);
111
+ }
112
+ }
113
+ return Promise.resolve(results);
114
+ }
115
+
116
+ async writeBatch(
117
+ ops: Array<{
118
+ type: "put" | "delete";
119
+ modelName: string;
120
+ id?: string;
121
+ data?: Record<string, unknown>;
122
+ }>
123
+ ): Promise<void> {
124
+ for (const op of ops) {
125
+ if (op.type === "put" && op.data) {
126
+ await this.put(op.modelName, op.data);
127
+ continue;
128
+ }
129
+ if (op.type === "delete" && op.id) {
130
+ await this.delete(op.modelName, op.id);
131
+ }
132
+ }
133
+ }
134
+
135
+ getMeta(): Promise<StorageMeta> {
136
+ const subscribedSyncGroups = this.meta.subscribedSyncGroups;
137
+ return Promise.resolve({
138
+ ...this.meta,
139
+ subscribedSyncGroups: Array.isArray(subscribedSyncGroups)
140
+ ? [...subscribedSyncGroups]
141
+ : undefined,
142
+ });
143
+ }
144
+
145
+ setMeta(meta: Partial<StorageMeta>): Promise<void> {
146
+ const subscribedSyncGroups = meta.subscribedSyncGroups;
147
+ this.meta = {
148
+ ...this.meta,
149
+ ...meta,
150
+ subscribedSyncGroups: Array.isArray(subscribedSyncGroups)
151
+ ? [...subscribedSyncGroups]
152
+ : this.meta.subscribedSyncGroups,
153
+ };
154
+ return Promise.resolve();
155
+ }
156
+
157
+ getModelPersistence(modelName: string): Promise<ModelPersistenceMeta> {
158
+ return Promise.resolve({
159
+ modelName,
160
+ persisted: this.modelPersistence.get(modelName) ?? false,
161
+ });
162
+ }
163
+
164
+ setModelPersistence(modelName: string, persisted: boolean): Promise<void> {
165
+ this.modelPersistence.set(modelName, persisted);
166
+ return Promise.resolve();
167
+ }
168
+
169
+ getOutbox(): Promise<Transaction[]> {
170
+ return Promise.resolve([...this.outbox]);
171
+ }
172
+
173
+ addToOutbox(tx: Transaction): Promise<void> {
174
+ this.outbox.push(tx);
175
+ return Promise.resolve();
176
+ }
177
+
178
+ removeFromOutbox(clientTxId: string): Promise<void> {
179
+ const index = this.outbox.findIndex((tx) => tx.clientTxId === clientTxId);
180
+ if (index >= 0) {
181
+ this.outbox.splice(index, 1);
182
+ }
183
+ return Promise.resolve();
184
+ }
185
+
186
+ updateOutboxTransaction(
187
+ clientTxId: string,
188
+ updates: Partial<Transaction>
189
+ ): Promise<void> {
190
+ const tx = this.outbox.find((entry) => entry.clientTxId === clientTxId);
191
+ if (tx) {
192
+ Object.assign(tx, updates);
193
+ }
194
+ return Promise.resolve();
195
+ }
196
+
197
+ hasPartialIndex(
198
+ modelName: string,
199
+ indexedKey: string,
200
+ keyValue: string
201
+ ): Promise<boolean> {
202
+ return Promise.resolve(
203
+ this.partialIndexes.has(`${modelName}:${indexedKey}:${keyValue}`)
204
+ );
205
+ }
206
+
207
+ setPartialIndex(
208
+ modelName: string,
209
+ indexedKey: string,
210
+ keyValue: string
211
+ ): Promise<void> {
212
+ this.partialIndexes.add(`${modelName}:${indexedKey}:${keyValue}`);
213
+ return Promise.resolve();
214
+ }
215
+
216
+ addSyncActions(actions: SyncAction[]): Promise<void> {
217
+ this.syncActions.push(...actions);
218
+ return Promise.resolve();
219
+ }
220
+
221
+ getSyncActions(afterSyncId?: number, limit?: number): Promise<SyncAction[]> {
222
+ const filtered = afterSyncId
223
+ ? this.syncActions.filter((action) => action.id > afterSyncId)
224
+ : [...this.syncActions];
225
+ if (typeof limit === "number") {
226
+ return Promise.resolve(filtered.slice(0, limit));
227
+ }
228
+ return Promise.resolve(filtered);
229
+ }
230
+
231
+ clearSyncActions(): Promise<void> {
232
+ this.syncActions.length = 0;
233
+ return Promise.resolve();
234
+ }
235
+
236
+ clear(): Promise<void> {
237
+ this.data.clear();
238
+ this.modelPersistence.clear();
239
+ this.outbox.length = 0;
240
+ this.partialIndexes.clear();
241
+ this.syncActions.length = 0;
242
+ this.meta = { lastSyncId: 0 };
243
+ return Promise.resolve();
244
+ }
245
+
246
+ count(modelName: string): Promise<number> {
247
+ return Promise.resolve(this.data.get(modelName)?.size ?? 0);
248
+ }
249
+ }
250
+
251
+ class AsyncQueue<T> implements AsyncIterable<T> {
252
+ private readonly items: T[] = [];
253
+ private readonly resolvers: Array<(result: IteratorResult<T>) => void> = [];
254
+ private closed = false;
255
+
256
+ push(item: T): void {
257
+ if (this.closed) {
258
+ return;
259
+ }
260
+ const resolver = this.resolvers.shift();
261
+ if (resolver) {
262
+ resolver({ value: item, done: false });
263
+ return;
264
+ }
265
+ this.items.push(item);
266
+ }
267
+
268
+ close(): void {
269
+ this.closed = true;
270
+ for (const resolver of this.resolvers.splice(0)) {
271
+ resolver({ value: undefined as T, done: true });
272
+ }
273
+ }
274
+
275
+ [Symbol.asyncIterator](): AsyncIterator<T> {
276
+ return {
277
+ next: (): Promise<IteratorResult<T>> => {
278
+ const item = this.items.shift();
279
+ if (item !== undefined) {
280
+ return Promise.resolve({ value: item, done: false });
281
+ }
282
+ if (this.closed) {
283
+ return Promise.resolve({ value: undefined as T, done: true });
284
+ }
285
+ return new Promise((resolve) => {
286
+ this.resolvers.push(resolve);
287
+ });
288
+ },
289
+ return: (): Promise<IteratorResult<T>> => {
290
+ this.close();
291
+ return Promise.resolve({ value: undefined as T, done: true });
292
+ },
293
+ };
294
+ }
295
+ }
296
+
297
+ class TestTransport implements TransportAdapter {
298
+ private readonly deltaQueue = new AsyncQueue<DeltaPacket>();
299
+ private readonly fullRows: ModelRow[];
300
+ private readonly fullMetadata: BootstrapMetadata;
301
+ private readonly partialRowsByGroup: Map<string, ModelRow[]>;
302
+ private readonly connectionListeners = new Set<
303
+ (state: ConnectionState) => void
304
+ >();
305
+ private readonly connectionState: ConnectionState = "connected";
306
+ private nextSyncId: number;
307
+
308
+ readonly bootstrapCalls: BootstrapOptions[] = [];
309
+
310
+ constructor(options: {
311
+ fullRows: ModelRow[];
312
+ fullMetadata: BootstrapMetadata;
313
+ partialRowsByGroup?: Map<string, ModelRow[]>;
314
+ startingSyncId?: number;
315
+ }) {
316
+ this.fullRows = options.fullRows;
317
+ this.fullMetadata = options.fullMetadata;
318
+ this.partialRowsByGroup = options.partialRowsByGroup ?? new Map();
319
+ this.nextSyncId = options.startingSyncId ?? 100;
320
+ }
321
+
322
+ bootstrap(
323
+ options: BootstrapOptions
324
+ ): AsyncGenerator<ModelRow, BootstrapMetadata, unknown> {
325
+ this.bootstrapCalls.push(options);
326
+ const isPartial = options.type === "partial";
327
+ const rows = isPartial
328
+ ? this.getPartialRows(options.syncGroups ?? [])
329
+ : this.fullRows;
330
+ const metadata = isPartial
331
+ ? { subscribedSyncGroups: options.syncGroups ?? [] }
332
+ : this.fullMetadata;
333
+
334
+ return (async function* generate() {
335
+ await Promise.resolve();
336
+ for (const row of rows) {
337
+ yield row;
338
+ }
339
+ return metadata;
340
+ })();
341
+ }
342
+
343
+ private getPartialRows(groups: string[]): ModelRow[] {
344
+ const rows: ModelRow[] = [];
345
+ for (const group of groups) {
346
+ const groupRows = this.partialRowsByGroup.get(group);
347
+ if (groupRows) {
348
+ rows.push(...groupRows);
349
+ }
350
+ }
351
+ return rows;
352
+ }
353
+
354
+ batchLoad(
355
+ _options: BatchLoadOptions
356
+ ): AsyncGenerator<ModelRow, void, unknown> {
357
+ return (async function* () {
358
+ await Promise.resolve();
359
+ yield* [];
360
+ })();
361
+ }
362
+
363
+ mutate(batch: TransactionBatch): Promise<MutateResult> {
364
+ const results = batch.transactions.map((tx) => {
365
+ this.nextSyncId += 1;
366
+ return {
367
+ clientTxId: tx.clientTxId,
368
+ success: true,
369
+ syncId: this.nextSyncId,
370
+ };
371
+ });
372
+
373
+ return Promise.resolve({
374
+ success: true,
375
+ lastSyncId: this.nextSyncId,
376
+ results,
377
+ });
378
+ }
379
+
380
+ subscribe(_options: SubscribeOptions): DeltaSubscription {
381
+ return {
382
+ [Symbol.asyncIterator]: () => this.deltaQueue[Symbol.asyncIterator](),
383
+ unsubscribe: () => this.deltaQueue.close(),
384
+ };
385
+ }
386
+
387
+ emitDelta(packet: DeltaPacket): void {
388
+ this.deltaQueue.push(packet);
389
+ }
390
+
391
+ fetchDeltas(after: number, _limit?: number): Promise<DeltaPacket> {
392
+ return Promise.resolve({ lastSyncId: after, actions: [] });
393
+ }
394
+
395
+ getConnectionState(): ConnectionState {
396
+ return this.connectionState;
397
+ }
398
+
399
+ onConnectionStateChange(
400
+ callback: (state: ConnectionState) => void
401
+ ): () => void {
402
+ this.connectionListeners.add(callback);
403
+ callback(this.connectionState);
404
+ return () => {
405
+ this.connectionListeners.delete(callback);
406
+ };
407
+ }
408
+
409
+ close(): Promise<void> {
410
+ this.deltaQueue.close();
411
+ return Promise.resolve();
412
+ }
413
+ }
414
+
415
+ const schema: SchemaDefinition = {
416
+ models: {
417
+ Issue: {
418
+ loadStrategy: "instant",
419
+ groupKey: "teamId",
420
+ fields: {
421
+ id: {},
422
+ title: {},
423
+ teamId: {},
424
+ },
425
+ },
426
+ Team: {
427
+ loadStrategy: "instant",
428
+ fields: {
429
+ id: {},
430
+ name: {},
431
+ },
432
+ },
433
+ },
434
+ };
435
+
436
+ const waitForSync = async (
437
+ client: ReturnType<typeof createSyncClient>,
438
+ expectedSyncId: number
439
+ ): Promise<void> => {
440
+ await new Promise<void>((resolve, reject) => {
441
+ const timeout = setTimeout(() => {
442
+ reject(new Error("Timed out waiting for sync completion"));
443
+ }, 2000);
444
+
445
+ const unsubscribe = client.onEvent((event) => {
446
+ if (
447
+ event.type === "syncComplete" &&
448
+ event.lastSyncId === expectedSyncId
449
+ ) {
450
+ clearTimeout(timeout);
451
+ unsubscribe();
452
+ resolve();
453
+ }
454
+ });
455
+ });
456
+ };
457
+
458
+ const waitForOutboxCount = async (
459
+ client: ReturnType<typeof createSyncClient>,
460
+ expectedCount: number
461
+ ): Promise<void> => {
462
+ await new Promise<void>((resolve, reject) => {
463
+ const timeout = setTimeout(() => {
464
+ reject(new Error("Timed out waiting for outbox count"));
465
+ }, 2000);
466
+
467
+ const unsubscribe = client.onEvent((event) => {
468
+ if (
469
+ event.type === "outboxChange" &&
470
+ event.pendingCount === expectedCount
471
+ ) {
472
+ clearTimeout(timeout);
473
+ unsubscribe();
474
+ resolve();
475
+ }
476
+ });
477
+ });
478
+ };
479
+
480
+ describe("reverse-strata-sync alignment", () => {
481
+ it("bootstraps metadata and hydrates the object pool", async () => {
482
+ const storage = new InMemoryStorage();
483
+ const rows: ModelRow[] = [
484
+ {
485
+ modelName: "Issue",
486
+ data: { id: "issue-1", title: "First", teamId: "team-1" },
487
+ },
488
+ {
489
+ modelName: "Team",
490
+ data: { id: "team-1", name: "Core" },
491
+ },
492
+ ];
493
+ const transport = new TestTransport({
494
+ fullRows: rows,
495
+ fullMetadata: {
496
+ lastSyncId: 42,
497
+ subscribedSyncGroups: ["team-1"],
498
+ databaseVersion: 7,
499
+ },
500
+ });
501
+
502
+ const client = createSyncClient({
503
+ storage,
504
+ transport,
505
+ reactivity: noopReactivityAdapter,
506
+ schema,
507
+ });
508
+
509
+ try {
510
+ await client.start();
511
+
512
+ expect(client.lastSyncId).toBe(42);
513
+ const meta = (await storage.getMeta()) as {
514
+ lastSyncId?: number;
515
+ firstSyncId?: number;
516
+ subscribedSyncGroups?: string[];
517
+ };
518
+ expect(meta.lastSyncId).toBe(42);
519
+ expect(meta.firstSyncId).toBe(42);
520
+ expect(meta.subscribedSyncGroups).toEqual(["team-1"]);
521
+
522
+ const issueMap = client.getIdentityMap<Record<string, unknown>>("Issue");
523
+ expect(issueMap.get("issue-1")).toMatchObject({
524
+ id: "issue-1",
525
+ title: "First",
526
+ });
527
+
528
+ const persistence = await storage.getModelPersistence("Issue");
529
+ expect(persistence.persisted).toBe(true);
530
+
531
+ expect(transport.bootstrapCalls[0]?.onlyModels).toEqual([
532
+ "Issue",
533
+ "Team",
534
+ ]);
535
+ } finally {
536
+ await client.stop();
537
+ }
538
+ });
539
+
540
+ it("applies delta packets and clears confirmed outbox transactions", async () => {
541
+ const storage = new InMemoryStorage();
542
+ const rows: ModelRow[] = [
543
+ {
544
+ modelName: "Issue",
545
+ data: { id: "issue-1", title: "Seed", teamId: "team-1" },
546
+ },
547
+ {
548
+ modelName: "Team",
549
+ data: { id: "team-1", name: "Core" },
550
+ },
551
+ ];
552
+ const transport = new TestTransport({
553
+ fullRows: rows,
554
+ fullMetadata: {
555
+ lastSyncId: 10,
556
+ subscribedSyncGroups: ["team-1"],
557
+ },
558
+ startingSyncId: 50,
559
+ });
560
+
561
+ const client = createSyncClient({
562
+ storage,
563
+ transport,
564
+ reactivity: noopReactivityAdapter,
565
+ schema,
566
+ batchMutations: false,
567
+ });
568
+
569
+ try {
570
+ await client.start();
571
+
572
+ let createdTx: Transaction | null = null;
573
+ const created = await client.create(
574
+ "Issue",
575
+ { id: "issue-2", title: "New", teamId: "team-1" },
576
+ {
577
+ onTransactionCreated: (tx) => {
578
+ createdTx = tx;
579
+ },
580
+ }
581
+ );
582
+
583
+ expect(createdTx).not.toBeNull();
584
+ const outbox = await storage.getOutbox();
585
+ expect(outbox).toHaveLength(1);
586
+ expect(outbox[0]?.state).toBe("awaitingSync");
587
+
588
+ const syncId = outbox[0]?.syncIdNeededForCompletion ?? 51;
589
+ const delta: DeltaPacket = {
590
+ lastSyncId: syncId,
591
+ actions: [
592
+ {
593
+ id: syncId,
594
+ action: "I",
595
+ modelName: "Issue",
596
+ modelId: "issue-2",
597
+ data: created,
598
+ clientTxId: createdTx?.clientTxId,
599
+ },
600
+ ],
601
+ };
602
+
603
+ const syncWaiter = waitForSync(client, syncId);
604
+ const outboxWaiter = waitForOutboxCount(client, 0);
605
+ transport.emitDelta(delta);
606
+ await Promise.all([syncWaiter, outboxWaiter]);
607
+
608
+ const clearedOutbox = await storage.getOutbox();
609
+ expect(clearedOutbox).toHaveLength(0);
610
+
611
+ const issueMap = client.getIdentityMap<Record<string, unknown>>("Issue");
612
+ expect(issueMap.get("issue-2")).toMatchObject({
613
+ id: "issue-2",
614
+ title: "New",
615
+ });
616
+ } finally {
617
+ await client.stop();
618
+ }
619
+ });
620
+
621
+ it("sync-group deltas trigger partial bootstrap for new groups", async () => {
622
+ const storage = new InMemoryStorage();
623
+ const rows: ModelRow[] = [
624
+ {
625
+ modelName: "Issue",
626
+ data: { id: "issue-1", title: "Seed", teamId: "team-1" },
627
+ },
628
+ {
629
+ modelName: "Team",
630
+ data: { id: "team-1", name: "Core" },
631
+ },
632
+ ];
633
+ const partialRows = new Map<string, ModelRow[]>([
634
+ [
635
+ "team-2",
636
+ [
637
+ {
638
+ modelName: "Issue",
639
+ data: { id: "issue-2", title: "Team 2", teamId: "team-2" },
640
+ },
641
+ ],
642
+ ],
643
+ ]);
644
+ const transport = new TestTransport({
645
+ fullRows: rows,
646
+ fullMetadata: {
647
+ lastSyncId: 10,
648
+ subscribedSyncGroups: ["team-1"],
649
+ },
650
+ partialRowsByGroup: partialRows,
651
+ });
652
+
653
+ const client = createSyncClient({
654
+ storage,
655
+ transport,
656
+ reactivity: noopReactivityAdapter,
657
+ schema,
658
+ });
659
+
660
+ try {
661
+ await client.start();
662
+
663
+ const delta: DeltaPacket = {
664
+ lastSyncId: 60,
665
+ actions: [
666
+ {
667
+ id: 60,
668
+ action: "S",
669
+ modelName: "SyncGroup",
670
+ modelId: "sync-groups",
671
+ data: { subscribedSyncGroups: ["team-1", "team-2"] },
672
+ },
673
+ ],
674
+ };
675
+
676
+ const syncWaiter = waitForSync(client, 60);
677
+ transport.emitDelta(delta);
678
+ await syncWaiter;
679
+
680
+ const meta = (await storage.getMeta()) as {
681
+ firstSyncId?: number;
682
+ subscribedSyncGroups?: string[];
683
+ };
684
+ expect(meta.firstSyncId).toBe(60);
685
+ expect(meta.subscribedSyncGroups).toEqual(["team-1", "team-2"]);
686
+
687
+ const issueMap = client.getIdentityMap<Record<string, unknown>>("Issue");
688
+ expect(issueMap.get("issue-2")).toMatchObject({
689
+ id: "issue-2",
690
+ teamId: "team-2",
691
+ });
692
+
693
+ const partialCall = transport.bootstrapCalls.find(
694
+ (call) => call.type === "partial"
695
+ );
696
+ expect(partialCall?.syncGroups).toEqual(["team-2"]);
697
+ } finally {
698
+ await client.stop();
699
+ }
700
+ });
701
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "paths": {}
6
+ },
7
+ "include": ["src/**/*"]
8
+ }