@fatagnus/dink-sync 1.0.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.
Files changed (186) hide show
  1. package/README.md +312 -0
  2. package/dist/client/attachment.d.ts +225 -0
  3. package/dist/client/attachment.d.ts.map +1 -0
  4. package/dist/client/attachment.js +402 -0
  5. package/dist/client/attachment.js.map +1 -0
  6. package/dist/client/binary-encoding.d.ts +45 -0
  7. package/dist/client/binary-encoding.d.ts.map +1 -0
  8. package/dist/client/binary-encoding.js +90 -0
  9. package/dist/client/binary-encoding.js.map +1 -0
  10. package/dist/client/collection.d.ts +10 -0
  11. package/dist/client/collection.d.ts.map +1 -0
  12. package/dist/client/collection.js +924 -0
  13. package/dist/client/collection.js.map +1 -0
  14. package/dist/client/compression.d.ts +56 -0
  15. package/dist/client/compression.d.ts.map +1 -0
  16. package/dist/client/compression.js +173 -0
  17. package/dist/client/compression.js.map +1 -0
  18. package/dist/client/crdt/index.d.ts +2 -0
  19. package/dist/client/crdt/index.d.ts.map +1 -0
  20. package/dist/client/crdt/index.js +2 -0
  21. package/dist/client/crdt/index.js.map +1 -0
  22. package/dist/client/crdt/yjs-doc.d.ts +88 -0
  23. package/dist/client/crdt/yjs-doc.d.ts.map +1 -0
  24. package/dist/client/crdt/yjs-doc.js +123 -0
  25. package/dist/client/crdt/yjs-doc.js.map +1 -0
  26. package/dist/client/index.d.ts +66 -0
  27. package/dist/client/index.d.ts.map +1 -0
  28. package/dist/client/index.js +233 -0
  29. package/dist/client/index.js.map +1 -0
  30. package/dist/client/mock-transport.d.ts +155 -0
  31. package/dist/client/mock-transport.d.ts.map +1 -0
  32. package/dist/client/mock-transport.js +292 -0
  33. package/dist/client/mock-transport.js.map +1 -0
  34. package/dist/client/network-detector.d.ts +65 -0
  35. package/dist/client/network-detector.d.ts.map +1 -0
  36. package/dist/client/network-detector.js +147 -0
  37. package/dist/client/network-detector.js.map +1 -0
  38. package/dist/client/provisioning.d.ts +126 -0
  39. package/dist/client/provisioning.d.ts.map +1 -0
  40. package/dist/client/provisioning.js +125 -0
  41. package/dist/client/provisioning.js.map +1 -0
  42. package/dist/client/signal.d.ts +13 -0
  43. package/dist/client/signal.d.ts.map +1 -0
  44. package/dist/client/signal.js +27 -0
  45. package/dist/client/signal.js.map +1 -0
  46. package/dist/client/sync-engine.d.ts +298 -0
  47. package/dist/client/sync-engine.d.ts.map +1 -0
  48. package/dist/client/sync-engine.js +904 -0
  49. package/dist/client/sync-engine.js.map +1 -0
  50. package/dist/client/synced-edge.d.ts +109 -0
  51. package/dist/client/synced-edge.d.ts.map +1 -0
  52. package/dist/client/synced-edge.js +179 -0
  53. package/dist/client/synced-edge.js.map +1 -0
  54. package/dist/client/synced-offline-edge-types.d.ts +540 -0
  55. package/dist/client/synced-offline-edge-types.d.ts.map +1 -0
  56. package/dist/client/synced-offline-edge-types.js +10 -0
  57. package/dist/client/synced-offline-edge-types.js.map +1 -0
  58. package/dist/client/synced-offline-edge.d.ts +54 -0
  59. package/dist/client/synced-offline-edge.d.ts.map +1 -0
  60. package/dist/client/synced-offline-edge.js +731 -0
  61. package/dist/client/synced-offline-edge.js.map +1 -0
  62. package/dist/client/transport.d.ts +202 -0
  63. package/dist/client/transport.d.ts.map +1 -0
  64. package/dist/client/transport.js +409 -0
  65. package/dist/client/transport.js.map +1 -0
  66. package/dist/client/types.d.ts +622 -0
  67. package/dist/client/types.d.ts.map +1 -0
  68. package/dist/client/types.js +60 -0
  69. package/dist/client/types.js.map +1 -0
  70. package/dist/client/validation.d.ts +61 -0
  71. package/dist/client/validation.d.ts.map +1 -0
  72. package/dist/client/validation.js +57 -0
  73. package/dist/client/validation.js.map +1 -0
  74. package/dist/client/versioning.d.ts +134 -0
  75. package/dist/client/versioning.d.ts.map +1 -0
  76. package/dist/client/versioning.js +304 -0
  77. package/dist/client/versioning.js.map +1 -0
  78. package/dist/index.d.ts +40 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +51 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/persistence/encryption.d.ts +114 -0
  83. package/dist/persistence/encryption.d.ts.map +1 -0
  84. package/dist/persistence/encryption.js +286 -0
  85. package/dist/persistence/encryption.js.map +1 -0
  86. package/dist/persistence/index.d.ts +21 -0
  87. package/dist/persistence/index.d.ts.map +1 -0
  88. package/dist/persistence/index.js +20 -0
  89. package/dist/persistence/index.js.map +1 -0
  90. package/dist/persistence/memory.d.ts +32 -0
  91. package/dist/persistence/memory.d.ts.map +1 -0
  92. package/dist/persistence/memory.js +57 -0
  93. package/dist/persistence/memory.js.map +1 -0
  94. package/dist/persistence/migrations.d.ts +106 -0
  95. package/dist/persistence/migrations.d.ts.map +1 -0
  96. package/dist/persistence/migrations.js +176 -0
  97. package/dist/persistence/migrations.js.map +1 -0
  98. package/dist/persistence/pending-queue.d.ts +109 -0
  99. package/dist/persistence/pending-queue.d.ts.map +1 -0
  100. package/dist/persistence/pending-queue.js +249 -0
  101. package/dist/persistence/pending-queue.js.map +1 -0
  102. package/dist/persistence/pglite.d.ts +72 -0
  103. package/dist/persistence/pglite.d.ts.map +1 -0
  104. package/dist/persistence/pglite.js +126 -0
  105. package/dist/persistence/pglite.js.map +1 -0
  106. package/dist/persistence/quota-manager.d.ts +134 -0
  107. package/dist/persistence/quota-manager.d.ts.map +1 -0
  108. package/dist/persistence/quota-manager.js +242 -0
  109. package/dist/persistence/quota-manager.js.map +1 -0
  110. package/dist/persistence/types.d.ts +54 -0
  111. package/dist/persistence/types.d.ts.map +1 -0
  112. package/dist/persistence/types.js +2 -0
  113. package/dist/persistence/types.js.map +1 -0
  114. package/dist/react/OfflineEdgeProvider.d.ts +91 -0
  115. package/dist/react/OfflineEdgeProvider.d.ts.map +1 -0
  116. package/dist/react/OfflineEdgeProvider.js +127 -0
  117. package/dist/react/OfflineEdgeProvider.js.map +1 -0
  118. package/dist/react/SyncedOfflineEdgeProvider.d.ts +105 -0
  119. package/dist/react/SyncedOfflineEdgeProvider.d.ts.map +1 -0
  120. package/dist/react/SyncedOfflineEdgeProvider.js +138 -0
  121. package/dist/react/SyncedOfflineEdgeProvider.js.map +1 -0
  122. package/dist/react/index.d.ts +50 -0
  123. package/dist/react/index.d.ts.map +1 -0
  124. package/dist/react/index.js +51 -0
  125. package/dist/react/index.js.map +1 -0
  126. package/dist/react/useCollection.d.ts +77 -0
  127. package/dist/react/useCollection.d.ts.map +1 -0
  128. package/dist/react/useCollection.js +113 -0
  129. package/dist/react/useCollection.js.map +1 -0
  130. package/dist/react/useCollectionSyncMode.d.ts +61 -0
  131. package/dist/react/useCollectionSyncMode.d.ts.map +1 -0
  132. package/dist/react/useCollectionSyncMode.js +93 -0
  133. package/dist/react/useCollectionSyncMode.js.map +1 -0
  134. package/dist/react/useConnectionState.d.ts +44 -0
  135. package/dist/react/useConnectionState.d.ts.map +1 -0
  136. package/dist/react/useConnectionState.js +46 -0
  137. package/dist/react/useConnectionState.js.map +1 -0
  138. package/dist/react/useDocumentSyncStatus.d.ts +72 -0
  139. package/dist/react/useDocumentSyncStatus.d.ts.map +1 -0
  140. package/dist/react/useDocumentSyncStatus.js +110 -0
  141. package/dist/react/useDocumentSyncStatus.js.map +1 -0
  142. package/dist/react/useOfflineEdge.d.ts +58 -0
  143. package/dist/react/useOfflineEdge.d.ts.map +1 -0
  144. package/dist/react/useOfflineEdge.js +54 -0
  145. package/dist/react/useOfflineEdge.js.map +1 -0
  146. package/dist/react/usePendingChanges.d.ts +67 -0
  147. package/dist/react/usePendingChanges.d.ts.map +1 -0
  148. package/dist/react/usePendingChanges.js +90 -0
  149. package/dist/react/usePendingChanges.js.map +1 -0
  150. package/dist/react/useRejectedDocuments.d.ts +112 -0
  151. package/dist/react/useRejectedDocuments.d.ts.map +1 -0
  152. package/dist/react/useRejectedDocuments.js +213 -0
  153. package/dist/react/useRejectedDocuments.js.map +1 -0
  154. package/dist/react/useSyncControls.d.ts +96 -0
  155. package/dist/react/useSyncControls.d.ts.map +1 -0
  156. package/dist/react/useSyncControls.js +112 -0
  157. package/dist/react/useSyncControls.js.map +1 -0
  158. package/dist/react/useSyncProgress.d.ts +78 -0
  159. package/dist/react/useSyncProgress.d.ts.map +1 -0
  160. package/dist/react/useSyncProgress.js +90 -0
  161. package/dist/react/useSyncProgress.js.map +1 -0
  162. package/dist/react/useSyncRejected.d.ts +47 -0
  163. package/dist/react/useSyncRejected.d.ts.map +1 -0
  164. package/dist/react/useSyncRejected.js +55 -0
  165. package/dist/react/useSyncRejected.js.map +1 -0
  166. package/dist/react/useSyncStatus.d.ts +56 -0
  167. package/dist/react/useSyncStatus.d.ts.map +1 -0
  168. package/dist/react/useSyncStatus.js +59 -0
  169. package/dist/react/useSyncStatus.js.map +1 -0
  170. package/dist/react/useSyncedOfflineEdge.d.ts +69 -0
  171. package/dist/react/useSyncedOfflineEdge.d.ts.map +1 -0
  172. package/dist/react/useSyncedOfflineEdge.js +65 -0
  173. package/dist/react/useSyncedOfflineEdge.js.map +1 -0
  174. package/dist/service-worker/index.d.ts +7 -0
  175. package/dist/service-worker/index.d.ts.map +1 -0
  176. package/dist/service-worker/index.js +7 -0
  177. package/dist/service-worker/index.js.map +1 -0
  178. package/dist/service-worker/sync-worker.d.ts +230 -0
  179. package/dist/service-worker/sync-worker.d.ts.map +1 -0
  180. package/dist/service-worker/sync-worker.js +471 -0
  181. package/dist/service-worker/sync-worker.js.map +1 -0
  182. package/dist/types.d.ts +6 -0
  183. package/dist/types.d.ts.map +1 -0
  184. package/dist/types.js +3 -0
  185. package/dist/types.js.map +1 -0
  186. package/package.json +95 -0
