@eide/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,542 @@
1
+ import * as y_protocols_awareness from 'y-protocols/awareness';
2
+ import * as yjs from 'yjs';
3
+
4
+ /**
5
+ * Storage Adapter — Abstract interface for local persistence.
6
+ *
7
+ * Implementations: IndexedDB (web), Memory (tests/SSR).
8
+ * Each "store" is a named key-value namespace.
9
+ */
10
+ interface StorageAdapter {
11
+ /** Get a single value by key from a store */
12
+ get<T>(store: string, key: string): Promise<T | undefined>;
13
+ /** Put a value by key into a store */
14
+ put<T>(store: string, key: string, value: T): Promise<void>;
15
+ /** Delete a value by key from a store */
16
+ delete(store: string, key: string): Promise<void>;
17
+ /** Get all values from a store */
18
+ getAll<T>(store: string): Promise<T[]>;
19
+ /** Clear all values from a store */
20
+ clear(store: string): Promise<void>;
21
+ /** Get a metadata value (separate namespace from record stores) */
22
+ getMeta(key: string): Promise<string | undefined>;
23
+ /** Set a metadata value */
24
+ setMeta(key: string, value: string): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Core types for the sync engine.
29
+ *
30
+ * These mirror the server-side GraphQL schema but are
31
+ * framework-agnostic and use native JS types (no BigInt transport concerns).
32
+ */
33
+ interface SyncableRecord {
34
+ /** Server-assigned ID (empty string for locally-created records not yet synced) */
35
+ id: string;
36
+ /** Client-assigned temporary ID */
37
+ clientId: string;
38
+ modelKey: string;
39
+ naturalKey: string | null;
40
+ data: Record<string, unknown>;
41
+ metadata: Record<string, unknown> | null;
42
+ /** Monotonically increasing version from server. "0" for local-only records. */
43
+ syncVersion: string;
44
+ updatedAt: string;
45
+ deleted: boolean;
46
+ /** True if this record has local changes not yet pushed to server */
47
+ pending: boolean;
48
+ }
49
+ interface PendingMutation {
50
+ /** Auto-generated queue ID */
51
+ queueId: string;
52
+ /** Client-assigned temporary ID or server ID */
53
+ clientId: string;
54
+ op: 'create' | 'update' | 'delete';
55
+ modelKey: string;
56
+ naturalKey?: string;
57
+ data?: Record<string, unknown>;
58
+ /** The syncVersion the client expects (for optimistic locking) */
59
+ expectedSyncVersion?: string;
60
+ /** ISO timestamp when the mutation was created */
61
+ createdAt: string;
62
+ /** Number of times this mutation has been attempted */
63
+ retryCount: number;
64
+ }
65
+ interface SyncClientConfig {
66
+ /** GraphQL endpoint URL (e.g. https://api.example.com/graphql) */
67
+ graphqlUrl: string;
68
+ /** WebSocket URL for subscriptions (e.g. wss://api.example.com/graphql) */
69
+ wsUrl?: string;
70
+ /** API key for authentication */
71
+ apiKey?: string;
72
+ /** Bearer token for authentication */
73
+ token?: string;
74
+ /** Model keys to sync (subscribes to changes for each) */
75
+ modelKeys: string[];
76
+ /** Polling interval in ms when WebSocket is unavailable (default: 5000) */
77
+ pollInterval?: number;
78
+ /** Max items per pull request (default: 100) */
79
+ pullLimit?: number;
80
+ /** Max items per push batch (default: 50) */
81
+ pushBatchSize?: number;
82
+ /** Conflict resolution strategy (default: 'server-wins') */
83
+ conflictStrategy?: ConflictStrategy;
84
+ /** Custom conflict resolver (used when strategy is 'custom') */
85
+ customResolver?: CustomConflictResolver;
86
+ /** Whether to enable debug logging (default: false) */
87
+ debug?: boolean;
88
+ }
89
+ interface QueryOptions {
90
+ /** Filter by naturalKey */
91
+ naturalKey?: string;
92
+ /** Filter function applied client-side */
93
+ filter?: (record: SyncableRecord) => boolean;
94
+ /** Sort function */
95
+ sort?: (a: SyncableRecord, b: SyncableRecord) => number;
96
+ /** Limit results */
97
+ limit?: number;
98
+ /** Skip N results */
99
+ offset?: number;
100
+ }
101
+ interface SyncResult {
102
+ pulled: number;
103
+ pushed: number;
104
+ conflicts: number;
105
+ errors: number;
106
+ }
107
+ type ConflictStrategy = 'field-lww' | 'server-wins' | 'client-wins' | 'custom';
108
+ type CustomConflictResolver = (local: Record<string, unknown>, server: Record<string, unknown>) => {
109
+ resolved: Record<string, unknown>;
110
+ conflictedFields: string[];
111
+ };
112
+ interface ConflictEvent {
113
+ recordId: string;
114
+ modelKey: string;
115
+ localData: Record<string, unknown>;
116
+ serverData: Record<string, unknown>;
117
+ resolvedData: Record<string, unknown>;
118
+ conflictedFields: string[];
119
+ strategy: ConflictStrategy;
120
+ }
121
+ type SyncEngineEvent = {
122
+ type: 'sync-start';
123
+ } | {
124
+ type: 'sync-complete';
125
+ result: SyncResult;
126
+ } | {
127
+ type: 'sync-error';
128
+ error: Error;
129
+ } | {
130
+ type: 'conflict';
131
+ event: ConflictEvent;
132
+ } | {
133
+ type: 'connected';
134
+ } | {
135
+ type: 'disconnected';
136
+ } | {
137
+ type: 'records-changed';
138
+ modelKey: string;
139
+ recordIds: string[];
140
+ } | {
141
+ type: 'pending-changed';
142
+ count: number;
143
+ };
144
+ type SyncEngineEventHandler = (event: SyncEngineEvent) => void;
145
+ interface GqlSyncDeltaItem {
146
+ id: string;
147
+ modelKey: string;
148
+ naturalKey: string | null;
149
+ data: Record<string, unknown> | null;
150
+ metadata: Record<string, unknown> | null;
151
+ syncVersion: string;
152
+ updatedAt: string;
153
+ deleted: boolean;
154
+ }
155
+ interface GqlSyncDeltaResult {
156
+ items: GqlSyncDeltaItem[];
157
+ cursor: string;
158
+ hasMore: boolean;
159
+ }
160
+ interface GqlSyncPushResultItem {
161
+ clientId: string;
162
+ serverId: string;
163
+ syncVersion: string;
164
+ status: 'applied' | 'conflict' | 'error';
165
+ serverData?: Record<string, unknown> | null;
166
+ serverSyncVersion?: string;
167
+ error?: string;
168
+ }
169
+ interface GqlRecordChangedEvent {
170
+ type: 'created' | 'updated' | 'deleted';
171
+ recordId: string;
172
+ modelKey: string;
173
+ naturalKey: string | null;
174
+ syncVersion: string;
175
+ data: Record<string, unknown> | null;
176
+ updatedBy: string | null;
177
+ timestamp: string;
178
+ }
179
+
180
+ /**
181
+ * SyncEngine — Layer 1 orchestrator for local-first data sync.
182
+ *
183
+ * Provides:
184
+ * - Instant local reads/writes via StorageAdapter
185
+ * - Background pull/push sync loop with the server
186
+ * - Real-time subscription for live updates (WebSocket, falls back to polling)
187
+ * - Offline queue with retry and conflict resolution
188
+ */
189
+
190
+ declare class SyncEngine {
191
+ private config;
192
+ private storage;
193
+ private queue;
194
+ private listeners;
195
+ private syncTimer;
196
+ private wsCleanup;
197
+ private running;
198
+ private syncing;
199
+ constructor(config: SyncClientConfig, storage: StorageAdapter);
200
+ on(handler: SyncEngineEventHandler): () => void;
201
+ private emit;
202
+ private log;
203
+ /**
204
+ * Start background sync loop and subscriptions.
205
+ */
206
+ start(): void;
207
+ /**
208
+ * Stop sync engine, close connections.
209
+ */
210
+ stop(): void;
211
+ /**
212
+ * Force an immediate sync cycle.
213
+ */
214
+ sync(): Promise<SyncResult>;
215
+ /**
216
+ * Query records from local storage.
217
+ */
218
+ query(modelKey: string, opts?: QueryOptions): Promise<SyncableRecord[]>;
219
+ /**
220
+ * Get a single record by server ID.
221
+ */
222
+ get(modelKey: string, id: string): Promise<SyncableRecord | null>;
223
+ /**
224
+ * Get a record by natural key.
225
+ */
226
+ getByKey(modelKey: string, naturalKey: string): Promise<SyncableRecord | null>;
227
+ /**
228
+ * Create a record locally and queue for push.
229
+ */
230
+ create(modelKey: string, data: Record<string, unknown>, naturalKey?: string): Promise<SyncableRecord>;
231
+ /**
232
+ * Update a record locally and queue for push.
233
+ */
234
+ update(modelKey: string, id: string, data: Record<string, unknown>): Promise<SyncableRecord | null>;
235
+ /**
236
+ * Delete a record locally and queue for push.
237
+ */
238
+ delete(modelKey: string, id: string): Promise<void>;
239
+ /**
240
+ * Get count of pending mutations.
241
+ */
242
+ getPendingCount(): Promise<number>;
243
+ private pullModel;
244
+ private pushPending;
245
+ private connectSubscriptions;
246
+ private setupWebSocket;
247
+ private handleSubscriptionEvent;
248
+ private graphqlQuery;
249
+ private storeKey;
250
+ }
251
+
252
+ /**
253
+ * Conflict Resolution — Field-level LWW merge and configurable strategies.
254
+ *
255
+ * When the server rejects a push with status='conflict', the client must
256
+ * reconcile local and server state before retrying.
257
+ */
258
+
259
+ interface ConflictResolution {
260
+ resolved: Record<string, unknown>;
261
+ conflictedFields: string[];
262
+ }
263
+ /**
264
+ * Resolve conflicts between local and server data.
265
+ */
266
+ declare function resolveConflict(local: Record<string, unknown>, server: Record<string, unknown>, strategy: ConflictStrategy, customResolver?: CustomConflictResolver): ConflictResolution;
267
+
268
+ /**
269
+ * Offline Queue — Manages pending mutations that haven't been pushed to the server.
270
+ *
271
+ * Mutations are stored in the storage adapter and replayed in order
272
+ * when the sync engine pushes. Supports deduplication (latest update wins).
273
+ */
274
+
275
+ declare class OfflineQueue {
276
+ private storeName;
277
+ private storage;
278
+ constructor(storage: StorageAdapter);
279
+ /**
280
+ * Enqueue a new mutation. Deduplicates by clientId —
281
+ * if a pending mutation already exists for the same clientId with the same op,
282
+ * the newer one replaces it (for updates).
283
+ */
284
+ enqueue(mutation: Omit<PendingMutation, 'queueId' | 'createdAt' | 'retryCount'>): Promise<PendingMutation>;
285
+ /**
286
+ * Get all pending mutations in creation order.
287
+ */
288
+ getAll(): Promise<PendingMutation[]>;
289
+ /**
290
+ * Get a batch of mutations for pushing.
291
+ */
292
+ getBatch(limit: number): Promise<PendingMutation[]>;
293
+ /**
294
+ * Remove a mutation after successful push.
295
+ */
296
+ remove(queueId: string): Promise<void>;
297
+ /**
298
+ * Remove multiple mutations.
299
+ */
300
+ removeBatch(queueIds: string[]): Promise<void>;
301
+ /**
302
+ * Increment retry count for a failed mutation.
303
+ */
304
+ incrementRetry(queueId: string): Promise<void>;
305
+ /**
306
+ * Get count of pending mutations.
307
+ */
308
+ count(): Promise<number>;
309
+ /**
310
+ * Clear all pending mutations.
311
+ */
312
+ clear(): Promise<void>;
313
+ }
314
+
315
+ /**
316
+ * Memory Storage Adapter — In-memory implementation for tests and SSR.
317
+ *
318
+ * All data is lost when the adapter is garbage collected.
319
+ */
320
+
321
+ declare class MemoryAdapter implements StorageAdapter {
322
+ private stores;
323
+ private meta;
324
+ private getStore;
325
+ get<T>(store: string, key: string): Promise<T | undefined>;
326
+ put<T>(store: string, key: string, value: T): Promise<void>;
327
+ delete(store: string, key: string): Promise<void>;
328
+ getAll<T>(store: string): Promise<T[]>;
329
+ clear(store: string): Promise<void>;
330
+ getMeta(key: string): Promise<string | undefined>;
331
+ setMeta(key: string, value: string): Promise<void>;
332
+ }
333
+
334
+ /**
335
+ * IndexedDB Storage Adapter — Persistent browser storage via the `idb` library.
336
+ *
337
+ * Uses a single IndexedDB database with one object store per "store" name.
338
+ * Store names are created lazily on first access via version upgrades.
339
+ */
340
+
341
+ declare class IndexedDBAdapter implements StorageAdapter {
342
+ private dbName;
343
+ private db;
344
+ private knownStores;
345
+ private version;
346
+ constructor(dbName?: string);
347
+ private ensureStore;
348
+ get<T>(store: string, key: string): Promise<T | undefined>;
349
+ put<T>(store: string, key: string, value: T): Promise<void>;
350
+ delete(store: string, key: string): Promise<void>;
351
+ getAll<T>(store: string): Promise<T[]>;
352
+ clear(store: string): Promise<void>;
353
+ getMeta(key: string): Promise<string | undefined>;
354
+ setMeta(key: string, value: string): Promise<void>;
355
+ }
356
+
357
+ /**
358
+ * Collaborative Editing Types — Layer 2.
359
+ */
360
+ interface SessionUser {
361
+ userId: string;
362
+ userName: string;
363
+ color: string;
364
+ joinedAt?: string;
365
+ }
366
+ interface SessionChange {
367
+ id: string;
368
+ fieldPath: string;
369
+ fieldValue: unknown;
370
+ previousValue?: unknown;
371
+ userId: string;
372
+ userName?: string;
373
+ createdAt: string;
374
+ sessionId?: string;
375
+ appliedToVersion?: boolean;
376
+ }
377
+ interface CollabSessionOptions {
378
+ /** WebSocket URL for Yjs (e.g. ws://localhost:4000/yjs) */
379
+ wsUrl: string;
380
+ /** Room name (format: entityType:entityId:variantId) */
381
+ room: string;
382
+ /** User info for presence */
383
+ user?: {
384
+ id: string;
385
+ name: string;
386
+ color?: string;
387
+ };
388
+ /** API base URL for clear-session REST call (optional, for admin compat) */
389
+ apiUrl?: string;
390
+ /** Authentication mode */
391
+ auth?: {
392
+ /** Session cookie name (admin mode — cookies sent automatically) */
393
+ sessionCookie?: boolean;
394
+ /** API key (public mode — sent as query param) */
395
+ apiKey?: string;
396
+ /** Bearer token (public mode — sent as query param) */
397
+ token?: string;
398
+ };
399
+ }
400
+ interface CollabFieldUpdateEvent {
401
+ path: string;
402
+ value: unknown;
403
+ origin: string;
404
+ }
405
+ interface CollabUndoStateEvent {
406
+ canUndo: boolean;
407
+ canRedo: boolean;
408
+ }
409
+
410
+ type YDoc = yjs.Doc;
411
+ type YMap = yjs.Map<unknown>;
412
+ type YArray = yjs.Array<SessionChange>;
413
+ type YUndoManager = yjs.UndoManager;
414
+ type Awareness = y_protocols_awareness.Awareness;
415
+ type EventMap = {
416
+ 'field-updated': CollabFieldUpdateEvent;
417
+ 'changelog-changed': SessionChange[];
418
+ 'presence-changed': SessionUser[];
419
+ 'can-undo-changed': CollabUndoStateEvent;
420
+ connected: void;
421
+ synced: void;
422
+ disconnected: void;
423
+ };
424
+ type EventHandler<T> = (data: T) => void;
425
+ declare class CollabSession {
426
+ private _ydoc;
427
+ private _content;
428
+ private _changesLog;
429
+ private _undoManager;
430
+ private _awareness;
431
+ private provider;
432
+ private options;
433
+ private _connected;
434
+ private _synced;
435
+ private _activeUsers;
436
+ private _sessionChanges;
437
+ private _canUndo;
438
+ private _canRedo;
439
+ private fieldSessions;
440
+ private listeners;
441
+ private cleanupFns;
442
+ constructor(options: CollabSessionOptions);
443
+ get ydoc(): YDoc | null;
444
+ get content(): YMap | null;
445
+ get changesLog(): YArray | null;
446
+ get undoManager(): YUndoManager | null;
447
+ get awareness(): Awareness | null;
448
+ get connected(): boolean;
449
+ get synced(): boolean;
450
+ get activeUsers(): SessionUser[];
451
+ get sessionChanges(): SessionChange[];
452
+ get canUndo(): boolean;
453
+ get canRedo(): boolean;
454
+ get room(): string;
455
+ on<K extends keyof EventMap>(event: K, handler: EventHandler<EventMap[K]>): () => void;
456
+ private emit;
457
+ connect(): Promise<void>;
458
+ disconnect(): void;
459
+ private setupPresence;
460
+ setUser(user: {
461
+ id: string;
462
+ name: string;
463
+ color?: string;
464
+ }): void;
465
+ private setupContentObservation;
466
+ updateField(fieldPath: string, value: unknown): void;
467
+ initializeContent(values: Record<string, unknown>, versionId?: string): void;
468
+ getContent(): Record<string, unknown>;
469
+ getContentStatus(): {
470
+ hasContent: boolean;
471
+ versionId: string | null;
472
+ };
473
+ loadContentIntoForm(): boolean;
474
+ undo(): void;
475
+ redo(): void;
476
+ revertField(fieldPath: string, previousValue: unknown, changeId?: string): void;
477
+ revertAll(): void;
478
+ clearSession(): Promise<void>;
479
+ /**
480
+ * Suppress changes for a duration (ms). Used to prevent phantom
481
+ * change entries after save/clear.
482
+ */
483
+ suppressChanges(durationMs: number): void;
484
+ clearContent(): void;
485
+ }
486
+
487
+ /**
488
+ * Presence helpers — user colors and awareness utilities.
489
+ */
490
+ /**
491
+ * Generate a consistent color for a user based on their ID.
492
+ */
493
+ declare function getUserColor(userId: string): string;
494
+
495
+ /**
496
+ * SyncBridge — Bidirectional coordination between Layer 1 (sync) and Layer 2 (collab).
497
+ *
498
+ * Responsibilities:
499
+ * 1. Auto-save: CollabSession field edits → SyncEngine.update() → push to server
500
+ * 2. Remote changes: SyncEngine subscription → CollabSession Y.Doc update
501
+ * 3. Loop prevention: Origin tracking prevents infinite push/pull cycles
502
+ */
503
+
504
+ interface SyncBridgeOptions {
505
+ /** The record ID being collaboratively edited */
506
+ recordId: string;
507
+ /** The model key for the record */
508
+ modelKey: string;
509
+ /** Enable auto-save mode (default: true) */
510
+ autoSave?: boolean;
511
+ }
512
+ declare class SyncBridge {
513
+ private engine;
514
+ private session;
515
+ private options;
516
+ private cleanupFns;
517
+ private autoSaveTimer;
518
+ /** Tracks our own writes to prevent re-applying them from subscription */
519
+ private selfWriteVersions;
520
+ constructor(engine: SyncEngine, session: CollabSession, options: SyncBridgeOptions);
521
+ private setup;
522
+ /**
523
+ * Auto-save: read content from CollabSession Y.Doc, push via SyncEngine.
524
+ */
525
+ private autoSave;
526
+ /**
527
+ * Apply remote changes from SyncEngine to CollabSession Y.Doc.
528
+ * Uses 'remote-sync' origin to prevent re-pushing.
529
+ */
530
+ private applyRemoteChanges;
531
+ /**
532
+ * Manual save: used for "version save" mode (admin clicks Save button).
533
+ * Reads content from CollabSession, pushes immediately, then clears session.
534
+ */
535
+ saveVersion(): Promise<Record<string, unknown>>;
536
+ /**
537
+ * Destroy the bridge and clean up all subscriptions.
538
+ */
539
+ destroy(): void;
540
+ }
541
+
542
+ export { type CollabFieldUpdateEvent, CollabSession, type CollabSessionOptions, type CollabUndoStateEvent, type ConflictEvent, type ConflictStrategy, type CustomConflictResolver, type GqlRecordChangedEvent, type GqlSyncDeltaItem, type GqlSyncDeltaResult, type GqlSyncPushResultItem, IndexedDBAdapter, MemoryAdapter, OfflineQueue, type PendingMutation, type QueryOptions, type SessionChange, type SessionUser, type StorageAdapter, SyncBridge, type SyncBridgeOptions, type SyncClientConfig, SyncEngine, type SyncEngineEvent, type SyncEngineEventHandler, type SyncResult, type SyncableRecord, getUserColor, resolveConflict };