local-lite 0.0.3 → 0.0.5
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 +421 -362
- package/dist/adapters/local/better-sqlite3.d.ts +2 -0
- package/dist/adapters/local/better-sqlite3.js +46 -25
- package/dist/adapters/local/expo-sqlite.d.ts +5 -1
- package/dist/adapters/local/expo-sqlite.js +40 -7
- package/dist/adapters/local/index.d.ts +1 -0
- package/dist/adapters/local/node-sqlite.d.ts +2 -0
- package/dist/adapters/local/node-sqlite.js +45 -22
- package/dist/adapters/local/op-sqlite.d.ts +1 -0
- package/dist/adapters/local/op-sqlite.js +6 -0
- package/dist/adapters/local/sql-js.d.ts +1 -0
- package/dist/adapters/local/sql-js.js +5 -0
- package/dist/adapters/network/index.js +33 -0
- package/dist/adapters/remote/convex.d.ts +2 -2
- package/dist/adapters/remote/convex.js +8 -12
- package/dist/adapters/remote/demo.d.ts +1 -1
- package/dist/adapters/remote/demo.js +3 -0
- package/dist/adapters/remote/firestore.d.ts +3 -2
- package/dist/adapters/remote/firestore.js +69 -46
- package/dist/adapters/remote/index.d.ts +6 -4
- package/dist/adapters/remote/supabase.d.ts +34 -6
- package/dist/adapters/remote/supabase.js +340 -79
- package/dist/config/index.js +7 -10
- package/dist/config/resolve-local.js +12 -1
- package/dist/config/resolve-remote.js +33 -28
- package/dist/config/types.d.ts +30 -5
- package/dist/core/client.d.ts +133 -5
- package/dist/core/client.js +431 -46
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +30 -0
- package/dist/core/outbox-store.d.ts +8 -4
- package/dist/core/outbox-store.js +84 -65
- package/dist/core/query-builder.d.ts +119 -0
- package/dist/core/query-builder.js +372 -0
- package/dist/core/query.d.ts +8 -0
- package/dist/core/query.js +37 -0
- package/dist/core/schema-manager.d.ts +9 -0
- package/dist/core/schema-manager.js +103 -0
- package/dist/core/sync-worker.d.ts +38 -1
- package/dist/core/sync-worker.js +351 -104
- package/dist/core/table-manager.js +26 -29
- package/dist/core/types.d.ts +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/react/BenchmarkModal.d.ts +32 -0
- package/dist/react/BenchmarkModal.js +777 -0
- package/dist/react/SyncInspector.d.ts +31 -0
- package/dist/react/SyncInspector.js +585 -0
- package/dist/react/index.d.ts +89 -18
- package/dist/react/index.js +450 -105
- package/dist/react/runner.d.ts +7 -0
- package/dist/react/runner.js +42 -0
- package/dist/react/status.d.ts +24 -0
- package/dist/react/status.js +123 -0
- package/dist/react/theme.d.ts +18 -0
- package/dist/react/theme.js +46 -0
- package/dist/schema/generator.d.ts +49 -0
- package/dist/schema/generator.js +209 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +17 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +20 -0
- package/package.json +143 -133
package/dist/core/sync-worker.js
CHANGED
|
@@ -7,6 +7,10 @@ const outbox_store_js_1 = require("./outbox-store.js");
|
|
|
7
7
|
const meta_store_js_1 = require("./meta-store.js");
|
|
8
8
|
const table_manager_js_1 = require("./table-manager.js");
|
|
9
9
|
const logger_js_1 = require("../utils/logger.js");
|
|
10
|
+
/** Maximum outbox records sent per push cycle. */
|
|
11
|
+
const PUSH_BATCH_SIZE = 50;
|
|
12
|
+
/** Maximum incoming delta changes applied in a single database transaction. */
|
|
13
|
+
const DELTA_CHUNK_SIZE = 250;
|
|
10
14
|
class SyncWorker {
|
|
11
15
|
db;
|
|
12
16
|
remote;
|
|
@@ -16,20 +20,29 @@ class SyncWorker {
|
|
|
16
20
|
primaryKey;
|
|
17
21
|
autoSchema;
|
|
18
22
|
schemaManager;
|
|
23
|
+
syncScope;
|
|
19
24
|
logger;
|
|
20
25
|
retryConfig;
|
|
21
26
|
pushIntervalMs;
|
|
22
27
|
pullIntervalMs;
|
|
23
28
|
maxRetries;
|
|
29
|
+
autoPauseOnBackground;
|
|
30
|
+
isPaused = false;
|
|
31
|
+
lifecycleUnsubscribe;
|
|
24
32
|
pushIntervalTimer;
|
|
25
33
|
pullIntervalTimer;
|
|
26
34
|
pushDebounceTimer;
|
|
27
35
|
networkUnsubscribe;
|
|
36
|
+
remoteUnsubscribe;
|
|
37
|
+
subscriptionGeneration = 0;
|
|
38
|
+
automaticSync;
|
|
28
39
|
isPushing = false;
|
|
29
40
|
isPulling = false;
|
|
30
41
|
lastPulledTimestamp = 0;
|
|
31
42
|
cursorLoaded = false;
|
|
32
43
|
deltaQueue = Promise.resolve();
|
|
44
|
+
statusRun;
|
|
45
|
+
statusRerunRequested = false;
|
|
33
46
|
// Telemetry metrics
|
|
34
47
|
totalPushedOperations = 0;
|
|
35
48
|
totalPulledDeltas = 0;
|
|
@@ -43,6 +56,7 @@ class SyncWorker {
|
|
|
43
56
|
lastPullError = null;
|
|
44
57
|
constructor(db, remote, events, options = {}) {
|
|
45
58
|
this.db = db;
|
|
59
|
+
this.automaticSync = options.autoSync ?? true;
|
|
46
60
|
this.remote = remote;
|
|
47
61
|
this.events = events;
|
|
48
62
|
this.network = options.network ?? new index_js_1.DefaultNetworkStatusProvider();
|
|
@@ -52,19 +66,9 @@ class SyncWorker {
|
|
|
52
66
|
this.schemaManager = options.schemaManager;
|
|
53
67
|
this.pushIntervalMs = options.pushIntervalMs ?? 5000;
|
|
54
68
|
this.pullIntervalMs = options.pullIntervalMs ?? 10000;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
else if (typeof options.logger === 'boolean') {
|
|
60
|
-
this.logger = new logger_js_1.SyncLogger({ enabled: options.logger, level: options.logLevel });
|
|
61
|
-
}
|
|
62
|
-
else if (options.logger && typeof options.logger === 'object') {
|
|
63
|
-
this.logger = new logger_js_1.SyncLogger({ customLogger: options.logger, level: options.logLevel });
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
this.logger = options.logLevel ? new logger_js_1.SyncLogger({ level: options.logLevel }) : logger_js_1.defaultLogger;
|
|
67
|
-
}
|
|
69
|
+
this.syncScope = options.syncScope;
|
|
70
|
+
this.autoPauseOnBackground = options.autoPauseOnBackground ?? true;
|
|
71
|
+
this.logger = (0, logger_js_1.createSyncLogger)(options.logger, options.logLevel);
|
|
68
72
|
// Configure retries & backoff
|
|
69
73
|
const r = options.retry;
|
|
70
74
|
if (typeof r === 'number') {
|
|
@@ -102,23 +106,41 @@ class SyncWorker {
|
|
|
102
106
|
*/
|
|
103
107
|
start() {
|
|
104
108
|
this.stop();
|
|
109
|
+
this.automaticSync = true;
|
|
110
|
+
this.isPaused = false;
|
|
111
|
+
const generation = this.subscriptionGeneration;
|
|
112
|
+
this.setupLifecycleListener();
|
|
113
|
+
// If currently backgrounded, pause immediately and wait for foreground
|
|
114
|
+
if (this.isAppBackgrounded()) {
|
|
115
|
+
this.isPaused = true;
|
|
116
|
+
this.logger.info('App currently in background: sync worker paused until foreground');
|
|
117
|
+
this.emitStatus();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
105
120
|
this.pushIntervalTimer = setInterval(() => this.pushLocalChanges(), this.pushIntervalMs);
|
|
106
121
|
this.pullIntervalTimer = setInterval(() => this.pullRemoteChanges(), this.pullIntervalMs);
|
|
107
122
|
// Subscribe to live remote changes
|
|
108
|
-
this.remote.subscribeToLiveChanges((deltas) => {
|
|
109
|
-
this.
|
|
123
|
+
const unsubscribe = this.remote.subscribeToLiveChanges((deltas) => {
|
|
124
|
+
if (generation !== this.subscriptionGeneration || this.isPaused)
|
|
125
|
+
return;
|
|
126
|
+
// A realtime event is not a complete pull page and cannot advance its checkpoint.
|
|
127
|
+
this.applyDeltasLocally(deltas, { advanceCursor: false }).catch((err) => {
|
|
110
128
|
this.logger.error('Error applying live remote deltas:', err);
|
|
111
129
|
});
|
|
112
130
|
});
|
|
113
|
-
|
|
131
|
+
if (typeof unsubscribe === 'function')
|
|
132
|
+
this.remoteUnsubscribe = unsubscribe;
|
|
133
|
+
// Subscribe to network connectivity changes (only pull on actual offline -> online transition)
|
|
134
|
+
let lastNetworkOnline = undefined;
|
|
114
135
|
this.networkUnsubscribe = this.network.subscribe((online) => {
|
|
115
136
|
this.emitStatus();
|
|
116
|
-
if (online) {
|
|
137
|
+
if (online && lastNetworkOnline === false && !this.isPaused) {
|
|
117
138
|
this.schedulePush(0);
|
|
118
139
|
this.pullRemoteChanges().catch((err) => {
|
|
119
140
|
this.logger.error('Error pulling changes on reconnect:', err);
|
|
120
141
|
});
|
|
121
142
|
}
|
|
143
|
+
lastNetworkOnline = online;
|
|
122
144
|
});
|
|
123
145
|
// Initial pull
|
|
124
146
|
this.pullRemoteChanges().catch((err) => {
|
|
@@ -130,6 +152,17 @@ class SyncWorker {
|
|
|
130
152
|
* Stops all active intervals, timers, and listeners.
|
|
131
153
|
*/
|
|
132
154
|
stop() {
|
|
155
|
+
this.automaticSync = false;
|
|
156
|
+
this.isPaused = false;
|
|
157
|
+
this.subscriptionGeneration++;
|
|
158
|
+
if (this.lifecycleUnsubscribe) {
|
|
159
|
+
this.lifecycleUnsubscribe();
|
|
160
|
+
this.lifecycleUnsubscribe = undefined;
|
|
161
|
+
}
|
|
162
|
+
if (this.remoteUnsubscribe) {
|
|
163
|
+
this.remoteUnsubscribe();
|
|
164
|
+
this.remoteUnsubscribe = undefined;
|
|
165
|
+
}
|
|
133
166
|
if (this.pushIntervalTimer) {
|
|
134
167
|
clearInterval(this.pushIntervalTimer);
|
|
135
168
|
this.pushIntervalTimer = undefined;
|
|
@@ -147,10 +180,123 @@ class SyncWorker {
|
|
|
147
180
|
this.networkUnsubscribe = undefined;
|
|
148
181
|
}
|
|
149
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Pauses recurring interval timers and active sync loops to conserve battery and data.
|
|
185
|
+
*/
|
|
186
|
+
pause() {
|
|
187
|
+
if (this.isPaused)
|
|
188
|
+
return;
|
|
189
|
+
this.isPaused = true;
|
|
190
|
+
this.logger.info('App backgrounded: pausing sync timers');
|
|
191
|
+
if (this.pushIntervalTimer) {
|
|
192
|
+
clearInterval(this.pushIntervalTimer);
|
|
193
|
+
this.pushIntervalTimer = undefined;
|
|
194
|
+
}
|
|
195
|
+
if (this.pullIntervalTimer) {
|
|
196
|
+
clearInterval(this.pullIntervalTimer);
|
|
197
|
+
this.pullIntervalTimer = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (this.pushDebounceTimer) {
|
|
200
|
+
clearTimeout(this.pushDebounceTimer);
|
|
201
|
+
this.pushDebounceTimer = undefined;
|
|
202
|
+
}
|
|
203
|
+
this.emitStatus();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Resumes recurring interval timers and triggers an immediate catch-up push and pull.
|
|
207
|
+
*/
|
|
208
|
+
resume() {
|
|
209
|
+
if (!this.isPaused)
|
|
210
|
+
return;
|
|
211
|
+
this.isPaused = false;
|
|
212
|
+
this.logger.info('App foregrounded: resuming sync');
|
|
213
|
+
if (this.automaticSync) {
|
|
214
|
+
if (!this.pushIntervalTimer) {
|
|
215
|
+
this.pushIntervalTimer = setInterval(() => this.pushLocalChanges(), this.pushIntervalMs);
|
|
216
|
+
}
|
|
217
|
+
if (!this.pullIntervalTimer) {
|
|
218
|
+
this.pullIntervalTimer = setInterval(() => this.pullRemoteChanges(), this.pullIntervalMs);
|
|
219
|
+
}
|
|
220
|
+
this.schedulePush(0);
|
|
221
|
+
this.pullRemoteChanges().catch((err) => {
|
|
222
|
+
this.logger.error('Error pulling changes on app resume:', err);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
this.emitStatus();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Returns whether the sync worker is currently suspended due to app backgrounding.
|
|
229
|
+
*/
|
|
230
|
+
get isBackgroundPaused() {
|
|
231
|
+
return this.isPaused;
|
|
232
|
+
}
|
|
233
|
+
isAppBackgrounded() {
|
|
234
|
+
if (!this.autoPauseOnBackground)
|
|
235
|
+
return false;
|
|
236
|
+
// React Native AppState
|
|
237
|
+
try {
|
|
238
|
+
const { AppState } = require('react-native');
|
|
239
|
+
if (AppState?.currentState && typeof AppState.currentState === 'string') {
|
|
240
|
+
return Boolean(AppState.currentState.match(/inactive|background/));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Not React Native
|
|
245
|
+
}
|
|
246
|
+
// Web Page Visibility API
|
|
247
|
+
if (typeof document !== 'undefined' && typeof document.hidden === 'boolean') {
|
|
248
|
+
return document.hidden;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
setupLifecycleListener() {
|
|
253
|
+
if (!this.autoPauseOnBackground || this.lifecycleUnsubscribe)
|
|
254
|
+
return;
|
|
255
|
+
// 1. Try React Native AppState
|
|
256
|
+
try {
|
|
257
|
+
const { AppState } = require('react-native');
|
|
258
|
+
if (AppState && typeof AppState.addEventListener === 'function') {
|
|
259
|
+
const sub = AppState.addEventListener('change', (state) => {
|
|
260
|
+
if (state.match(/inactive|background/)) {
|
|
261
|
+
this.pause();
|
|
262
|
+
}
|
|
263
|
+
else if (state === 'active') {
|
|
264
|
+
this.resume();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
this.lifecycleUnsubscribe = () => {
|
|
268
|
+
if (typeof sub?.remove === 'function') {
|
|
269
|
+
sub.remove();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Not React Native
|
|
277
|
+
}
|
|
278
|
+
// 2. Web Page Visibility API
|
|
279
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
280
|
+
const onVisibilityChange = () => {
|
|
281
|
+
if (document.hidden) {
|
|
282
|
+
this.pause();
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
this.resume();
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
289
|
+
this.lifecycleUnsubscribe = () => {
|
|
290
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
150
294
|
/**
|
|
151
295
|
* Schedules an immediate push with a short debounce.
|
|
152
296
|
*/
|
|
153
297
|
schedulePush(delayMs = 50) {
|
|
298
|
+
if (!this.automaticSync || this.isPaused)
|
|
299
|
+
return;
|
|
154
300
|
if (this.pushDebounceTimer) {
|
|
155
301
|
clearTimeout(this.pushDebounceTimer);
|
|
156
302
|
}
|
|
@@ -161,6 +307,12 @@ class SyncWorker {
|
|
|
161
307
|
});
|
|
162
308
|
}, delayMs);
|
|
163
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Dynamically sets or updates the active sync scope (e.g. switching active businessId or userId).
|
|
312
|
+
*/
|
|
313
|
+
setSyncScope(scope) {
|
|
314
|
+
this.syncScope = scope;
|
|
315
|
+
}
|
|
164
316
|
async isOnline() {
|
|
165
317
|
try {
|
|
166
318
|
return await Promise.resolve(this.network.isOnline());
|
|
@@ -169,41 +321,70 @@ class SyncWorker {
|
|
|
169
321
|
return true;
|
|
170
322
|
}
|
|
171
323
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
};
|
|
182
|
-
this.events.emit('sync:status', statusEvent);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// Ignored
|
|
324
|
+
/**
|
|
325
|
+
* Emits a `sync:status` snapshot. Calls are coalesced: while one snapshot is being
|
|
326
|
+
* computed, further requests collapse into a single trailing run that reflects the
|
|
327
|
+
* latest state, so bursts of push/pull transitions cost at most two COUNT queries.
|
|
328
|
+
*/
|
|
329
|
+
emitStatus() {
|
|
330
|
+
if (this.statusRun) {
|
|
331
|
+
this.statusRerunRequested = true;
|
|
332
|
+
return this.statusRun;
|
|
186
333
|
}
|
|
334
|
+
const run = async () => {
|
|
335
|
+
do {
|
|
336
|
+
this.statusRerunRequested = false;
|
|
337
|
+
try {
|
|
338
|
+
const online = await this.isOnline();
|
|
339
|
+
const counts = await outbox_store_js_1.OutboxStore.getCounts(this.db);
|
|
340
|
+
const statusEvent = {
|
|
341
|
+
isOnline: online,
|
|
342
|
+
isPushing: this.isPushing,
|
|
343
|
+
isPulling: this.isPulling,
|
|
344
|
+
pendingCount: counts.total,
|
|
345
|
+
};
|
|
346
|
+
this.events.emit('sync:status', statusEvent);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Ignored
|
|
350
|
+
}
|
|
351
|
+
} while (this.statusRerunRequested);
|
|
352
|
+
};
|
|
353
|
+
this.statusRun = run().finally(() => {
|
|
354
|
+
this.statusRun = undefined;
|
|
355
|
+
});
|
|
356
|
+
return this.statusRun;
|
|
187
357
|
}
|
|
188
358
|
/**
|
|
189
359
|
* Pushes pending outbox records to the remote database.
|
|
190
360
|
*/
|
|
191
361
|
async pushLocalChanges() {
|
|
192
|
-
if (this.isPushing)
|
|
193
|
-
return;
|
|
194
|
-
if (!(await this.isOnline()))
|
|
362
|
+
if (this.isPushing || this.isPaused)
|
|
195
363
|
return;
|
|
196
364
|
this.isPushing = true;
|
|
197
365
|
this.emitStatus();
|
|
198
366
|
const startTime = Date.now();
|
|
199
367
|
let pendingRecords = [];
|
|
200
368
|
try {
|
|
369
|
+
if (!(await this.isOnline()))
|
|
370
|
+
return;
|
|
201
371
|
this.totalPushCycles++;
|
|
202
|
-
|
|
372
|
+
// No push is active: recover records left behind by a failed status write.
|
|
373
|
+
await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
|
|
374
|
+
pendingRecords = await outbox_store_js_1.OutboxStore.getPending(this.db, this.maxRetries, PUSH_BATCH_SIZE);
|
|
203
375
|
if (pendingRecords.length === 0) {
|
|
204
376
|
return;
|
|
205
377
|
}
|
|
206
|
-
|
|
378
|
+
// Filter out internal/ephemeral tables (e.g. starting with '_') so they are never sent to remote backends
|
|
379
|
+
const internalRecords = pendingRecords.filter((r) => r.tableName.startsWith('_'));
|
|
380
|
+
if (internalRecords.length > 0) {
|
|
381
|
+
await outbox_store_js_1.OutboxStore.deleteCompleted(this.db, internalRecords.map((r) => r.id)).catch(() => { });
|
|
382
|
+
}
|
|
383
|
+
const validRecords = pendingRecords.filter((r) => !r.tableName.startsWith('_'));
|
|
384
|
+
if (validRecords.length === 0) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const operations = validRecords.map((r) => ({
|
|
207
388
|
id: r.id,
|
|
208
389
|
tableName: r.tableName,
|
|
209
390
|
rowId: r.rowId,
|
|
@@ -214,35 +395,65 @@ class SyncWorker {
|
|
|
214
395
|
const recordIds = operations.map((o) => o.id);
|
|
215
396
|
// Mark records as PROCESSING
|
|
216
397
|
await outbox_store_js_1.OutboxStore.markProcessing(this.db, recordIds);
|
|
217
|
-
const
|
|
398
|
+
const pushRes = await this.remote.pushChanges(operations);
|
|
218
399
|
const durationMs = Date.now() - startTime;
|
|
219
400
|
this.lastPushDurationMs = durationMs;
|
|
220
401
|
this.lastPushTimestamp = Date.now();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
402
|
+
let isSuccess = false;
|
|
403
|
+
let successfulIds = [];
|
|
404
|
+
let failedIds = [];
|
|
405
|
+
let errorsMap = {};
|
|
406
|
+
if (typeof pushRes === 'boolean') {
|
|
407
|
+
isSuccess = pushRes;
|
|
408
|
+
if (pushRes) {
|
|
409
|
+
successfulIds = recordIds;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
failedIds = recordIds;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (pushRes && typeof pushRes === 'object') {
|
|
416
|
+
isSuccess = Boolean(pushRes.success);
|
|
417
|
+
successfulIds = pushRes.successfulIds ?? (isSuccess ? recordIds : []);
|
|
418
|
+
failedIds = pushRes.failedIds ?? (isSuccess ? [] : recordIds.filter((id) => !successfulIds.includes(id)));
|
|
419
|
+
errorsMap = pushRes.errors ?? {};
|
|
420
|
+
}
|
|
421
|
+
if (successfulIds.length > 0) {
|
|
422
|
+
// Delete records that were pushed successfully
|
|
423
|
+
await outbox_store_js_1.OutboxStore.deleteCompleted(this.db, successfulIds);
|
|
424
|
+
this.totalPushedOperations += successfulIds.length;
|
|
425
|
+
this.events.emit('sync_outbox', { operation: 'DELETE', rowId: 'batch' });
|
|
426
|
+
}
|
|
427
|
+
if (failedIds.length === 0) {
|
|
225
428
|
this.lastPushError = null;
|
|
226
429
|
const pushEvent = {
|
|
227
430
|
success: true,
|
|
228
|
-
count:
|
|
431
|
+
count: successfulIds.length,
|
|
229
432
|
durationMs,
|
|
230
433
|
error: null,
|
|
231
434
|
};
|
|
232
435
|
this.events.emit('sync:push', pushEvent);
|
|
233
|
-
|
|
436
|
+
// Drain backlog: if a full batch was processed and all succeeded, continue immediately
|
|
437
|
+
if (operations.length >= PUSH_BATCH_SIZE) {
|
|
438
|
+
this.schedulePush(0);
|
|
439
|
+
}
|
|
234
440
|
}
|
|
235
441
|
else {
|
|
236
|
-
const errMsg = 'Remote push returned
|
|
442
|
+
const errMsg = Object.values(errorsMap)[0] || 'Remote push returned failure';
|
|
237
443
|
this.lastPushError = errMsg;
|
|
238
444
|
const pushEvent = {
|
|
239
445
|
success: false,
|
|
240
|
-
count:
|
|
446
|
+
count: failedIds.length,
|
|
241
447
|
durationMs,
|
|
242
448
|
error: errMsg,
|
|
243
449
|
};
|
|
244
450
|
this.events.emit('sync:push', pushEvent);
|
|
245
|
-
|
|
451
|
+
const failedRecords = pendingRecords.filter((r) => failedIds.includes(r.id));
|
|
452
|
+
await this.handlePushFailure(failedRecords, errMsg, undefined, errorsMap);
|
|
453
|
+
// If part of the batch succeeded, keep draining the rest
|
|
454
|
+
if (successfulIds.length > 0 && operations.length >= PUSH_BATCH_SIZE) {
|
|
455
|
+
this.schedulePush(0);
|
|
456
|
+
}
|
|
246
457
|
}
|
|
247
458
|
}
|
|
248
459
|
catch (error) {
|
|
@@ -268,10 +479,18 @@ class SyncWorker {
|
|
|
268
479
|
this.emitStatus();
|
|
269
480
|
}
|
|
270
481
|
}
|
|
271
|
-
async handlePushFailure(records, errorMessage, errorObj) {
|
|
482
|
+
async handlePushFailure(records, errorMessage, errorObj, errorsByRecordId) {
|
|
272
483
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
484
|
+
if (errorsByRecordId && Object.keys(errorsByRecordId).length > 0) {
|
|
485
|
+
for (const rec of records) {
|
|
486
|
+
const msg = errorsByRecordId[rec.id] || errorMessage;
|
|
487
|
+
await outbox_store_js_1.OutboxStore.markFailed(this.db, [rec.id], msg);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const recordIds = records.map((r) => r.id);
|
|
492
|
+
await outbox_store_js_1.OutboxStore.markFailed(this.db, recordIds, errorMessage);
|
|
493
|
+
}
|
|
275
494
|
this.events.emit('sync_outbox', { operation: 'UPDATE', rowId: 'batch' });
|
|
276
495
|
// If custom shouldRetry filter specifies an operation should NOT be retried, mark it FAILED permanently
|
|
277
496
|
if (this.retryConfig.shouldRetry && errorObj) {
|
|
@@ -303,20 +522,23 @@ class SyncWorker {
|
|
|
303
522
|
* Pulls delta changes from the remote backend since lastPulledTimestamp.
|
|
304
523
|
*/
|
|
305
524
|
async pullRemoteChanges() {
|
|
306
|
-
if (this.isPulling)
|
|
307
|
-
return;
|
|
308
|
-
if (!(await this.isOnline()))
|
|
525
|
+
if (this.isPulling || this.isPaused)
|
|
309
526
|
return;
|
|
310
527
|
this.isPulling = true;
|
|
311
528
|
this.emitStatus();
|
|
312
529
|
const startTime = Date.now();
|
|
313
530
|
try {
|
|
531
|
+
if (!(await this.isOnline()))
|
|
532
|
+
return;
|
|
314
533
|
this.totalPullCycles++;
|
|
315
534
|
if (!this.cursorLoaded) {
|
|
316
535
|
this.lastPulledTimestamp = await meta_store_js_1.MetaStore.getLastSyncedAt(this.db);
|
|
317
536
|
this.cursorLoaded = true;
|
|
318
537
|
}
|
|
319
|
-
const
|
|
538
|
+
const scope = typeof this.syncScope === 'function' ? await this.syncScope() : this.syncScope;
|
|
539
|
+
const deltas = scope !== undefined
|
|
540
|
+
? await this.remote.fetchChanges(this.lastPulledTimestamp, scope)
|
|
541
|
+
: await this.remote.fetchChanges(this.lastPulledTimestamp);
|
|
320
542
|
const durationMs = Date.now() - startTime;
|
|
321
543
|
this.lastPullDurationMs = durationMs;
|
|
322
544
|
this.lastPullTimestamp = Date.now();
|
|
@@ -325,6 +547,10 @@ class SyncWorker {
|
|
|
325
547
|
if (deltas) {
|
|
326
548
|
this.totalPulledDeltas += changesCount;
|
|
327
549
|
await this.applyDeltasLocally(deltas);
|
|
550
|
+
if (deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
|
|
551
|
+
this.lastPulledTimestamp = deltas.timestamp;
|
|
552
|
+
await meta_store_js_1.MetaStore.setLastSyncedAt(this.db, deltas.timestamp);
|
|
553
|
+
}
|
|
328
554
|
}
|
|
329
555
|
const pullEvent = {
|
|
330
556
|
success: true,
|
|
@@ -360,9 +586,10 @@ class SyncWorker {
|
|
|
360
586
|
* Applies incoming server delta changes to local tables with conflict resolution.
|
|
361
587
|
* Serializes execution in a queue to prevent concurrent SQLite transactions.
|
|
362
588
|
*/
|
|
363
|
-
async applyDeltasLocally(deltas) {
|
|
589
|
+
async applyDeltasLocally(deltas, options = {}) {
|
|
364
590
|
const task = async () => {
|
|
365
|
-
await this.
|
|
591
|
+
await this.getLastPulledTimestamp();
|
|
592
|
+
await this.processDeltasLocally(options.advanceCursor === false ? { ...deltas, timestamp: 0 } : deltas);
|
|
366
593
|
};
|
|
367
594
|
const next = this.deltaQueue.then(task, task);
|
|
368
595
|
this.deltaQueue = next.catch(() => { });
|
|
@@ -371,63 +598,83 @@ class SyncWorker {
|
|
|
371
598
|
async processDeltasLocally(deltas) {
|
|
372
599
|
if (!deltas.changes || deltas.changes.length === 0) {
|
|
373
600
|
if (deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
|
|
601
|
+
await meta_store_js_1.MetaStore.setLastSyncedAt(this.db, deltas.timestamp);
|
|
374
602
|
this.lastPulledTimestamp = deltas.timestamp;
|
|
375
|
-
await meta_store_js_1.MetaStore.setLastSyncedAt(this.db, this.lastPulledTimestamp);
|
|
376
603
|
}
|
|
377
604
|
return;
|
|
378
605
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
606
|
+
const totalChanges = deltas.changes.length;
|
|
607
|
+
// Apply incoming deltas in chunks to avoid holding long SQLite write locks
|
|
608
|
+
// and starving concurrent UI reads on mobile/embedded environments.
|
|
609
|
+
for (let i = 0; i < totalChanges; i += DELTA_CHUNK_SIZE) {
|
|
610
|
+
const chunk = deltas.changes.slice(i, i + DELTA_CHUNK_SIZE);
|
|
611
|
+
const isLastChunk = i + DELTA_CHUNK_SIZE >= totalChanges;
|
|
612
|
+
const queuedEvents = [];
|
|
613
|
+
await this.db.transaction(async (tx) => {
|
|
614
|
+
for (const change of chunk) {
|
|
615
|
+
// 1. Check for pending local mutations for this (table_name, row_id)
|
|
616
|
+
const localPendingRecord = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, change.tableName, change.rowId);
|
|
617
|
+
// 2. Conflict resolution if there is a pending local change
|
|
618
|
+
if (localPendingRecord) {
|
|
619
|
+
// Only load the current local row when a resolver actually needs it.
|
|
620
|
+
const localRow = await table_manager_js_1.TableManager.fetchRow(tx, change.tableName, change.rowId, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
621
|
+
const resolution = await Promise.resolve(this.conflictResolver({
|
|
622
|
+
tableName: change.tableName,
|
|
623
|
+
rowId: change.rowId,
|
|
624
|
+
remoteChange: change,
|
|
625
|
+
localOutboxRecord: localPendingRecord,
|
|
626
|
+
localRow,
|
|
627
|
+
}));
|
|
628
|
+
const resolutionStr = typeof resolution === 'string' ? resolution : 'merge';
|
|
629
|
+
const conflictEvent = {
|
|
630
|
+
tableName: change.tableName,
|
|
631
|
+
rowId: change.rowId,
|
|
632
|
+
resolution: resolutionStr,
|
|
633
|
+
timestamp: Date.now(),
|
|
634
|
+
};
|
|
635
|
+
queuedEvents.push({ event: 'sync:conflict', data: conflictEvent });
|
|
636
|
+
if (resolution === 'keep-local') {
|
|
637
|
+
// Local offline change takes precedence
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (resolution === 'apply-remote') {
|
|
641
|
+
// Remote change wins: remove the superseded local pending outbox mutation
|
|
642
|
+
await outbox_store_js_1.OutboxStore.deleteRecord(tx, localPendingRecord.id);
|
|
643
|
+
}
|
|
644
|
+
else if (typeof resolution === 'object' && resolution.mergePayload) {
|
|
645
|
+
// Merge scenario
|
|
646
|
+
change.payload = resolution.mergePayload;
|
|
647
|
+
await outbox_store_js_1.OutboxStore.updatePayload(tx, localPendingRecord.id, resolution.mergePayload);
|
|
648
|
+
}
|
|
414
649
|
}
|
|
650
|
+
// 3. Apply remote change to local database
|
|
651
|
+
await table_manager_js_1.TableManager.applyMutation(tx, change.tableName, change.rowId, change.operation, change.payload, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
|
|
652
|
+
// 4. Queue table change event to notify reactive listeners after commit
|
|
653
|
+
queuedEvents.push({
|
|
654
|
+
event: change.tableName,
|
|
655
|
+
data: {
|
|
656
|
+
operation: change.operation,
|
|
657
|
+
rowId: change.rowId,
|
|
658
|
+
payload: change.payload,
|
|
659
|
+
source: 'remote',
|
|
660
|
+
},
|
|
661
|
+
});
|
|
415
662
|
}
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
663
|
+
// Advance durable checkpoint only when a valid pull timestamp was provided
|
|
664
|
+
if (isLastChunk && deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
|
|
665
|
+
await meta_store_js_1.MetaStore.setLastSyncedAt(tx, deltas.timestamp);
|
|
666
|
+
this.lastPulledTimestamp = deltas.timestamp;
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
// Emit events after chunk commit
|
|
670
|
+
for (const item of queuedEvents) {
|
|
671
|
+
this.events.emit(item.event, item.data);
|
|
425
672
|
}
|
|
426
|
-
//
|
|
427
|
-
if (
|
|
428
|
-
await
|
|
673
|
+
// Yield between chunks if there are multiple chunks
|
|
674
|
+
if (totalChanges > DELTA_CHUNK_SIZE && !isLastChunk) {
|
|
675
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
429
676
|
}
|
|
430
|
-
}
|
|
677
|
+
}
|
|
431
678
|
if (deltas.timestamp) {
|
|
432
679
|
this.lastPulledTimestamp = Math.max(this.lastPulledTimestamp, deltas.timestamp);
|
|
433
680
|
}
|