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