@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/types.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BatchLoadOptions,
|
|
3
|
+
BootstrapMetadata,
|
|
4
|
+
BootstrapOptions,
|
|
5
|
+
ConnectionState,
|
|
6
|
+
DeltaPacket,
|
|
7
|
+
DeltaSubscription,
|
|
8
|
+
ModelRegistrySnapshot,
|
|
9
|
+
ModelRow,
|
|
10
|
+
MutateResult,
|
|
11
|
+
ReactivityAdapter,
|
|
12
|
+
SchemaDefinition,
|
|
13
|
+
SubscribeOptions,
|
|
14
|
+
SyncAction,
|
|
15
|
+
SyncClientState,
|
|
16
|
+
Transaction,
|
|
17
|
+
TransactionBatch,
|
|
18
|
+
} from "@strata-sync/core";
|
|
19
|
+
import type {
|
|
20
|
+
YjsDocumentManager,
|
|
21
|
+
YjsPresenceManager,
|
|
22
|
+
YjsTransport,
|
|
23
|
+
} from "@strata-sync/yjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Storage adapter interface (from sync-storage-idb or similar)
|
|
27
|
+
*/
|
|
28
|
+
export interface StorageAdapter {
|
|
29
|
+
open(options: {
|
|
30
|
+
name?: string;
|
|
31
|
+
userId?: string;
|
|
32
|
+
version?: number;
|
|
33
|
+
userVersion?: number;
|
|
34
|
+
schema?: SchemaDefinition | ModelRegistrySnapshot;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
get<T>(modelName: string, id: string): Promise<T | null>;
|
|
38
|
+
getAll<T>(modelName: string): Promise<T[]>;
|
|
39
|
+
put<T extends Record<string, unknown>>(
|
|
40
|
+
modelName: string,
|
|
41
|
+
row: T
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
delete(modelName: string, id: string): Promise<void>;
|
|
44
|
+
getByIndex<T>(
|
|
45
|
+
modelName: string,
|
|
46
|
+
indexName: string,
|
|
47
|
+
key: string
|
|
48
|
+
): Promise<T[]>;
|
|
49
|
+
writeBatch(
|
|
50
|
+
ops: Array<{
|
|
51
|
+
type: "put" | "delete";
|
|
52
|
+
modelName: string;
|
|
53
|
+
id?: string;
|
|
54
|
+
data?: Record<string, unknown>;
|
|
55
|
+
}>
|
|
56
|
+
): Promise<void>;
|
|
57
|
+
getMeta(): Promise<StorageMeta>;
|
|
58
|
+
setMeta(meta: Partial<StorageMeta>): Promise<void>;
|
|
59
|
+
getModelPersistence(modelName: string): Promise<ModelPersistenceMeta>;
|
|
60
|
+
setModelPersistence(modelName: string, persisted: boolean): Promise<void>;
|
|
61
|
+
getOutbox(): Promise<Transaction[]>;
|
|
62
|
+
addToOutbox(tx: Transaction): Promise<void>;
|
|
63
|
+
removeFromOutbox(clientTxId: string): Promise<void>;
|
|
64
|
+
updateOutboxTransaction(
|
|
65
|
+
clientTxId: string,
|
|
66
|
+
updates: Partial<Transaction>
|
|
67
|
+
): Promise<void>;
|
|
68
|
+
hasPartialIndex(
|
|
69
|
+
modelName: string,
|
|
70
|
+
indexedKey: string,
|
|
71
|
+
keyValue: string
|
|
72
|
+
): Promise<boolean>;
|
|
73
|
+
setPartialIndex(
|
|
74
|
+
modelName: string,
|
|
75
|
+
indexedKey: string,
|
|
76
|
+
keyValue: string
|
|
77
|
+
): Promise<void>;
|
|
78
|
+
addSyncActions(actions: SyncAction[]): Promise<void>;
|
|
79
|
+
getSyncActions(afterSyncId?: number, limit?: number): Promise<SyncAction[]>;
|
|
80
|
+
clearSyncActions(): Promise<void>;
|
|
81
|
+
clear(): Promise<void>;
|
|
82
|
+
count(modelName: string): Promise<number>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Store interface for model instances (lazy relations)
|
|
87
|
+
*/
|
|
88
|
+
export interface ModelStore {
|
|
89
|
+
get<T extends Record<string, unknown>>(
|
|
90
|
+
modelName: string,
|
|
91
|
+
id: string
|
|
92
|
+
): Promise<T | null>;
|
|
93
|
+
getByIndex?<T extends Record<string, unknown>>(
|
|
94
|
+
modelName: string,
|
|
95
|
+
indexName: string,
|
|
96
|
+
key: string
|
|
97
|
+
): Promise<T[]>;
|
|
98
|
+
loadByIndex?<T extends Record<string, unknown>>(
|
|
99
|
+
modelName: string,
|
|
100
|
+
indexName: string,
|
|
101
|
+
key: string
|
|
102
|
+
): Promise<T[]>;
|
|
103
|
+
hasPartialIndex?(
|
|
104
|
+
modelName: string,
|
|
105
|
+
indexName: string,
|
|
106
|
+
key: string
|
|
107
|
+
): Promise<boolean>;
|
|
108
|
+
setPartialIndex?(
|
|
109
|
+
modelName: string,
|
|
110
|
+
indexName: string,
|
|
111
|
+
key: string
|
|
112
|
+
): Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Model factory for creating model instances from raw data
|
|
117
|
+
*/
|
|
118
|
+
export type ModelFactory = (
|
|
119
|
+
modelName: string,
|
|
120
|
+
data: Record<string, unknown>
|
|
121
|
+
) => Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Context passed to model factory builders
|
|
125
|
+
*/
|
|
126
|
+
export interface ModelFactoryContext {
|
|
127
|
+
store: ModelStore;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Model factory builder (receives store context)
|
|
132
|
+
*/
|
|
133
|
+
export type ModelFactoryFactory = (
|
|
134
|
+
context: ModelFactoryContext
|
|
135
|
+
) => ModelFactory;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Storage metadata
|
|
139
|
+
*/
|
|
140
|
+
export interface StorageMeta {
|
|
141
|
+
schemaHash?: string;
|
|
142
|
+
lastSyncId: number;
|
|
143
|
+
firstSyncId?: number;
|
|
144
|
+
subscribedSyncGroups?: string[];
|
|
145
|
+
clientId?: string;
|
|
146
|
+
bootstrapComplete?: boolean;
|
|
147
|
+
lastSyncAt?: number;
|
|
148
|
+
databaseVersion?: number;
|
|
149
|
+
updatedAt?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ModelPersistenceMeta {
|
|
153
|
+
modelName: string;
|
|
154
|
+
persisted: boolean;
|
|
155
|
+
updatedAt?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Transport adapter interface (from sync-transport-graphql or similar)
|
|
160
|
+
*/
|
|
161
|
+
export interface TransportAdapter {
|
|
162
|
+
bootstrap(
|
|
163
|
+
options: BootstrapOptions
|
|
164
|
+
): AsyncGenerator<ModelRow, BootstrapMetadata, unknown>;
|
|
165
|
+
batchLoad(options: BatchLoadOptions): AsyncIterable<ModelRow>;
|
|
166
|
+
mutate(batch: TransactionBatch): Promise<MutateResult>;
|
|
167
|
+
subscribe(options: SubscribeOptions): DeltaSubscription;
|
|
168
|
+
fetchDeltas(after: number, limit?: number): Promise<DeltaPacket>;
|
|
169
|
+
getConnectionState(): ConnectionState;
|
|
170
|
+
onConnectionStateChange(
|
|
171
|
+
callback: (state: ConnectionState) => void
|
|
172
|
+
): () => void;
|
|
173
|
+
close(): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Sync client options
|
|
178
|
+
*/
|
|
179
|
+
export interface SyncClientOptions {
|
|
180
|
+
/** Storage adapter (e.g., IndexedDB) */
|
|
181
|
+
storage: StorageAdapter;
|
|
182
|
+
/** Transport adapter (e.g., GraphQL) */
|
|
183
|
+
transport: TransportAdapter;
|
|
184
|
+
/** Reactivity adapter (e.g., MobX) */
|
|
185
|
+
reactivity: ReactivityAdapter;
|
|
186
|
+
/** Schema definition or registry snapshot */
|
|
187
|
+
schema?: SchemaDefinition | ModelRegistrySnapshot;
|
|
188
|
+
/** Optional model factory (or factory builder) */
|
|
189
|
+
modelFactory?: ModelFactory | ModelFactoryFactory;
|
|
190
|
+
/** Database name for storage */
|
|
191
|
+
dbName?: string;
|
|
192
|
+
/** Logged-in user ID for storage naming */
|
|
193
|
+
userId?: string;
|
|
194
|
+
/** Client version for storage naming */
|
|
195
|
+
version?: number;
|
|
196
|
+
/** Per-user version for storage naming */
|
|
197
|
+
userVersion?: number;
|
|
198
|
+
/** Groups to sync (for multi-tenancy) */
|
|
199
|
+
groups?: string[];
|
|
200
|
+
/** Enable optimistic updates */
|
|
201
|
+
optimistic?: boolean;
|
|
202
|
+
/** Batch mutations before sending */
|
|
203
|
+
batchMutations?: boolean;
|
|
204
|
+
/** Mutation batch delay in ms */
|
|
205
|
+
batchDelay?: number;
|
|
206
|
+
/** Bootstrap mode selection */
|
|
207
|
+
bootstrapMode?: "auto" | "full" | "local";
|
|
208
|
+
/** Optional Yjs transport for live editing */
|
|
209
|
+
yjsTransport?: YjsTransport;
|
|
210
|
+
/** Default conflict resolution strategy for transaction rebasing (default: "server-wins") */
|
|
211
|
+
rebaseStrategy?: "server-wins" | "client-wins" | "merge";
|
|
212
|
+
/** Enable field-level conflict detection for rebasing (default: true) */
|
|
213
|
+
fieldLevelConflicts?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Model instance with sync metadata
|
|
218
|
+
*/
|
|
219
|
+
export interface SyncModelInstance<T = Record<string, unknown>> {
|
|
220
|
+
/** The model data */
|
|
221
|
+
data: T;
|
|
222
|
+
/** Whether the model is fully hydrated */
|
|
223
|
+
__isHydrated: boolean;
|
|
224
|
+
/** Whether the model has unsaved changes */
|
|
225
|
+
__dirty: boolean;
|
|
226
|
+
/** Fields that have been modified */
|
|
227
|
+
__modifiedFields: Set<string>;
|
|
228
|
+
/** Model name */
|
|
229
|
+
__model: string;
|
|
230
|
+
/** Model ID */
|
|
231
|
+
__id: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Query options for fetching models
|
|
236
|
+
*/
|
|
237
|
+
export interface QueryOptions<T> {
|
|
238
|
+
/** Filter function */
|
|
239
|
+
where?: (item: T) => boolean;
|
|
240
|
+
/** Sort function */
|
|
241
|
+
orderBy?: (a: T, b: T) => number;
|
|
242
|
+
/** Maximum number of results */
|
|
243
|
+
limit?: number;
|
|
244
|
+
/** Number of results to skip */
|
|
245
|
+
offset?: number;
|
|
246
|
+
/** Include soft-deleted items */
|
|
247
|
+
includeArchived?: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Query result with metadata
|
|
252
|
+
*/
|
|
253
|
+
export interface QueryResult<T> {
|
|
254
|
+
/** Query results */
|
|
255
|
+
data: T[];
|
|
256
|
+
/** Whether more results are available */
|
|
257
|
+
hasMore: boolean;
|
|
258
|
+
/** Total count (if available) */
|
|
259
|
+
totalCount?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type ModelChangeAction =
|
|
263
|
+
| "insert"
|
|
264
|
+
| "update"
|
|
265
|
+
| "delete"
|
|
266
|
+
| "archive"
|
|
267
|
+
| "unarchive";
|
|
268
|
+
|
|
269
|
+
export type SyncClientEvent =
|
|
270
|
+
| { type: "syncStart" }
|
|
271
|
+
| { type: "syncComplete"; lastSyncId: number }
|
|
272
|
+
| { type: "syncError"; error: Error }
|
|
273
|
+
| { type: "stateChange"; state: SyncClientState }
|
|
274
|
+
| { type: "connectionChange"; state: ConnectionState }
|
|
275
|
+
| { type: "outboxChange"; pendingCount: number }
|
|
276
|
+
| {
|
|
277
|
+
type: "modelChange";
|
|
278
|
+
modelName: string;
|
|
279
|
+
modelId: string;
|
|
280
|
+
action: ModelChangeAction;
|
|
281
|
+
}
|
|
282
|
+
| {
|
|
283
|
+
type: "rebaseConflict";
|
|
284
|
+
modelName: string;
|
|
285
|
+
modelId: string;
|
|
286
|
+
conflictType: string;
|
|
287
|
+
resolution: string;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Sync client interface
|
|
292
|
+
*/
|
|
293
|
+
export interface SyncClient {
|
|
294
|
+
/** Current client state */
|
|
295
|
+
state: SyncClientState;
|
|
296
|
+
|
|
297
|
+
/** Current connection state */
|
|
298
|
+
connectionState: ConnectionState;
|
|
299
|
+
|
|
300
|
+
/** Last sync ID received from server */
|
|
301
|
+
lastSyncId: number;
|
|
302
|
+
|
|
303
|
+
/** Last error (if any) */
|
|
304
|
+
lastError: Error | null;
|
|
305
|
+
|
|
306
|
+
/** Client ID */
|
|
307
|
+
clientId: string;
|
|
308
|
+
|
|
309
|
+
/** Yjs managers for live editing (if configured) */
|
|
310
|
+
yjs?: {
|
|
311
|
+
documentManager: YjsDocumentManager;
|
|
312
|
+
presenceManager: YjsPresenceManager;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Start the sync client */
|
|
316
|
+
start(): Promise<void>;
|
|
317
|
+
|
|
318
|
+
/** Stop the sync client */
|
|
319
|
+
stop(): Promise<void>;
|
|
320
|
+
|
|
321
|
+
/** Get a model by ID */
|
|
322
|
+
get<T>(modelName: string, id: string): Promise<T | null>;
|
|
323
|
+
|
|
324
|
+
/** Get a cached model by ID (no network) */
|
|
325
|
+
getCached<T>(modelName: string, id: string): T | null;
|
|
326
|
+
|
|
327
|
+
/** Ensure a model is available locally, loading if needed */
|
|
328
|
+
ensureModel<T>(modelName: string, id: string): Promise<T | null>;
|
|
329
|
+
|
|
330
|
+
/** Get all models of a type */
|
|
331
|
+
getAll<T>(modelName: string, options?: QueryOptions<T>): Promise<T[]>;
|
|
332
|
+
|
|
333
|
+
/** Query models */
|
|
334
|
+
query<T>(
|
|
335
|
+
modelName: string,
|
|
336
|
+
options?: QueryOptions<T>
|
|
337
|
+
): Promise<QueryResult<T>>;
|
|
338
|
+
|
|
339
|
+
/** Create a new model */
|
|
340
|
+
create<T extends Record<string, unknown>>(
|
|
341
|
+
modelName: string,
|
|
342
|
+
data: T,
|
|
343
|
+
options?: { onTransactionCreated?: (tx: Transaction) => void }
|
|
344
|
+
): Promise<T>;
|
|
345
|
+
|
|
346
|
+
/** Update a model */
|
|
347
|
+
update<T extends Record<string, unknown>>(
|
|
348
|
+
modelName: string,
|
|
349
|
+
id: string,
|
|
350
|
+
changes: Partial<T>,
|
|
351
|
+
options?: {
|
|
352
|
+
original?: Record<string, unknown>;
|
|
353
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
354
|
+
}
|
|
355
|
+
): Promise<T>;
|
|
356
|
+
|
|
357
|
+
/** Delete a model */
|
|
358
|
+
delete(
|
|
359
|
+
modelName: string,
|
|
360
|
+
id: string,
|
|
361
|
+
options?: {
|
|
362
|
+
original?: Record<string, unknown>;
|
|
363
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
364
|
+
}
|
|
365
|
+
): Promise<void>;
|
|
366
|
+
|
|
367
|
+
/** Archive a model (soft delete) */
|
|
368
|
+
archive(
|
|
369
|
+
modelName: string,
|
|
370
|
+
id: string,
|
|
371
|
+
options?: {
|
|
372
|
+
original?: Record<string, unknown>;
|
|
373
|
+
archivedAt?: string;
|
|
374
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
375
|
+
}
|
|
376
|
+
): Promise<void>;
|
|
377
|
+
|
|
378
|
+
/** Unarchive a model */
|
|
379
|
+
unarchive(
|
|
380
|
+
modelName: string,
|
|
381
|
+
id: string,
|
|
382
|
+
options?: {
|
|
383
|
+
original?: Record<string, unknown>;
|
|
384
|
+
onTransactionCreated?: (tx: Transaction) => void;
|
|
385
|
+
}
|
|
386
|
+
): Promise<void>;
|
|
387
|
+
|
|
388
|
+
/** Whether undo is available */
|
|
389
|
+
canUndo(): boolean;
|
|
390
|
+
|
|
391
|
+
/** Whether redo is available */
|
|
392
|
+
canRedo(): boolean;
|
|
393
|
+
|
|
394
|
+
/** Undo the last operation */
|
|
395
|
+
undo(): Promise<void>;
|
|
396
|
+
|
|
397
|
+
/** Redo the last undone operation */
|
|
398
|
+
redo(): Promise<void>;
|
|
399
|
+
|
|
400
|
+
/** Subscribe to events */
|
|
401
|
+
onEvent(callback: (event: SyncClientEvent) => void): () => void;
|
|
402
|
+
|
|
403
|
+
/** Subscribe to state changes */
|
|
404
|
+
onStateChange(callback: (state: SyncClientState) => void): () => void;
|
|
405
|
+
|
|
406
|
+
/** Subscribe to connection state changes */
|
|
407
|
+
onConnectionStateChange(
|
|
408
|
+
callback: (state: ConnectionState) => void
|
|
409
|
+
): () => void;
|
|
410
|
+
|
|
411
|
+
/** Get pending transaction count */
|
|
412
|
+
getPendingCount(): Promise<number>;
|
|
413
|
+
|
|
414
|
+
/** Force a sync now */
|
|
415
|
+
syncNow(): Promise<void>;
|
|
416
|
+
|
|
417
|
+
/** Clear all local data */
|
|
418
|
+
clearAll(): Promise<void>;
|
|
419
|
+
|
|
420
|
+
/** Get the identity map for a model type */
|
|
421
|
+
getIdentityMap<T>(modelName: string): Map<string, T>;
|
|
422
|
+
|
|
423
|
+
/** Whether a model was previously missing in storage/network */
|
|
424
|
+
isModelMissing(modelName: string, id: string): boolean;
|
|
425
|
+
}
|