@@ -0,0 +1,904 @@
1
+ /**
2
+ * SyncEngine - NATS-based sync engine for offline-first document synchronization
3
+ *
4
+ * Implements per-document actors with:
5
+ * - Connection state tracking (online/offline/reconnecting)
6
+ * - Local change queuing with debounced batch sending
7
+ * - State vector exchange for CRDT convergence
8
+ * - Event emission for UI binding
9
+ * - Actual NATS transport for sync communication
10
+ */
11
+ import { createNatsTransport, } from './transport.js';
12
+ import { maybeCompress } from './compression.js';
13
+ // Connection state enum
14
+ export const ConnectionState = {
15
+ Offline: 'offline',
16
+ Connecting: 'connecting',
17
+ Online: 'online',
18
+ Reconnecting: 'reconnecting',
19
+ };
20
+ // Default debounce for batching local changes
21
+ export const DEFAULT_DEBOUNCE_MS = 200;
22
+ // Default max retries for sync operations
23
+ export const DEFAULT_MAX_RETRIES = 5;
24
+ // Default max delay for exponential backoff (30 seconds)
25
+ export const DEFAULT_MAX_BACKOFF_DELAY = 30000;
26
+ // Default batch size
27
+ export const DEFAULT_BATCH_SIZE = 50;
28
+ /**
29
+ * Error codes for sync rejection (server-side failures).
30
+ */
31
+ export const SyncRejectionCode = {
32
+ UniqueViolation: 'UNIQUE_VIOLATION',
33
+ ValidationError: 'VALIDATION_ERROR',
34
+ PermissionDenied: 'PERMISSION_DENIED',
35
+ NotFound: 'NOT_FOUND',
36
+ ServerError: 'SERVER_ERROR',
37
+ Unknown: 'UNKNOWN',
38
+ };
39
+ /**
40
+ * Calculate exponential backoff delay.
41
+ * Delays: 1s, 2s, 4s, 8s, 16s, capped at maxDelay (default 30s).
42
+ * @param retryCount - The current retry attempt (0-indexed)
43
+ * @param maxDelay - Maximum delay in ms (default: 30000)
44
+ * @returns Delay in milliseconds
45
+ */
46
+ export function calculateBackoffDelay(retryCount, maxDelay = DEFAULT_MAX_BACKOFF_DELAY) {
47
+ // Base delay of 1 second, doubles each retry
48
+ const delay = 1000 * Math.pow(2, retryCount);
49
+ return Math.min(delay, maxDelay);
50
+ }
51
+ /**
52
+ * Permanent error codes that should not be retried.
53
+ */
54
+ const PERMANENT_ERROR_CODES = new Set([
55
+ 'VALIDATION_ERROR',
56
+ 'PERMISSION_DENIED',
57
+ 'UNIQUE_VIOLATION',
58
+ 'NOT_FOUND',
59
+ ]);
60
+ /**
61
+ * Patterns in error messages that indicate retryable network/timeout errors.
62
+ */
63
+ const RETRYABLE_ERROR_PATTERNS = [
64
+ /network/i,
65
+ /timeout/i,
66
+ /econnrefused/i,
67
+ /etimedout/i,
68
+ /fetch failed/i,
69
+ /connection reset/i,
70
+ /connection refused/i,
71
+ ];
72
+ /**
73
+ * Determine if an error is retryable.
74
+ * Network errors, timeouts, and server errors are retryable.
75
+ * Validation, auth, and unique constraint errors are not.
76
+ * @param error - The error to check
77
+ * @returns true if the error is retryable, false otherwise
78
+ */
79
+ export function isRetryableError(error) {
80
+ // Check for permanent error codes
81
+ const errorCode = error.code;
82
+ if (errorCode && PERMANENT_ERROR_CODES.has(errorCode)) {
83
+ return false;
84
+ }
85
+ // Server errors are retryable
86
+ if (errorCode === 'SERVER_ERROR') {
87
+ return true;
88
+ }
89
+ // Check error message for retryable patterns
90
+ const message = error.message || '';
91
+ for (const pattern of RETRYABLE_ERROR_PATTERNS) {
92
+ if (pattern.test(message)) {
93
+ return true;
94
+ }
95
+ }
96
+ // Default to retryable for unknown errors (network issues, etc.)
97
+ return true;
98
+ }
99
+ /**
100
+ * Creates a document actor key from collection and documentId
101
+ */
102
+ function actorKey(collection, documentId) {
103
+ return `${collection}:${documentId}`;
104
+ }
105
+ /**
106
+ * Generate a unique change ID
107
+ */
108
+ function generateChangeId() {
109
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
110
+ return crypto.randomUUID();
111
+ }
112
+ return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`;
113
+ }
114
+ /**
115
+ * Internal document actor implementation
116
+ */
117
+ class DocumentActorImpl {
118
+ documentId;
119
+ collection;
120
+ stateVector = new Uint8Array(0);
121
+ pendingChanges = new Map(); // changeId -> delta
122
+ pendingCallbacks = new Set();
123
+ externalUpdateCallbacks = new Set();
124
+ debounceTimer = null;
125
+ debounceMs;
126
+ onSync;
127
+ pendingQueue;
128
+ persistence;
129
+ constructor(collection, documentId, debounceMs, onSync, pendingQueue = null, persistence = null) {
130
+ this.collection = collection;
131
+ this.documentId = documentId;
132
+ this.debounceMs = debounceMs;
133
+ this.onSync = onSync;
134
+ this.pendingQueue = pendingQueue;
135
+ this.persistence = persistence;
136
+ }
137
+ /**
138
+ * Load pending changes and state vector from persistence (called on startup)
139
+ */
140
+ async loadFromPersistence() {
141
+ // Load pending changes from queue
142
+ if (this.pendingQueue) {
143
+ const persisted = await this.pendingQueue.list(this.collection, this.documentId);
144
+ for (const change of persisted) {
145
+ this.pendingChanges.set(change.id, change.delta);
146
+ }
147
+ if (this.pendingChanges.size > 0) {
148
+ this.notifyPendingChange(true);
149
+ }
150
+ }
151
+ // Load state vector from persistence
152
+ if (this.persistence) {
153
+ const savedVector = await this.persistence.loadStateVector(this.collection, this.documentId);
154
+ if (savedVector) {
155
+ this.stateVector = savedVector;
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * Save state vector to persistence
161
+ */
162
+ async saveStateVectorToPersistence() {
163
+ if (this.persistence && this.stateVector.length > 0) {
164
+ await this.persistence.saveStateVector(this.collection, this.documentId, this.stateVector);
165
+ }
166
+ }
167
+ queueChange(delta) {
168
+ const wasPending = this.pendingChanges.size > 0;
169
+ const changeId = generateChangeId();
170
+ this.pendingChanges.set(changeId, delta);
171
+ // Persist to queue if available (fire and forget for performance)
172
+ if (this.pendingQueue) {
173
+ this.pendingQueue.enqueue({
174
+ id: changeId,
175
+ collection: this.collection,
176
+ docId: this.documentId,
177
+ delta,
178
+ timestamp: Date.now(),
179
+ }).catch(err => {
180
+ console.error('Failed to persist pending change:', err);
181
+ });
182
+ }
183
+ if (!wasPending) {
184
+ this.notifyPendingChange(true);
185
+ }
186
+ // Debounce sync
187
+ if (this.debounceTimer) {
188
+ clearTimeout(this.debounceTimer);
189
+ }
190
+ this.debounceTimer = setTimeout(() => {
191
+ this.onSync();
192
+ }, this.debounceMs);
193
+ }
194
+ hasPendingChanges() {
195
+ return this.pendingChanges.size > 0;
196
+ }
197
+ onPendingChange(callback) {
198
+ this.pendingCallbacks.add(callback);
199
+ // Emit current state immediately
200
+ callback(this.hasPendingChanges());
201
+ return () => {
202
+ this.pendingCallbacks.delete(callback);
203
+ };
204
+ }
205
+ getStateVector() {
206
+ return this.stateVector;
207
+ }
208
+ setStateVector(vector) {
209
+ this.stateVector = vector;
210
+ }
211
+ applyExternalUpdate(update) {
212
+ for (const callback of this.externalUpdateCallbacks) {
213
+ callback(update);
214
+ }
215
+ }
216
+ onExternalUpdate(callback) {
217
+ this.externalUpdateCallbacks.add(callback);
218
+ return () => {
219
+ this.externalUpdateCallbacks.delete(callback);
220
+ };
221
+ }
222
+ async clearPending() {
223
+ if (this.pendingChanges.size > 0) {
224
+ // Acknowledge all changes in persistence
225
+ if (this.pendingQueue) {
226
+ for (const changeId of this.pendingChanges.keys()) {
227
+ await this.pendingQueue.acknowledge(changeId);
228
+ }
229
+ }
230
+ this.pendingChanges.clear();
231
+ this.notifyPendingChange(false);
232
+ }
233
+ }
234
+ getPendingChanges() {
235
+ return Array.from(this.pendingChanges.values());
236
+ }
237
+ getPendingChangeIds() {
238
+ return Array.from(this.pendingChanges.keys());
239
+ }
240
+ /**
241
+ * Get the count of pending changes.
242
+ */
243
+ getPendingCount() {
244
+ return this.pendingChanges.size;
245
+ }
246
+ /**
247
+ * Discard all pending changes for this document.
248
+ * Clears the in-memory pending changes, removes from persistence, and cancels debounce timer.
249
+ * @returns The number of changes that were discarded
250
+ */
251
+ async discardPending() {
252
+ const count = this.pendingChanges.size;
253
+ if (count === 0) {
254
+ return 0;
255
+ }
256
+ // Cancel any pending debounce timer
257
+ if (this.debounceTimer) {
258
+ clearTimeout(this.debounceTimer);
259
+ this.debounceTimer = null;
260
+ }
261
+ // Remove from persistence queue
262
+ if (this.pendingQueue) {
263
+ for (const changeId of this.pendingChanges.keys()) {
264
+ await this.pendingQueue.acknowledge(changeId);
265
+ }
266
+ }
267
+ // Clear in-memory state
268
+ this.pendingChanges.clear();
269
+ this.notifyPendingChange(false);
270
+ return count;
271
+ }
272
+ destroy() {
273
+ if (this.debounceTimer) {
274
+ clearTimeout(this.debounceTimer);
275
+ this.debounceTimer = null;
276
+ }
277
+ this.pendingCallbacks.clear();
278
+ this.externalUpdateCallbacks.clear();
279
+ }
280
+ notifyPendingChange(pending) {
281
+ for (const callback of this.pendingCallbacks) {
282
+ callback(pending);
283
+ }
284
+ }
285
+ }
286
+ /**
287
+ * Map transport state to connection state
288
+ */
289
+ function mapTransportState(state) {
290
+ switch (state) {
291
+ case 'connected': return ConnectionState.Online;
292
+ case 'connecting': return ConnectionState.Connecting;
293
+ case 'reconnecting': return ConnectionState.Reconnecting;
294
+ case 'disconnected': return ConnectionState.Offline;
295
+ default: return ConnectionState.Offline;
296
+ }
297
+ }
298
+ /**
299
+ * Map sync response error to rejection error
300
+ */
301
+ function mapResponseError(error) {
302
+ let code;
303
+ switch (error.code) {
304
+ case 'UNIQUE_VIOLATION':
305
+ code = SyncRejectionCode.UniqueViolation;
306
+ break;
307
+ case 'VALIDATION_ERROR':
308
+ code = SyncRejectionCode.ValidationError;
309
+ break;
310
+ case 'PERMISSION_DENIED':
311
+ code = SyncRejectionCode.PermissionDenied;
312
+ break;
313
+ case 'NOT_FOUND':
314
+ code = SyncRejectionCode.NotFound;
315
+ break;
316
+ case 'SERVER_ERROR':
317
+ code = SyncRejectionCode.ServerError;
318
+ break;
319
+ default:
320
+ code = SyncRejectionCode.Unknown;
321
+ }
322
+ return {
323
+ code,
324
+ field: error.field,
325
+ message: error.message,
326
+ };
327
+ }
328
+ /**
329
+ * Creates a SyncEngine instance
330
+ */
331
+ export function createSyncEngine(config) {
332
+ const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
333
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
334
+ const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
335
+ const pendingQueue = config.pendingQueue ?? null;
336
+ const persistence = config.persistence ?? null;
337
+ const networkDetector = config.networkDetector ?? null;
338
+ const compression = config.compression ?? null;
339
+ // Create or use provided transport
340
+ let transport = config.transport ?? null;
341
+ let transportUnsubscribes = [];
342
+ let networkDetectorUnsubscribe = null;
343
+ // Track network state separately from transport state
344
+ let networkOnline = networkDetector ? networkDetector.getState() === 'online' : true;
345
+ // Track manual offline mode (overrides network detection)
346
+ let manuallyOffline = false;
347
+ // State
348
+ let connectionState = ConnectionState.Offline;
349
+ const stateCallbacks = new Set();
350
+ const syncStartedCallbacks = new Set();
351
+ const syncCompleteCallbacks = new Set();
352
+ const syncErrorCallbacks = new Set();
353
+ const syncRejectedCallbacks = new Set();
354
+ const retryScheduledCallbacks = new Set();
355
+ const syncFailedCallbacks = new Set();
356
+ const localChangesDiscardedCallbacks = new Set();
357
+ const forcePushCallbacks = new Set();
358
+ const actors = new Map();
359
+ // Track retry counts per document (collection:docId -> retryCount)
360
+ const retryCounters = new Map();
361
+ // Pending sync queue (for when offline)
362
+ const pendingSyncQueue = new Map();
363
+ let syncInProgress = false;
364
+ // Track if we've been connected before (to distinguish initial connect from reconnect)
365
+ let hasConnectedBefore = false;
366
+ // Helper to update connection state
367
+ function setConnectionState(state) {
368
+ const previousState = connectionState;
369
+ if (previousState !== state) {
370
+ connectionState = state;
371
+ for (const callback of stateCallbacks) {
372
+ callback(state);
373
+ }
374
+ // When coming online (both transport and network), flush pending syncs
375
+ // But only if not manually offline
376
+ if (state === ConnectionState.Online && networkOnline && !manuallyOffline) {
377
+ // If reconnecting (was previously connected), perform state vector exchange first
378
+ if (hasConnectedBefore && previousState === ConnectionState.Reconnecting) {
379
+ performStateVectorExchange().then(() => {
380
+ flushPendingSyncs();
381
+ }).catch(err => {
382
+ console.error('State vector exchange failed:', err);
383
+ flushPendingSyncs();
384
+ });
385
+ }
386
+ else {
387
+ flushPendingSyncs();
388
+ }
389
+ hasConnectedBefore = true;
390
+ }
391
+ }
392
+ }
393
+ // Helper to handle network state changes
394
+ function handleNetworkStateChange(state) {
395
+ const wasOnline = networkOnline;
396
+ networkOnline = state === 'online';
397
+ // If network just came online and transport is connected, flush pending syncs
398
+ // But only if not manually offline
399
+ if (!wasOnline && networkOnline && connectionState === ConnectionState.Online && !manuallyOffline) {
400
+ flushPendingSyncs();
401
+ }
402
+ }
403
+ // Helper to emit sync events
404
+ function emitSyncStarted(collection, documentId) {
405
+ const event = { collection, documentId };
406
+ for (const callback of syncStartedCallbacks) {
407
+ callback(event);
408
+ }
409
+ }
410
+ function emitSyncComplete(collection, documentId) {
411
+ const event = { collection, documentId };
412
+ for (const callback of syncCompleteCallbacks) {
413
+ callback(event);
414
+ }
415
+ }
416
+ function emitSyncError(collection, documentId, error) {
417
+ const event = { collection, documentId, error };
418
+ for (const callback of syncErrorCallbacks) {
419
+ callback(event);
420
+ }
421
+ }
422
+ function emitSyncRejected(collection, documentId, error) {
423
+ const event = { collection, documentId, error };
424
+ for (const callback of syncRejectedCallbacks) {
425
+ callback(event);
426
+ }
427
+ }
428
+ function emitRetryScheduled(collection, documentId, attempt, delay) {
429
+ const event = { collection, documentId, attempt, delay };
430
+ for (const callback of retryScheduledCallbacks) {
431
+ callback(event);
432
+ }
433
+ }
434
+ function emitSyncFailed(collection, documentId, error, totalAttempts) {
435
+ const event = { collection, documentId, error, totalAttempts };
436
+ for (const callback of syncFailedCallbacks) {
437
+ callback(event);
438
+ }
439
+ }
440
+ function emitLocalChangesDiscarded(collection, documentId, discardedCount) {
441
+ const event = { collection, documentId, discardedCount };
442
+ for (const callback of localChangesDiscardedCallbacks) {
443
+ callback(event);
444
+ }
445
+ }
446
+ function emitForcePush(collection, documentId, success, error) {
447
+ const event = {
448
+ collection,
449
+ documentId,
450
+ timestamp: Date.now(),
451
+ success,
452
+ error,
453
+ };
454
+ for (const callback of forcePushCallbacks) {
455
+ callback(event);
456
+ }
457
+ }
458
+ // Flush all pending syncs when connection is restored
459
+ async function flushPendingSyncs() {
460
+ // Don't flush if sync is in progress, not connected, network is offline, or manually offline
461
+ if (syncInProgress || connectionState !== ConnectionState.Online || !networkOnline || manuallyOffline) {
462
+ return;
463
+ }
464
+ for (const actor of actors.values()) {
465
+ if (actor.hasPendingChanges()) {
466
+ await performSync(actor.collection, actor.documentId);
467
+ }
468
+ }
469
+ }
470
+ // Perform state vector exchange for all registered documents on reconnect
471
+ async function performStateVectorExchange() {
472
+ if (!transport || connectionState !== ConnectionState.Online) {
473
+ return;
474
+ }
475
+ // Exchange state vectors for all registered documents
476
+ const exchangePromises = Array.from(actors.values()).map(async (actor) => {
477
+ try {
478
+ const localVector = actor.getStateVector();
479
+ const response = await transport.exchangeStateVector(actor.collection, actor.documentId, localVector.length > 0 ? localVector : undefined);
480
+ // If server returned a delta, apply it as an external update
481
+ if (response.delta && response.delta.length > 0) {
482
+ actor.applyExternalUpdate(response.delta);
483
+ }
484
+ // If server returned a new state vector, update the actor's vector
485
+ if (response.vector && response.vector.length > 0) {
486
+ actor.setStateVector(response.vector);
487
+ // Save to persistence
488
+ await actor.saveStateVectorToPersistence();
489
+ }
490
+ }
491
+ catch (err) {
492
+ console.error(`State vector exchange failed for ${actor.collection}:${actor.documentId}:`, err);
493
+ }
494
+ });
495
+ await Promise.all(exchangePromises);
496
+ }
497
+ // Perform actual sync via transport
498
+ async function performSync(collection, documentId) {
499
+ const key = actorKey(collection, documentId);
500
+ const actor = actors.get(key);
501
+ if (!actor)
502
+ return;
503
+ const pending = actor.getPendingChanges();
504
+ if (pending.length === 0)
505
+ return;
506
+ // If offline (transport disconnected, network offline, or manually offline), queue for later
507
+ if (connectionState !== ConnectionState.Online || !transport || !networkOnline || manuallyOffline) {
508
+ pendingSyncQueue.set(key, { collection, documentId });
509
+ return;
510
+ }
511
+ // Get current retry count for this document
512
+ const currentRetryCount = retryCounters.get(key) ?? 0;
513
+ emitSyncStarted(collection, documentId);
514
+ syncInProgress = true;
515
+ try {
516
+ // Convert pending changes to transport format with optional compression
517
+ const changes = pending.map((data, index) => ({
518
+ collection,
519
+ docId: documentId,
520
+ data: compression ? maybeCompress(data, compression) : data,
521
+ timestamp: Date.now() + index, // Ensure unique timestamps
522
+ }));
523
+ // Send batch to center
524
+ const responses = await transport.sendBatch(collection, changes);
525
+ // Process responses
526
+ let allSuccess = true;
527
+ for (const response of responses) {
528
+ if (!response.success && response.error) {
529
+ allSuccess = false;
530
+ const rejectionError = mapResponseError(response.error);
531
+ emitSyncRejected(collection, documentId, rejectionError);
532
+ }
533
+ }
534
+ if (allSuccess) {
535
+ await actor.clearPending();
536
+ // Save state vector to persistence after successful sync
537
+ await actor.saveStateVectorToPersistence();
538
+ pendingSyncQueue.delete(key);
539
+ // Reset retry counter on success
540
+ retryCounters.delete(key);
541
+ emitSyncComplete(collection, documentId);
542
+ }
543
+ }
544
+ catch (err) {
545
+ const error = err instanceof Error ? err : new Error(String(err));
546
+ // Check if error is retryable
547
+ if (!isRetryableError(error)) {
548
+ // Permanent error - emit rejection and don't retry
549
+ const rejectionError = {
550
+ code: SyncRejectionCode.Unknown,
551
+ message: error.message,
552
+ originalError: error,
553
+ };
554
+ emitSyncRejected(collection, documentId, rejectionError);
555
+ return;
556
+ }
557
+ // Check if we have retries left
558
+ if (currentRetryCount < maxRetries) {
559
+ // Calculate delay with exponential backoff
560
+ const delay = calculateBackoffDelay(currentRetryCount);
561
+ const nextRetryCount = currentRetryCount + 1;
562
+ // Update retry counter
563
+ retryCounters.set(key, nextRetryCount);
564
+ // Emit retry scheduled event
565
+ emitRetryScheduled(collection, documentId, nextRetryCount, delay);
566
+ // Schedule retry
567
+ setTimeout(() => {
568
+ performSync(collection, documentId);
569
+ }, delay);
570
+ }
571
+ else {
572
+ // Max retries exceeded - emit sync failed
573
+ const totalAttempts = currentRetryCount + 1; // Initial attempt + retries
574
+ emitSyncFailed(collection, documentId, error, totalAttempts);
575
+ emitSyncError(collection, documentId, error);
576
+ // Keep changes pending for manual retry via resetRetries()
577
+ }
578
+ }
579
+ finally {
580
+ syncInProgress = false;
581
+ }
582
+ }
583
+ // Create sync function for actors
584
+ function createSyncFn(collection, documentId) {
585
+ return () => {
586
+ performSync(collection, documentId).catch(err => {
587
+ emitSyncError(collection, documentId, err instanceof Error ? err : new Error(String(err)));
588
+ });
589
+ };
590
+ }
591
+ // Handle external updates from transport
592
+ function handleExternalUpdate(update) {
593
+ const actor = actors.get(actorKey(update.collection, update.docId));
594
+ if (actor) {
595
+ actor.applyExternalUpdate(update.data);
596
+ }
597
+ }
598
+ // Setup transport event handlers
599
+ function setupTransportHandlers() {
600
+ if (!transport)
601
+ return;
602
+ // Clean up previous handlers
603
+ for (const unsub of transportUnsubscribes) {
604
+ unsub();
605
+ }
606
+ transportUnsubscribes = [];
607
+ // Subscribe to state changes
608
+ transportUnsubscribes.push(transport.onStateChange((state) => {
609
+ setConnectionState(mapTransportState(state));
610
+ }));
611
+ // Subscribe to updates
612
+ transportUnsubscribes.push(transport.onUpdate((update) => {
613
+ handleExternalUpdate(update);
614
+ }));
615
+ // Subscribe to errors
616
+ transportUnsubscribes.push(transport.onError((error) => {
617
+ // Emit as sync error for any registered documents
618
+ // In practice, this would be more targeted
619
+ console.error('Transport error:', error);
620
+ }));
621
+ }
622
+ const engine = {
623
+ getConnectionState() {
624
+ return connectionState;
625
+ },
626
+ onStateChange(callback) {
627
+ stateCallbacks.add(callback);
628
+ // Emit current state immediately
629
+ callback(connectionState);
630
+ return () => {
631
+ stateCallbacks.delete(callback);
632
+ };
633
+ },
634
+ async connect() {
635
+ if (connectionState === ConnectionState.Online) {
636
+ return;
637
+ }
638
+ setConnectionState(ConnectionState.Connecting);
639
+ try {
640
+ // Create transport if not provided
641
+ if (!transport) {
642
+ transport = createNatsTransport({
643
+ serverUrl: config.serverUrl,
644
+ apiKey: config.apiKey,
645
+ appId: config.appId,
646
+ edgeId: config.edgeId,
647
+ requestTimeout: config.requestTimeout,
648
+ });
649
+ }
650
+ // Setup handlers before connecting
651
+ setupTransportHandlers();
652
+ // Setup network detector handler if provided
653
+ if (networkDetector && !networkDetectorUnsubscribe) {
654
+ networkDetectorUnsubscribe = networkDetector.onStateChange(handleNetworkStateChange);
655
+ }
656
+ // Connect to NATS
657
+ await transport.connect();
658
+ // State will be updated via transport state change callback
659
+ }
660
+ catch (err) {
661
+ setConnectionState(ConnectionState.Offline);
662
+ throw err;
663
+ }
664
+ },
665
+ async disconnect() {
666
+ if (connectionState === ConnectionState.Offline) {
667
+ return;
668
+ }
669
+ // Disconnect transport
670
+ if (transport) {
671
+ await transport.disconnect();
672
+ }
673
+ // Clean up handlers
674
+ for (const unsub of transportUnsubscribes) {
675
+ unsub();
676
+ }
677
+ transportUnsubscribes = [];
678
+ setConnectionState(ConnectionState.Offline);
679
+ },
680
+ async destroy() {
681
+ // Destroy all actors
682
+ for (const actor of actors.values()) {
683
+ actor.destroy();
684
+ }
685
+ actors.clear();
686
+ // Clear pending queue
687
+ pendingSyncQueue.clear();
688
+ // Clear all callbacks
689
+ stateCallbacks.clear();
690
+ syncStartedCallbacks.clear();
691
+ syncCompleteCallbacks.clear();
692
+ syncErrorCallbacks.clear();
693
+ syncRejectedCallbacks.clear();
694
+ retryScheduledCallbacks.clear();
695
+ syncFailedCallbacks.clear();
696
+ localChangesDiscardedCallbacks.clear();
697
+ forcePushCallbacks.clear();
698
+ retryCounters.clear();
699
+ // Clean up transport handlers
700
+ for (const unsub of transportUnsubscribes) {
701
+ unsub();
702
+ }
703
+ transportUnsubscribes = [];
704
+ // Clean up network detector handler
705
+ if (networkDetectorUnsubscribe) {
706
+ networkDetectorUnsubscribe();
707
+ networkDetectorUnsubscribe = null;
708
+ }
709
+ // Disconnect
710
+ await engine.disconnect();
711
+ },
712
+ async registerDocument(collection, documentId) {
713
+ const key = actorKey(collection, documentId);
714
+ let actor = actors.get(key);
715
+ if (actor) {
716
+ return actor;
717
+ }
718
+ actor = new DocumentActorImpl(collection, documentId, debounceMs, createSyncFn(collection, documentId), pendingQueue, persistence);
719
+ // Load any persisted pending changes
720
+ await actor.loadFromPersistence();
721
+ actors.set(key, actor);
722
+ return actor;
723
+ },
724
+ async unregisterDocument(collection, documentId) {
725
+ const key = actorKey(collection, documentId);
726
+ const actor = actors.get(key);
727
+ if (actor) {
728
+ actor.destroy();
729
+ actors.delete(key);
730
+ }
731
+ // Remove from pending queue and retry counters
732
+ pendingSyncQueue.delete(key);
733
+ retryCounters.delete(key);
734
+ },
735
+ onSyncStarted(callback) {
736
+ syncStartedCallbacks.add(callback);
737
+ return () => {
738
+ syncStartedCallbacks.delete(callback);
739
+ };
740
+ },
741
+ onSyncComplete(callback) {
742
+ syncCompleteCallbacks.add(callback);
743
+ return () => {
744
+ syncCompleteCallbacks.delete(callback);
745
+ };
746
+ },
747
+ onSyncError(callback) {
748
+ syncErrorCallbacks.add(callback);
749
+ return () => {
750
+ syncErrorCallbacks.delete(callback);
751
+ };
752
+ },
753
+ onSyncRejected(callback) {
754
+ syncRejectedCallbacks.add(callback);
755
+ return () => {
756
+ syncRejectedCallbacks.delete(callback);
757
+ };
758
+ },
759
+ rejectDocument(collection, documentId, error) {
760
+ emitSyncRejected(collection, documentId, error);
761
+ },
762
+ onRetryScheduled(callback) {
763
+ retryScheduledCallbacks.add(callback);
764
+ return () => {
765
+ retryScheduledCallbacks.delete(callback);
766
+ };
767
+ },
768
+ onSyncFailed(callback) {
769
+ syncFailedCallbacks.add(callback);
770
+ return () => {
771
+ syncFailedCallbacks.delete(callback);
772
+ };
773
+ },
774
+ resetRetries(collection, documentId) {
775
+ const key = actorKey(collection, documentId);
776
+ // Reset retry counter
777
+ retryCounters.delete(key);
778
+ // Trigger a new sync attempt if actor exists and has pending changes
779
+ const actor = actors.get(key);
780
+ if (actor && actor.hasPendingChanges()) {
781
+ // Schedule immediate sync attempt
782
+ performSync(collection, documentId).catch(err => {
783
+ emitSyncError(collection, documentId, err instanceof Error ? err : new Error(String(err)));
784
+ });
785
+ }
786
+ },
787
+ goOffline() {
788
+ manuallyOffline = true;
789
+ },
790
+ goOnline() {
791
+ const wasManuallyOffline = manuallyOffline;
792
+ manuallyOffline = false;
793
+ // Flush pending syncs if we were manually offline and conditions are met
794
+ if (wasManuallyOffline && connectionState === ConnectionState.Online && networkOnline) {
795
+ flushPendingSyncs();
796
+ }
797
+ },
798
+ isManuallyOffline() {
799
+ return manuallyOffline;
800
+ },
801
+ async discardLocalChanges(collection, documentId) {
802
+ const key = actorKey(collection, documentId);
803
+ const actor = actors.get(key);
804
+ if (!actor) {
805
+ // Also clear from pending queue if it exists (for unregistered documents)
806
+ if (pendingQueue) {
807
+ await pendingQueue.clearDocument(collection, documentId);
808
+ }
809
+ return;
810
+ }
811
+ const discardedCount = await actor.discardPending();
812
+ // Reset retry counter for this document
813
+ retryCounters.delete(key);
814
+ // Remove from pending sync queue
815
+ pendingSyncQueue.delete(key);
816
+ // Emit event only if there were changes to discard
817
+ if (discardedCount > 0) {
818
+ emitLocalChangesDiscarded(collection, documentId, discardedCount);
819
+ }
820
+ },
821
+ async discardAllLocalChanges(collection) {
822
+ const actorsToDiscard = Array.from(actors.values()).filter(actor => {
823
+ if (collection) {
824
+ return actor.collection === collection;
825
+ }
826
+ return true;
827
+ });
828
+ for (const actor of actorsToDiscard) {
829
+ const discardedCount = await actor.discardPending();
830
+ const key = actorKey(actor.collection, actor.documentId);
831
+ // Reset retry counter
832
+ retryCounters.delete(key);
833
+ // Remove from pending sync queue
834
+ pendingSyncQueue.delete(key);
835
+ // Emit event only if there were changes to discard
836
+ if (discardedCount > 0) {
837
+ emitLocalChangesDiscarded(actor.collection, actor.documentId, discardedCount);
838
+ }
839
+ }
840
+ },
841
+ onLocalChangesDiscarded(callback) {
842
+ localChangesDiscardedCallbacks.add(callback);
843
+ return () => {
844
+ localChangesDiscardedCallbacks.delete(callback);
845
+ };
846
+ },
847
+ async forcePush(collection, documentId) {
848
+ const key = actorKey(collection, documentId);
849
+ const actor = actors.get(key);
850
+ if (!actor) {
851
+ throw new Error(`Document not registered: ${collection}:${documentId}`);
852
+ }
853
+ if (connectionState !== ConnectionState.Online || !transport) {
854
+ throw new Error('Not connected to server');
855
+ }
856
+ try {
857
+ // Get the full document state
858
+ const stateVector = actor.getStateVector();
859
+ const pendingChanges = actor.getPendingChanges();
860
+ // Send force push request to server
861
+ const response = await transport.forcePush(collection, documentId, stateVector, pendingChanges);
862
+ if (response.success) {
863
+ // Clear pending changes on success
864
+ await actor.clearPending();
865
+ // Save state vector to persistence
866
+ await actor.saveStateVectorToPersistence();
867
+ // Reset retry counter
868
+ retryCounters.delete(key);
869
+ // Emit audit event
870
+ emitForcePush(collection, documentId, true);
871
+ return { success: true };
872
+ }
873
+ else {
874
+ // Emit audit event with error
875
+ emitForcePush(collection, documentId, false, response.error);
876
+ return {
877
+ success: false,
878
+ error: response.error,
879
+ };
880
+ }
881
+ }
882
+ catch (err) {
883
+ const error = {
884
+ code: 'FORCE_PUSH_ERROR',
885
+ message: err instanceof Error ? err.message : String(err),
886
+ };
887
+ // Emit audit event with error
888
+ emitForcePush(collection, documentId, false, error);
889
+ return {
890
+ success: false,
891
+ error,
892
+ };
893
+ }
894
+ },
895
+ onForcePush(callback) {
896
+ forcePushCallbacks.add(callback);
897
+ return () => {
898
+ forcePushCallbacks.delete(callback);
899
+ };
900
+ },
901
+ };
902
+ return engine;
903
+ }
904
+ //# sourceMappingURL=sync-engine.js.map