local-lite 0.0.2 → 0.0.4

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 (57) hide show
  1. package/README.md +421 -362
  2. package/dist/adapters/local/better-sqlite3.d.ts +2 -0
  3. package/dist/adapters/local/better-sqlite3.js +46 -25
  4. package/dist/adapters/local/expo-sqlite.d.ts +5 -1
  5. package/dist/adapters/local/expo-sqlite.js +40 -7
  6. package/dist/adapters/local/index.d.ts +1 -0
  7. package/dist/adapters/local/node-sqlite.d.ts +2 -0
  8. package/dist/adapters/local/node-sqlite.js +45 -22
  9. package/dist/adapters/local/op-sqlite.d.ts +1 -0
  10. package/dist/adapters/local/op-sqlite.js +6 -0
  11. package/dist/adapters/local/sql-js.d.ts +1 -0
  12. package/dist/adapters/local/sql-js.js +5 -0
  13. package/dist/adapters/network/index.js +33 -0
  14. package/dist/adapters/remote/convex.d.ts +2 -2
  15. package/dist/adapters/remote/convex.js +8 -12
  16. package/dist/adapters/remote/demo.d.ts +1 -1
  17. package/dist/adapters/remote/demo.js +3 -0
  18. package/dist/adapters/remote/firestore.d.ts +3 -2
  19. package/dist/adapters/remote/firestore.js +69 -46
  20. package/dist/adapters/remote/index.d.ts +6 -4
  21. package/dist/adapters/remote/supabase.d.ts +34 -6
  22. package/dist/adapters/remote/supabase.js +340 -79
  23. package/dist/config/index.js +7 -10
  24. package/dist/config/resolve-local.js +12 -1
  25. package/dist/config/resolve-remote.js +33 -28
  26. package/dist/config/types.d.ts +30 -5
  27. package/dist/core/client.d.ts +86 -3
  28. package/dist/core/client.js +423 -41
  29. package/dist/core/index.d.ts +14 -0
  30. package/dist/core/index.js +29 -0
  31. package/dist/core/outbox-store.d.ts +8 -4
  32. package/dist/core/outbox-store.js +84 -65
  33. package/dist/core/query.d.ts +8 -0
  34. package/dist/core/query.js +37 -0
  35. package/dist/core/schema-manager.d.ts +9 -0
  36. package/dist/core/schema-manager.js +68 -0
  37. package/dist/core/sync-worker.d.ts +38 -1
  38. package/dist/core/sync-worker.js +351 -104
  39. package/dist/core/table-manager.js +26 -29
  40. package/dist/core/types.d.ts +8 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +0 -1
  43. package/dist/react/BenchmarkModal.d.ts +32 -0
  44. package/dist/react/BenchmarkModal.js +777 -0
  45. package/dist/react/SyncInspector.d.ts +31 -0
  46. package/dist/react/SyncInspector.js +655 -0
  47. package/dist/react/index.d.ts +49 -19
  48. package/dist/react/index.js +266 -95
  49. package/dist/react/runner.d.ts +7 -0
  50. package/dist/react/runner.js +42 -0
  51. package/dist/react/status.d.ts +24 -0
  52. package/dist/react/status.js +123 -0
  53. package/dist/react/theme.d.ts +18 -0
  54. package/dist/react/theme.js +46 -0
  55. package/dist/utils/logger.d.ts +4 -0
  56. package/dist/utils/logger.js +20 -0
  57. package/package.json +138 -133
