@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,294 @@
1
+ import type { ObservableMap, ReactivityAdapter } from "@strata-sync/core";
2
+ import type { ModelFactory } from "./types";
3
+
4
+ interface ModelInstanceLike {
5
+ _applyUpdate?: (data: Record<string, unknown>) => void;
6
+ makeObservable?: () => void;
7
+ toJSON?: () => Record<string, unknown>;
8
+ }
9
+
10
+ const isModelInstanceLike = (value: unknown): value is ModelInstanceLike => {
11
+ if (!value || typeof value !== "object") {
12
+ return false;
13
+ }
14
+ const record = value as Record<string, unknown>;
15
+ return (
16
+ typeof record._applyUpdate === "function" ||
17
+ typeof record.makeObservable === "function"
18
+ );
19
+ };
20
+
21
+ /**
22
+ * Identity map for managing model instances
23
+ * Ensures only one instance exists per model+id combination
24
+ */
25
+ export class IdentityMap<T extends Record<string, unknown>> {
26
+ private readonly map: ObservableMap<string, T>;
27
+ private readonly modelName: string;
28
+ private readonly reactivity: ReactivityAdapter;
29
+ private modelFactory?: ModelFactory;
30
+
31
+ constructor(
32
+ modelName: string,
33
+ reactivity: ReactivityAdapter,
34
+ modelFactory?: ModelFactory
35
+ ) {
36
+ this.modelName = modelName;
37
+ this.reactivity = reactivity;
38
+ this.modelFactory = modelFactory;
39
+ this.map = reactivity.createMap<string, T>(undefined, {
40
+ name: `IdentityMap:${modelName}`,
41
+ });
42
+ }
43
+
44
+ setModelFactory(modelFactory?: ModelFactory): void {
45
+ this.modelFactory = modelFactory;
46
+ }
47
+
48
+ private ensureObservable(instance: T): T {
49
+ if (
50
+ isModelInstanceLike(instance) &&
51
+ typeof instance.makeObservable === "function"
52
+ ) {
53
+ instance.makeObservable();
54
+ }
55
+ return instance;
56
+ }
57
+
58
+ private toInstance(data: T): T {
59
+ if (!this.modelFactory || isModelInstanceLike(data)) {
60
+ return this.ensureObservable(data);
61
+ }
62
+
63
+ const instance = this.modelFactory(
64
+ this.modelName,
65
+ data as Record<string, unknown>
66
+ ) as T;
67
+ return this.ensureObservable(instance);
68
+ }
69
+
70
+ /**
71
+ * Gets a model instance by ID
72
+ */
73
+ get(id: string): T | undefined {
74
+ return this.map.get(id);
75
+ }
76
+
77
+ /**
78
+ * Checks if a model instance exists
79
+ */
80
+ has(id: string): boolean {
81
+ return this.map.has(id);
82
+ }
83
+
84
+ /**
85
+ * Sets a model instance, replacing any existing instance
86
+ */
87
+ set(id: string, instance: T): void {
88
+ this.reactivity.runInAction(() => {
89
+ this.map.set(id, this.toInstance(instance));
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Updates an existing model instance in place
95
+ */
96
+ update(id: string, changes: Partial<T>): T | undefined {
97
+ const existing = this.map.get(id);
98
+ if (!existing) {
99
+ return undefined;
100
+ }
101
+
102
+ this.reactivity.runInAction(() => {
103
+ this.applyChanges(existing, changes);
104
+ this.map.set(id, this.ensureObservable(existing));
105
+ });
106
+
107
+ return this.map.get(id);
108
+ }
109
+
110
+ /**
111
+ * Merges data into an existing instance or creates a new one
112
+ */
113
+ merge(id: string, data: Partial<T>): T {
114
+ return this.reactivity.runInAction(() => {
115
+ const existing = this.map.get(id);
116
+ if (existing) {
117
+ this.applyChanges(existing, data);
118
+ this.map.set(id, this.ensureObservable(existing));
119
+ return existing;
120
+ }
121
+ const merged = this.toInstance(data as T);
122
+ this.map.set(id, merged);
123
+ return merged;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Deletes a model instance
129
+ */
130
+ delete(id: string): boolean {
131
+ return this.reactivity.runInAction(() => {
132
+ return this.map.delete(id);
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Clears all model instances
138
+ */
139
+ clear(): void {
140
+ this.reactivity.runInAction(() => {
141
+ this.map.clear();
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Gets all model instances
147
+ */
148
+ values(): T[] {
149
+ return Array.from(this.map.values());
150
+ }
151
+
152
+ /**
153
+ * Gets all model IDs
154
+ */
155
+ keys(): string[] {
156
+ return Array.from(this.map.keys());
157
+ }
158
+
159
+ /**
160
+ * Gets all entries
161
+ */
162
+ entries(): [string, T][] {
163
+ return Array.from(this.map.entries());
164
+ }
165
+
166
+ /**
167
+ * Gets the number of instances
168
+ */
169
+ get size(): number {
170
+ return this.map.size;
171
+ }
172
+
173
+ /**
174
+ * Iterates over all instances
175
+ */
176
+ forEach(callback: (value: T, key: string) => void): void {
177
+ this.map.forEach(callback);
178
+ }
179
+
180
+ /**
181
+ * Finds an instance matching a predicate
182
+ */
183
+ find(predicate: (value: T) => boolean): T | undefined {
184
+ for (const value of this.map.values()) {
185
+ if (predicate(value)) {
186
+ return value;
187
+ }
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ /**
193
+ * Filters instances matching a predicate
194
+ */
195
+ filter(predicate: (value: T) => boolean): T[] {
196
+ return this.values().filter(predicate);
197
+ }
198
+
199
+ /**
200
+ * Gets the model name
201
+ */
202
+ getModelName(): string {
203
+ return this.modelName;
204
+ }
205
+
206
+ /**
207
+ * Gets the underlying map (for advanced usage)
208
+ */
209
+ getRawMap(): Map<string, T> {
210
+ return new Map(this.map.entries());
211
+ }
212
+
213
+ /**
214
+ * Applies changes to an existing instance, preserving identity
215
+ */
216
+ private applyChanges(target: T, changes: Partial<T>): void {
217
+ const candidate = target as ModelInstanceLike;
218
+
219
+ if (typeof candidate._applyUpdate === "function") {
220
+ candidate._applyUpdate(changes as Record<string, unknown>);
221
+ return;
222
+ }
223
+
224
+ Object.assign(target, changes);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Manages identity maps for all model types
230
+ */
231
+ export class IdentityMapRegistry {
232
+ private readonly maps: Map<string, IdentityMap<Record<string, unknown>>> =
233
+ new Map();
234
+ private readonly reactivity: ReactivityAdapter;
235
+ private modelFactory?: ModelFactory;
236
+
237
+ constructor(reactivity: ReactivityAdapter, modelFactory?: ModelFactory) {
238
+ this.reactivity = reactivity;
239
+ this.modelFactory = modelFactory;
240
+ }
241
+
242
+ setModelFactory(modelFactory?: ModelFactory): void {
243
+ this.modelFactory = modelFactory;
244
+ for (const map of this.maps.values()) {
245
+ map.setModelFactory(modelFactory);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Gets or creates an identity map for a model type
251
+ */
252
+ getMap<T extends Record<string, unknown>>(modelName: string): IdentityMap<T> {
253
+ let map = this.maps.get(modelName);
254
+ if (!map) {
255
+ map = new IdentityMap<Record<string, unknown>>(
256
+ modelName,
257
+ this.reactivity,
258
+ this.modelFactory
259
+ );
260
+ this.maps.set(modelName, map);
261
+ }
262
+ return map as IdentityMap<T>;
263
+ }
264
+
265
+ /**
266
+ * Checks if a map exists for a model type
267
+ */
268
+ hasMap(modelName: string): boolean {
269
+ return this.maps.has(modelName);
270
+ }
271
+
272
+ /**
273
+ * Clears all identity maps
274
+ */
275
+ clearAll(): void {
276
+ for (const map of this.maps.values()) {
277
+ map.clear();
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Clears a specific identity map
283
+ */
284
+ clear(modelName: string): void {
285
+ this.maps.get(modelName)?.clear();
286
+ }
287
+
288
+ /**
289
+ * Gets all model names with identity maps
290
+ */
291
+ getModelNames(): string[] {
292
+ return Array.from(this.maps.keys());
293
+ }
294
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // biome-ignore-all lint/performance/noBarrelFile: public API
2
+ export { createSyncClient } from "./client";
3
+ export { IdentityMap, IdentityMapRegistry } from "./identity-map";
4
+ export { OutboxManager } from "./outbox-manager";
5
+ export {
6
+ and,
7
+ combineSorts,
8
+ contains,
9
+ eq,
10
+ executeQuery,
11
+ gt,
12
+ isIn,
13
+ lt,
14
+ matches,
15
+ neq,
16
+ not,
17
+ or,
18
+ sortBy,
19
+ } from "./query";
20
+ export { SyncOrchestrator } from "./sync-orchestrator";
21
+
22
+ export type OutboxManagerOptions =
23
+ import("./outbox-manager").OutboxManagerOptions;
24
+ export type QueryOptions<T> = import("./types").QueryOptions<T>;
25
+ export type QueryResult<T> = import("./types").QueryResult<T>;
26
+ export type StorageAdapter = import("./types").StorageAdapter;
27
+ export type StorageMeta = import("./types").StorageMeta;
28
+ export type SyncClient = import("./types").SyncClient;
29
+ export type SyncClientEvent = import("./types").SyncClientEvent;
30
+ export type SyncClientOptions = import("./types").SyncClientOptions;
31
+ export type SyncModelInstance<T = Record<string, unknown>> =
32
+ import("./types").SyncModelInstance<T>;
33
+ export type TransportAdapter = import("./types").TransportAdapter;
@@ -0,0 +1,399 @@
1
+ import type { MutateResult, Transaction } from "@strata-sync/core";
2
+ import {
3
+ createArchiveTransaction,
4
+ createDeleteTransaction,
5
+ createInsertTransaction,
6
+ createTransactionBatch,
7
+ createUnarchiveTransaction,
8
+ createUpdateTransaction,
9
+ } from "@strata-sync/core";
10
+ import type { StorageAdapter, TransportAdapter } from "./types";
11
+
12
+ /**
13
+ * Options for the outbox manager
14
+ */
15
+ export interface OutboxManagerOptions {
16
+ /** Storage adapter for persisting transactions */
17
+ storage: StorageAdapter;
18
+ /** Transport adapter for sending transactions */
19
+ transport: TransportAdapter;
20
+ /** Client ID */
21
+ clientId: string;
22
+ /** Batch mutations together */
23
+ batchMutations?: boolean;
24
+ /** Delay before sending batch (ms) */
25
+ batchDelay?: number;
26
+ /** Maximum batch size */
27
+ maxBatchSize?: number;
28
+ /** Callback when transaction state changes */
29
+ onTransactionStateChange?: (tx: Transaction) => void;
30
+ /** Callback when transaction is rejected by server */
31
+ onTransactionRejected?: (tx: Transaction) => void;
32
+ }
33
+
34
+ /**
35
+ * Manages the outbox queue of pending transactions
36
+ */
37
+ export class OutboxManager {
38
+ private readonly storage: StorageAdapter;
39
+ private readonly transport: TransportAdapter;
40
+ private readonly clientId: string;
41
+ private readonly batchMutations: boolean;
42
+ private readonly batchDelay: number;
43
+ private readonly maxBatchSize: number;
44
+ private readonly onTransactionStateChange?: (tx: Transaction) => void;
45
+ private readonly onTransactionRejected?: (tx: Transaction) => void;
46
+
47
+ private pendingBatch: Transaction[] = [];
48
+ private batchTimer: ReturnType<typeof setTimeout> | null = null;
49
+ private processing = false;
50
+ private processingPromise: Promise<void> | null = null;
51
+
52
+ constructor(options: OutboxManagerOptions) {
53
+ this.storage = options.storage;
54
+ this.transport = options.transport;
55
+ this.clientId = options.clientId;
56
+ this.batchMutations = options.batchMutations ?? true;
57
+ this.batchDelay = options.batchDelay ?? 50;
58
+ this.maxBatchSize = options.maxBatchSize ?? 100;
59
+ this.onTransactionStateChange = options.onTransactionStateChange;
60
+ this.onTransactionRejected = options.onTransactionRejected;
61
+ }
62
+
63
+ /**
64
+ * Queues an INSERT transaction
65
+ */
66
+ async insert(
67
+ modelName: string,
68
+ modelId: string,
69
+ data: Record<string, unknown>
70
+ ): Promise<Transaction> {
71
+ const tx = createInsertTransaction(this.clientId, modelName, modelId, data);
72
+ await this.queueTransaction(tx);
73
+ return tx;
74
+ }
75
+
76
+ /**
77
+ * Queues an UPDATE transaction
78
+ */
79
+ async update(
80
+ modelName: string,
81
+ modelId: string,
82
+ changes: Record<string, unknown>,
83
+ original: Record<string, unknown>
84
+ ): Promise<Transaction> {
85
+ const tx = createUpdateTransaction(
86
+ this.clientId,
87
+ modelName,
88
+ modelId,
89
+ changes,
90
+ original
91
+ );
92
+ await this.queueTransaction(tx);
93
+ return tx;
94
+ }
95
+
96
+ /**
97
+ * Queues a DELETE transaction
98
+ */
99
+ async delete(
100
+ modelName: string,
101
+ modelId: string,
102
+ original: Record<string, unknown>
103
+ ): Promise<Transaction> {
104
+ const tx = createDeleteTransaction(
105
+ this.clientId,
106
+ modelName,
107
+ modelId,
108
+ original
109
+ );
110
+ await this.queueTransaction(tx);
111
+ return tx;
112
+ }
113
+
114
+ /**
115
+ * Queues an ARCHIVE transaction
116
+ */
117
+ async archive(
118
+ modelName: string,
119
+ modelId: string,
120
+ options: { original?: Record<string, unknown>; archivedAt?: string } = {}
121
+ ): Promise<Transaction> {
122
+ const tx = createArchiveTransaction(
123
+ this.clientId,
124
+ modelName,
125
+ modelId,
126
+ options
127
+ );
128
+ await this.queueTransaction(tx);
129
+ return tx;
130
+ }
131
+
132
+ /**
133
+ * Queues an UNARCHIVE transaction
134
+ */
135
+ async unarchive(
136
+ modelName: string,
137
+ modelId: string,
138
+ options: { original?: Record<string, unknown> } = {}
139
+ ): Promise<Transaction> {
140
+ const tx = createUnarchiveTransaction(
141
+ this.clientId,
142
+ modelName,
143
+ modelId,
144
+ options
145
+ );
146
+ await this.queueTransaction(tx);
147
+ return tx;
148
+ }
149
+
150
+ /**
151
+ * Queues a transaction for sending
152
+ */
153
+ private async queueTransaction(tx: Transaction): Promise<void> {
154
+ // Persist to storage first
155
+ await this.storage.addToOutbox(tx);
156
+ this.onTransactionStateChange?.(tx);
157
+
158
+ if (this.batchMutations) {
159
+ this.pendingBatch.push(tx);
160
+ this.scheduleBatchSend();
161
+ } else {
162
+ await this.sendBatch([tx]);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Schedules sending the pending batch
168
+ */
169
+ private scheduleBatchSend(): void {
170
+ if (this.batchTimer) {
171
+ clearTimeout(this.batchTimer);
172
+ }
173
+
174
+ // Send immediately if batch is full
175
+ if (this.pendingBatch.length >= this.maxBatchSize) {
176
+ this.flushBatch();
177
+ return;
178
+ }
179
+
180
+ this.batchTimer = setTimeout(() => {
181
+ this.flushBatch();
182
+ }, this.batchDelay);
183
+ }
184
+
185
+ /**
186
+ * Flushes the pending batch
187
+ */
188
+ private flushBatch(): void {
189
+ if (this.batchTimer) {
190
+ clearTimeout(this.batchTimer);
191
+ this.batchTimer = null;
192
+ }
193
+
194
+ if (this.pendingBatch.length === 0) {
195
+ return;
196
+ }
197
+
198
+ const batch = this.pendingBatch;
199
+ this.pendingBatch = [];
200
+
201
+ this.sendBatch(batch).catch(() => {
202
+ // Errors are handled in sendBatch
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Sends a batch of transactions
208
+ */
209
+ private async sendBatch(transactions: Transaction[]): Promise<void> {
210
+ if (transactions.length === 0) {
211
+ return;
212
+ }
213
+
214
+ // Mark transactions as sent
215
+ for (const tx of transactions) {
216
+ tx.state = "sent";
217
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
218
+ state: "sent",
219
+ });
220
+ this.onTransactionStateChange?.(tx);
221
+ }
222
+
223
+ const batch = createTransactionBatch(transactions);
224
+
225
+ try {
226
+ const result = await this.transport.mutate(batch);
227
+ await this.handleMutateResult(transactions, result);
228
+ } catch (error) {
229
+ // Mark transactions as failed
230
+ for (const tx of transactions) {
231
+ tx.state = "failed";
232
+ tx.lastError = error instanceof Error ? error.message : "Unknown error";
233
+ tx.retryCount++;
234
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
235
+ state: "failed",
236
+ lastError: tx.lastError,
237
+ retryCount: tx.retryCount,
238
+ });
239
+ this.onTransactionStateChange?.(tx);
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Handles the result of a mutation batch
247
+ */
248
+ private async handleMutateResult(
249
+ transactions: Transaction[],
250
+ result: MutateResult
251
+ ): Promise<void> {
252
+ const txMap = new Map(transactions.map((tx) => [tx.clientTxId, tx]));
253
+ const maxSyncId = Math.max(
254
+ result.lastSyncId ?? 0,
255
+ ...result.results
256
+ .map((txResult) => txResult.syncId)
257
+ .filter((value): value is number => typeof value === "number")
258
+ );
259
+
260
+ for (const txResult of result.results) {
261
+ const tx = txMap.get(txResult.clientTxId);
262
+ if (!tx) {
263
+ continue;
264
+ }
265
+
266
+ if (txResult.success) {
267
+ const syncIdNeededForCompletion =
268
+ txResult.syncId ??
269
+ (Number.isFinite(maxSyncId) ? maxSyncId : undefined);
270
+ tx.state = "awaitingSync";
271
+ tx.syncIdNeededForCompletion = syncIdNeededForCompletion;
272
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
273
+ state: "awaitingSync",
274
+ syncIdNeededForCompletion,
275
+ });
276
+ } else {
277
+ tx.state = "failed";
278
+ tx.lastError = txResult.error ?? "Unknown error";
279
+ tx.retryCount++;
280
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
281
+ state: "failed",
282
+ lastError: tx.lastError,
283
+ retryCount: tx.retryCount,
284
+ });
285
+ this.onTransactionRejected?.(tx);
286
+ }
287
+
288
+ this.onTransactionStateChange?.(tx);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Processes any pending transactions from storage (e.g., after reconnect)
294
+ */
295
+ async processPendingTransactions(): Promise<void> {
296
+ if (this.processing) {
297
+ await this.processingPromise;
298
+ return;
299
+ }
300
+
301
+ this.processing = true;
302
+ this.processingPromise = this.doProcessPending();
303
+
304
+ try {
305
+ await this.processingPromise;
306
+ } finally {
307
+ this.processing = false;
308
+ this.processingPromise = null;
309
+ }
310
+ }
311
+
312
+ private async doProcessPending(): Promise<void> {
313
+ const pending = await this.storage.getOutbox();
314
+
315
+ // Reset sent transactions back to queued (they may not have been confirmed)
316
+ for (const tx of pending) {
317
+ if (tx.state === "sent") {
318
+ tx.state = "queued";
319
+ await this.storage.updateOutboxTransaction(tx.clientTxId, {
320
+ state: "queued",
321
+ });
322
+ }
323
+ }
324
+
325
+ // Filter to only queued transactions
326
+ const queued = pending.filter(
327
+ (tx) => tx.state === "queued" && tx.retryCount < 5
328
+ );
329
+
330
+ if (queued.length === 0) {
331
+ return;
332
+ }
333
+
334
+ // Send in batches
335
+ for (let i = 0; i < queued.length; i += this.maxBatchSize) {
336
+ const batch = queued.slice(i, i + this.maxBatchSize);
337
+ await this.sendBatch(batch);
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Gets the count of pending transactions
343
+ */
344
+ async getPendingCount(): Promise<number> {
345
+ const outbox = await this.storage.getOutbox();
346
+ return outbox.filter(
347
+ (tx) =>
348
+ tx.state === "queued" ||
349
+ tx.state === "sent" ||
350
+ tx.state === "awaitingSync"
351
+ ).length;
352
+ }
353
+
354
+ /**
355
+ * Completes any awaiting transactions up to the given sync ID
356
+ */
357
+ async completeUpToSyncId(lastSyncId: number): Promise<number> {
358
+ const outbox = await this.storage.getOutbox();
359
+ let completed = 0;
360
+
361
+ for (const tx of outbox) {
362
+ if (
363
+ tx.state === "awaitingSync" &&
364
+ typeof tx.syncIdNeededForCompletion === "number" &&
365
+ tx.syncIdNeededForCompletion <= lastSyncId
366
+ ) {
367
+ await this.storage.removeFromOutbox(tx.clientTxId);
368
+ tx.state = "completed";
369
+ completed++;
370
+ }
371
+ }
372
+
373
+ return completed;
374
+ }
375
+
376
+ /**
377
+ * Forces an immediate flush of pending batches
378
+ */
379
+ async flush(): Promise<void> {
380
+ this.flushBatch();
381
+ await this.processingPromise;
382
+ }
383
+
384
+ /**
385
+ * Clears all pending transactions
386
+ */
387
+ async clear(): Promise<void> {
388
+ if (this.batchTimer) {
389
+ clearTimeout(this.batchTimer);
390
+ this.batchTimer = null;
391
+ }
392
+ this.pendingBatch = [];
393
+
394
+ const outbox = await this.storage.getOutbox();
395
+ for (const tx of outbox) {
396
+ await this.storage.removeFromOutbox(tx.clientTxId);
397
+ }
398
+ }
399
+ }