@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.
- package/README.md +312 -0
- package/dist/client/attachment.d.ts +225 -0
- package/dist/client/attachment.d.ts.map +1 -0
- package/dist/client/attachment.js +402 -0
- package/dist/client/attachment.js.map +1 -0
- package/dist/client/binary-encoding.d.ts +45 -0
- package/dist/client/binary-encoding.d.ts.map +1 -0
- package/dist/client/binary-encoding.js +90 -0
- package/dist/client/binary-encoding.js.map +1 -0
- package/dist/client/collection.d.ts +10 -0
- package/dist/client/collection.d.ts.map +1 -0
- package/dist/client/collection.js +924 -0
- package/dist/client/collection.js.map +1 -0
- package/dist/client/compression.d.ts +56 -0
- package/dist/client/compression.d.ts.map +1 -0
- package/dist/client/compression.js +173 -0
- package/dist/client/compression.js.map +1 -0
- package/dist/client/crdt/index.d.ts +2 -0
- package/dist/client/crdt/index.d.ts.map +1 -0
- package/dist/client/crdt/index.js +2 -0
- package/dist/client/crdt/index.js.map +1 -0
- package/dist/client/crdt/yjs-doc.d.ts +88 -0
- package/dist/client/crdt/yjs-doc.d.ts.map +1 -0
- package/dist/client/crdt/yjs-doc.js +123 -0
- package/dist/client/crdt/yjs-doc.js.map +1 -0
- package/dist/client/index.d.ts +66 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +233 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/mock-transport.d.ts +155 -0
- package/dist/client/mock-transport.d.ts.map +1 -0
- package/dist/client/mock-transport.js +292 -0
- package/dist/client/mock-transport.js.map +1 -0
- package/dist/client/network-detector.d.ts +65 -0
- package/dist/client/network-detector.d.ts.map +1 -0
- package/dist/client/network-detector.js +147 -0
- package/dist/client/network-detector.js.map +1 -0
- package/dist/client/provisioning.d.ts +126 -0
- package/dist/client/provisioning.d.ts.map +1 -0
- package/dist/client/provisioning.js +125 -0
- package/dist/client/provisioning.js.map +1 -0
- package/dist/client/signal.d.ts +13 -0
- package/dist/client/signal.d.ts.map +1 -0
- package/dist/client/signal.js +27 -0
- package/dist/client/signal.js.map +1 -0
- package/dist/client/sync-engine.d.ts +298 -0
- package/dist/client/sync-engine.d.ts.map +1 -0
- package/dist/client/sync-engine.js +904 -0
- package/dist/client/sync-engine.js.map +1 -0
- package/dist/client/synced-edge.d.ts +109 -0
- package/dist/client/synced-edge.d.ts.map +1 -0
- package/dist/client/synced-edge.js +179 -0
- package/dist/client/synced-edge.js.map +1 -0
- package/dist/client/synced-offline-edge-types.d.ts +540 -0
- package/dist/client/synced-offline-edge-types.d.ts.map +1 -0
- package/dist/client/synced-offline-edge-types.js +10 -0
- package/dist/client/synced-offline-edge-types.js.map +1 -0
- package/dist/client/synced-offline-edge.d.ts +54 -0
- package/dist/client/synced-offline-edge.d.ts.map +1 -0
- package/dist/client/synced-offline-edge.js +731 -0
- package/dist/client/synced-offline-edge.js.map +1 -0
- package/dist/client/transport.d.ts +202 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/transport.js +409 -0
- package/dist/client/transport.js.map +1 -0
- package/dist/client/types.d.ts +622 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +60 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/validation.d.ts +61 -0
- package/dist/client/validation.d.ts.map +1 -0
- package/dist/client/validation.js +57 -0
- package/dist/client/validation.js.map +1 -0
- package/dist/client/versioning.d.ts +134 -0
- package/dist/client/versioning.d.ts.map +1 -0
- package/dist/client/versioning.js +304 -0
- package/dist/client/versioning.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/encryption.d.ts +114 -0
- package/dist/persistence/encryption.d.ts.map +1 -0
- package/dist/persistence/encryption.js +286 -0
- package/dist/persistence/encryption.js.map +1 -0
- package/dist/persistence/index.d.ts +21 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +20 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/memory.d.ts +32 -0
- package/dist/persistence/memory.d.ts.map +1 -0
- package/dist/persistence/memory.js +57 -0
- package/dist/persistence/memory.js.map +1 -0
- package/dist/persistence/migrations.d.ts +106 -0
- package/dist/persistence/migrations.d.ts.map +1 -0
- package/dist/persistence/migrations.js +176 -0
- package/dist/persistence/migrations.js.map +1 -0
- package/dist/persistence/pending-queue.d.ts +109 -0
- package/dist/persistence/pending-queue.d.ts.map +1 -0
- package/dist/persistence/pending-queue.js +249 -0
- package/dist/persistence/pending-queue.js.map +1 -0
- package/dist/persistence/pglite.d.ts +72 -0
- package/dist/persistence/pglite.d.ts.map +1 -0
- package/dist/persistence/pglite.js +126 -0
- package/dist/persistence/pglite.js.map +1 -0
- package/dist/persistence/quota-manager.d.ts +134 -0
- package/dist/persistence/quota-manager.d.ts.map +1 -0
- package/dist/persistence/quota-manager.js +242 -0
- package/dist/persistence/quota-manager.js.map +1 -0
- package/dist/persistence/types.d.ts +54 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +2 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/react/OfflineEdgeProvider.d.ts +91 -0
- package/dist/react/OfflineEdgeProvider.d.ts.map +1 -0
- package/dist/react/OfflineEdgeProvider.js +127 -0
- package/dist/react/OfflineEdgeProvider.js.map +1 -0
- package/dist/react/SyncedOfflineEdgeProvider.d.ts +105 -0
- package/dist/react/SyncedOfflineEdgeProvider.d.ts.map +1 -0
- package/dist/react/SyncedOfflineEdgeProvider.js +138 -0
- package/dist/react/SyncedOfflineEdgeProvider.js.map +1 -0
- package/dist/react/index.d.ts +50 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +51 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useCollection.d.ts +77 -0
- package/dist/react/useCollection.d.ts.map +1 -0
- package/dist/react/useCollection.js +113 -0
- package/dist/react/useCollection.js.map +1 -0
- package/dist/react/useCollectionSyncMode.d.ts +61 -0
- package/dist/react/useCollectionSyncMode.d.ts.map +1 -0
- package/dist/react/useCollectionSyncMode.js +93 -0
- package/dist/react/useCollectionSyncMode.js.map +1 -0
- package/dist/react/useConnectionState.d.ts +44 -0
- package/dist/react/useConnectionState.d.ts.map +1 -0
- package/dist/react/useConnectionState.js +46 -0
- package/dist/react/useConnectionState.js.map +1 -0
- package/dist/react/useDocumentSyncStatus.d.ts +72 -0
- package/dist/react/useDocumentSyncStatus.d.ts.map +1 -0
- package/dist/react/useDocumentSyncStatus.js +110 -0
- package/dist/react/useDocumentSyncStatus.js.map +1 -0
- package/dist/react/useOfflineEdge.d.ts +58 -0
- package/dist/react/useOfflineEdge.d.ts.map +1 -0
- package/dist/react/useOfflineEdge.js +54 -0
- package/dist/react/useOfflineEdge.js.map +1 -0
- package/dist/react/usePendingChanges.d.ts +67 -0
- package/dist/react/usePendingChanges.d.ts.map +1 -0
- package/dist/react/usePendingChanges.js +90 -0
- package/dist/react/usePendingChanges.js.map +1 -0
- package/dist/react/useRejectedDocuments.d.ts +112 -0
- package/dist/react/useRejectedDocuments.d.ts.map +1 -0
- package/dist/react/useRejectedDocuments.js +213 -0
- package/dist/react/useRejectedDocuments.js.map +1 -0
- package/dist/react/useSyncControls.d.ts +96 -0
- package/dist/react/useSyncControls.d.ts.map +1 -0
- package/dist/react/useSyncControls.js +112 -0
- package/dist/react/useSyncControls.js.map +1 -0
- package/dist/react/useSyncProgress.d.ts +78 -0
- package/dist/react/useSyncProgress.d.ts.map +1 -0
- package/dist/react/useSyncProgress.js +90 -0
- package/dist/react/useSyncProgress.js.map +1 -0
- package/dist/react/useSyncRejected.d.ts +47 -0
- package/dist/react/useSyncRejected.d.ts.map +1 -0
- package/dist/react/useSyncRejected.js +55 -0
- package/dist/react/useSyncRejected.js.map +1 -0
- package/dist/react/useSyncStatus.d.ts +56 -0
- package/dist/react/useSyncStatus.d.ts.map +1 -0
- package/dist/react/useSyncStatus.js +59 -0
- package/dist/react/useSyncStatus.js.map +1 -0
- package/dist/react/useSyncedOfflineEdge.d.ts +69 -0
- package/dist/react/useSyncedOfflineEdge.d.ts.map +1 -0
- package/dist/react/useSyncedOfflineEdge.js +65 -0
- package/dist/react/useSyncedOfflineEdge.js.map +1 -0
- package/dist/service-worker/index.d.ts +7 -0
- package/dist/service-worker/index.d.ts.map +1 -0
- package/dist/service-worker/index.js +7 -0
- package/dist/service-worker/index.js.map +1 -0
- package/dist/service-worker/sync-worker.d.ts +230 -0
- package/dist/service-worker/sync-worker.d.ts.map +1 -0
- package/dist/service-worker/sync-worker.js +471 -0
- package/dist/service-worker/sync-worker.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- 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
|