@@ -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
- // Configure logger
56
- if (options.logger instanceof logger_js_1.SyncLogger) {
57
- this.logger = options.logger;
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.applyDeltasLocally(deltas).catch((err) => {
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
- // Subscribe to network connectivity changes
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
- async emitStatus() {
173
- try {
174
- const online = await this.isOnline();
175
- const counts = await outbox_store_js_1.OutboxStore.getCounts(this.db);
176
- const statusEvent = {
177
- isOnline: online,
178
- isPushing: this.isPushing,
179
- isPulling: this.isPulling,
180
- pendingCount: counts.pending,
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
- pendingRecords = await outbox_store_js_1.OutboxStore.getPending(this.db, this.maxRetries);
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
- const operations = pendingRecords.map((r) => ({
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 success = await this.remote.pushChanges(operations);
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
- if (success) {
222
- // Successful push: delete rows from outbox
223
- await outbox_store_js_1.OutboxStore.deleteCompleted(this.db, recordIds);
224
- this.totalPushedOperations += operations.length;
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: operations.length,
431
+ count: successfulIds.length,
229
432
  durationMs,
230
433
  error: null,
231
434
  };
232
435
  this.events.emit('sync:push', pushEvent);
233
- this.events.emit('sync_outbox', { operation: 'DELETE', rowId: 'batch' });
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 false';
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: operations.length,
446
+ count: failedIds.length,
241
447
  durationMs,
242
448
  error: errMsg,
243
449
  };
244
450
  this.events.emit('sync:push', pushEvent);
245
- await this.handlePushFailure(pendingRecords, errMsg);
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
- const recordIds = records.map((r) => r.id);
274
- await outbox_store_js_1.OutboxStore.markFailed(this.db, recordIds, errorMessage);
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 deltas = await this.remote.fetchChanges(this.lastPulledTimestamp);
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.processDeltasLocally(deltas);
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
- await this.db.transaction(async (tx) => {
380
- for (const change of deltas.changes) {
381
- // 1. Check for pending local mutations for this (table_name, row_id)
382
- const localPendingRecord = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, change.tableName, change.rowId);
383
- // 2. Query current local row if it exists
384
- const localRow = await table_manager_js_1.TableManager.fetchRow(tx, change.tableName, change.rowId, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
385
- // 3. Conflict resolution if there is a pending local change
386
- if (localPendingRecord) {
387
- const resolution = await Promise.resolve(this.conflictResolver({
388
- tableName: change.tableName,
389
- rowId: change.rowId,
390
- remoteChange: change,
391
- localOutboxRecord: localPendingRecord,
392
- localRow,
393
- }));
394
- const resolutionStr = typeof resolution === 'string' ? resolution : 'merge';
395
- const conflictEvent = {
396
- tableName: change.tableName,
397
- rowId: change.rowId,
398
- resolution: resolutionStr,
399
- timestamp: Date.now(),
400
- };
401
- this.events.emit('sync:conflict', conflictEvent);
402
- if (resolution === 'keep-local') {
403
- // Local offline change takes precedence
404
- continue;
405
- }
406
- if (resolution === 'apply-remote') {
407
- // Remote change wins: remove the superseded local pending outbox mutation
408
- await outbox_store_js_1.OutboxStore.deleteRecord(tx, localPendingRecord.id);
409
- }
410
- else if (typeof resolution === 'object' && resolution.mergePayload) {
411
- // Merge scenario
412
- change.payload = resolution.mergePayload;
413
- await outbox_store_js_1.OutboxStore.updatePayload(tx, localPendingRecord.id, resolution.mergePayload);
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
- // 4. Apply remote change to local database
417
- await table_manager_js_1.TableManager.applyMutation(tx, change.tableName, change.rowId, change.operation, change.payload, this.primaryKey, { autoSchema: this.autoSchema, schemaManager: this.schemaManager });
418
- // 5. Emit table change event to notify reactive listeners
419
- this.events.emit(change.tableName, {
420
- operation: change.operation,
421
- rowId: change.rowId,
422
- payload: change.payload,
423
- source: 'remote',
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
- // Persist sync cursor atomically within the same transaction as delta writes
427
- if (deltas.timestamp && deltas.timestamp > this.lastPulledTimestamp) {
428
- await meta_store_js_1.MetaStore.setLastSyncedAt(tx, deltas.timestamp);
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
  }