@syncular/client 0.0.1 → 0.0.2-126
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 +23 -0
- package/dist/blobs/index.js +3 -3
- package/dist/client.d.ts +10 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -21
- package/dist/client.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -7
- package/dist/conflicts.js.map +1 -1
- package/dist/create-client.d.ts +5 -1
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js +22 -10
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +24 -2
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +290 -43
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/types.d.ts +16 -4
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/create-handler.d.ts +15 -5
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +35 -24
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -5
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.js +19 -19
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +148 -28
- package/dist/migrate.js.map +1 -1
- package/dist/mutations.d.ts +3 -1
- package/dist/mutations.d.ts.map +1 -1
- package/dist/mutations.js +93 -18
- package/dist/mutations.js.map +1 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +1 -11
- package/dist/outbox.js.map +1 -1
- package/dist/plugins/incrementing-version.d.ts +1 -1
- package/dist/plugins/incrementing-version.js +2 -2
- package/dist/plugins/index.js +2 -2
- package/dist/proxy/dialect.js +1 -1
- package/dist/proxy/driver.js +1 -1
- package/dist/proxy/index.js +4 -4
- package/dist/proxy/mutations.js +1 -1
- package/dist/pull-engine.d.ts +29 -3
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +314 -78
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +28 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/query/QueryContext.js +1 -1
- package/dist/query/index.js +3 -3
- package/dist/query/tracked-select.d.ts +2 -1
- package/dist/query/tracked-select.d.ts.map +1 -1
- package/dist/query/tracked-select.js +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/sync-loop.d.ts +5 -1
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +167 -18
- package/dist/sync-loop.js.map +1 -1
- package/package.json +30 -6
- package/src/client.test.ts +369 -0
- package/src/client.ts +101 -22
- package/src/conflicts.ts +1 -10
- package/src/create-client.ts +33 -5
- package/src/engine/SyncEngine.test.ts +157 -0
- package/src/engine/SyncEngine.ts +359 -40
- package/src/engine/types.ts +22 -4
- package/src/handlers/create-handler.ts +86 -37
- package/src/handlers/types.ts +10 -4
- package/src/migrate.ts +215 -33
- package/src/mutations.ts +143 -21
- package/src/outbox.ts +1 -15
- package/src/plugins/incrementing-version.ts +2 -2
- package/src/pull-engine.test.ts +147 -0
- package/src/pull-engine.ts +392 -77
- package/src/push-engine.ts +33 -1
- package/src/query/tracked-select.ts +1 -1
- package/src/schema.ts +2 -2
- package/src/sync-loop.ts +215 -19
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
* and provides a clean API for framework bindings to consume.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import {
|
|
9
|
+
captureSyncException,
|
|
10
|
+
countSyncMetric,
|
|
11
|
+
distributionSyncMetric,
|
|
12
|
+
isRecord,
|
|
13
|
+
type SyncChange,
|
|
14
|
+
type SyncPullResponse,
|
|
15
|
+
type SyncSubscriptionRequest,
|
|
16
|
+
startSyncSpan,
|
|
12
17
|
} from '@syncular/core';
|
|
13
18
|
import { type Kysely, sql } from 'kysely';
|
|
14
19
|
import { syncPushOnce } from '../push-engine';
|
|
@@ -39,6 +44,7 @@ const DEFAULT_MAX_RETRIES = 5;
|
|
|
39
44
|
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
40
45
|
const MAX_RETRY_DELAY_MS = 60000;
|
|
41
46
|
const EXPONENTIAL_FACTOR = 2;
|
|
47
|
+
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
42
48
|
|
|
43
49
|
function calculateRetryDelay(attemptIndex: number): number {
|
|
44
50
|
return Math.min(
|
|
@@ -70,8 +76,10 @@ function createSyncError(
|
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
function
|
|
74
|
-
|
|
79
|
+
function resolveSyncTriggerLabel(
|
|
80
|
+
trigger?: 'ws' | 'local' | 'poll'
|
|
81
|
+
): 'ws' | 'local' | 'poll' | 'auto' {
|
|
82
|
+
return trigger ?? 'auto';
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
@@ -99,6 +107,8 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
99
107
|
private syncPromise: Promise<SyncResult> | null = null;
|
|
100
108
|
private syncRequestedWhileRunning = false;
|
|
101
109
|
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
110
|
+
private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
private hasRealtimeConnectedOnce = false;
|
|
102
112
|
|
|
103
113
|
/**
|
|
104
114
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
@@ -282,7 +292,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
282
292
|
|
|
283
293
|
private detectTransportMode(): SyncTransportMode {
|
|
284
294
|
if (
|
|
285
|
-
this.config.realtimeEnabled &&
|
|
295
|
+
this.config.realtimeEnabled !== false &&
|
|
286
296
|
isRealtimeTransport(this.config.transport)
|
|
287
297
|
) {
|
|
288
298
|
return 'realtime';
|
|
@@ -473,12 +483,18 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
473
483
|
clearTimeout(this.retryTimeoutId);
|
|
474
484
|
this.retryTimeoutId = null;
|
|
475
485
|
}
|
|
486
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
487
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
488
|
+
this.realtimeCatchupTimeoutId = null;
|
|
489
|
+
}
|
|
476
490
|
}
|
|
477
491
|
|
|
478
492
|
/**
|
|
479
493
|
* Trigger a manual sync
|
|
480
494
|
*/
|
|
481
|
-
async sync(
|
|
495
|
+
async sync(opts?: {
|
|
496
|
+
trigger?: 'ws' | 'local' | 'poll';
|
|
497
|
+
}): Promise<SyncResult> {
|
|
482
498
|
// Dedupe concurrent sync calls
|
|
483
499
|
if (this.syncPromise) {
|
|
484
500
|
// A sync is already in-flight; queue one more run so we don't miss
|
|
@@ -501,7 +517,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
501
517
|
};
|
|
502
518
|
}
|
|
503
519
|
|
|
504
|
-
this.syncPromise = this.performSyncLoop();
|
|
520
|
+
this.syncPromise = this.performSyncLoop(opts?.trigger);
|
|
505
521
|
try {
|
|
506
522
|
return await this.syncPromise;
|
|
507
523
|
} finally {
|
|
@@ -509,7 +525,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
509
525
|
}
|
|
510
526
|
}
|
|
511
527
|
|
|
512
|
-
private async performSyncLoop(
|
|
528
|
+
private async performSyncLoop(
|
|
529
|
+
trigger?: 'ws' | 'local' | 'poll'
|
|
530
|
+
): Promise<SyncResult> {
|
|
513
531
|
let lastResult: SyncResult = {
|
|
514
532
|
success: false,
|
|
515
533
|
pushedCommits: 0,
|
|
@@ -520,7 +538,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
520
538
|
|
|
521
539
|
do {
|
|
522
540
|
this.syncRequestedWhileRunning = false;
|
|
523
|
-
lastResult = await this.performSyncOnce();
|
|
541
|
+
lastResult = await this.performSyncOnce(trigger);
|
|
542
|
+
// After the first iteration, clear trigger context
|
|
543
|
+
trigger = undefined;
|
|
524
544
|
// If the sync failed, let retry logic handle backoff instead of tight looping.
|
|
525
545
|
if (!lastResult.success) break;
|
|
526
546
|
} while (
|
|
@@ -532,27 +552,45 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
532
552
|
return lastResult;
|
|
533
553
|
}
|
|
534
554
|
|
|
535
|
-
private async performSyncOnce(
|
|
555
|
+
private async performSyncOnce(
|
|
556
|
+
trigger?: 'ws' | 'local' | 'poll'
|
|
557
|
+
): Promise<SyncResult> {
|
|
536
558
|
const timestamp = Date.now();
|
|
559
|
+
const startedAtMs = timestamp;
|
|
560
|
+
const triggerLabel = resolveSyncTriggerLabel(trigger);
|
|
537
561
|
this.updateState({ isSyncing: true });
|
|
538
562
|
this.emit('sync:start', { timestamp });
|
|
563
|
+
countSyncMetric('sync.client.sync.attempts', 1, {
|
|
564
|
+
attributes: { trigger: triggerLabel },
|
|
565
|
+
});
|
|
539
566
|
|
|
540
567
|
try {
|
|
541
568
|
const pullApplyTimestamp = Date.now();
|
|
542
|
-
const result = await
|
|
543
|
-
this.config.db,
|
|
544
|
-
this.config.transport,
|
|
545
|
-
this.config.shapes,
|
|
569
|
+
const result = await startSyncSpan(
|
|
546
570
|
{
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
571
|
+
name: 'sync.client.sync',
|
|
572
|
+
op: 'sync.client.sync',
|
|
573
|
+
attributes: { trigger: triggerLabel },
|
|
574
|
+
},
|
|
575
|
+
() =>
|
|
576
|
+
syncOnce(
|
|
577
|
+
this.config.db,
|
|
578
|
+
this.config.transport,
|
|
579
|
+
this.config.handlers,
|
|
580
|
+
{
|
|
581
|
+
clientId: this.config.clientId!,
|
|
582
|
+
actorId: this.config.actorId ?? undefined,
|
|
583
|
+
plugins: this.config.plugins,
|
|
584
|
+
subscriptions: this.config
|
|
585
|
+
.subscriptions as SyncSubscriptionRequest[],
|
|
586
|
+
limitCommits: this.config.limitCommits,
|
|
587
|
+
limitSnapshotRows: this.config.limitSnapshotRows,
|
|
588
|
+
maxSnapshotPages: this.config.maxSnapshotPages,
|
|
589
|
+
stateId: this.config.stateId,
|
|
590
|
+
sha256: this.config.sha256,
|
|
591
|
+
trigger,
|
|
592
|
+
}
|
|
593
|
+
)
|
|
556
594
|
);
|
|
557
595
|
|
|
558
596
|
const syncResult: SyncResult = {
|
|
@@ -594,8 +632,42 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
594
632
|
this.config.onDataChange?.(changedTables);
|
|
595
633
|
}
|
|
596
634
|
|
|
597
|
-
// Refresh outbox stats
|
|
598
|
-
|
|
635
|
+
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
636
|
+
this.refreshOutboxStats().catch((error) => {
|
|
637
|
+
console.warn(
|
|
638
|
+
'[SyncEngine] Failed to refresh outbox stats after sync:',
|
|
639
|
+
error
|
|
640
|
+
);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
644
|
+
countSyncMetric('sync.client.sync.results', 1, {
|
|
645
|
+
attributes: {
|
|
646
|
+
trigger: triggerLabel,
|
|
647
|
+
status: 'success',
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
|
|
651
|
+
unit: 'millisecond',
|
|
652
|
+
attributes: {
|
|
653
|
+
trigger: triggerLabel,
|
|
654
|
+
status: 'success',
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
distributionSyncMetric(
|
|
658
|
+
'sync.client.sync.pushed_commits',
|
|
659
|
+
result.pushedCommits,
|
|
660
|
+
{
|
|
661
|
+
attributes: { trigger: triggerLabel },
|
|
662
|
+
}
|
|
663
|
+
);
|
|
664
|
+
distributionSyncMetric(
|
|
665
|
+
'sync.client.sync.pull_rounds',
|
|
666
|
+
result.pullRounds,
|
|
667
|
+
{
|
|
668
|
+
attributes: { trigger: triggerLabel },
|
|
669
|
+
}
|
|
670
|
+
);
|
|
599
671
|
|
|
600
672
|
return syncResult;
|
|
601
673
|
} catch (err) {
|
|
@@ -614,6 +686,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
614
686
|
|
|
615
687
|
this.handleError(error);
|
|
616
688
|
|
|
689
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
690
|
+
countSyncMetric('sync.client.sync.results', 1, {
|
|
691
|
+
attributes: {
|
|
692
|
+
trigger: triggerLabel,
|
|
693
|
+
status: 'error',
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
|
|
697
|
+
unit: 'millisecond',
|
|
698
|
+
attributes: {
|
|
699
|
+
trigger: triggerLabel,
|
|
700
|
+
status: 'error',
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
captureSyncException(err, {
|
|
704
|
+
event: 'sync.client.sync',
|
|
705
|
+
trigger: triggerLabel,
|
|
706
|
+
});
|
|
707
|
+
|
|
617
708
|
// Schedule retry if under max retries
|
|
618
709
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
619
710
|
if (this.state.retryCount < maxRetries) {
|
|
@@ -652,6 +743,165 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
652
743
|
return Array.from(tables);
|
|
653
744
|
}
|
|
654
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Apply changes delivered inline over WebSocket for instant UI updates.
|
|
748
|
+
* Returns true if changes were applied and cursor updated successfully,
|
|
749
|
+
* false if anything failed (caller should fall back to HTTP sync).
|
|
750
|
+
*/
|
|
751
|
+
private async applyWsDeliveredChanges(
|
|
752
|
+
changes: SyncChange[],
|
|
753
|
+
cursor: number
|
|
754
|
+
): Promise<boolean> {
|
|
755
|
+
try {
|
|
756
|
+
await this.config.db.transaction().execute(async (trx) => {
|
|
757
|
+
for (const change of changes) {
|
|
758
|
+
const handler = this.config.handlers.get(change.table);
|
|
759
|
+
if (!handler) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Missing client table handler for WS change table "${change.table}"`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
await handler.applyChange({ trx }, change);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Update subscription cursors
|
|
768
|
+
const stateId = this.config.stateId ?? 'default';
|
|
769
|
+
await sql`
|
|
770
|
+
update ${sql.table('sync_subscription_state')}
|
|
771
|
+
set ${sql.ref('cursor')} = ${sql.val(cursor)}
|
|
772
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
773
|
+
and ${sql.ref('cursor')} < ${sql.val(cursor)}
|
|
774
|
+
`.execute(trx);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Update mutation timestamps BEFORE emitting data:change so that
|
|
778
|
+
// React hooks re-querying the DB see fresh fingerprints immediately.
|
|
779
|
+
const now = Date.now();
|
|
780
|
+
for (const change of changes) {
|
|
781
|
+
if (!change.table || !change.row_id) continue;
|
|
782
|
+
if (change.op === 'delete') {
|
|
783
|
+
this.mutationTimestamps.delete(`${change.table}:${change.row_id}`);
|
|
784
|
+
} else {
|
|
785
|
+
this.bumpMutationTimestamp(change.table, change.row_id, now);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Emit data change for immediate UI update
|
|
790
|
+
const changedTables = [...new Set(changes.map((c) => c.table))];
|
|
791
|
+
if (changedTables.length > 0) {
|
|
792
|
+
this.emit('data:change', {
|
|
793
|
+
scopes: changedTables,
|
|
794
|
+
timestamp: Date.now(),
|
|
795
|
+
});
|
|
796
|
+
this.config.onDataChange?.(changedTables);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return true;
|
|
800
|
+
} catch {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Handle WS-delivered changes: apply them and decide whether to skip HTTP pull.
|
|
807
|
+
* Falls back to full HTTP sync when conditions require it.
|
|
808
|
+
*/
|
|
809
|
+
private async handleWsDelivery(
|
|
810
|
+
changes: SyncChange[],
|
|
811
|
+
cursor: number
|
|
812
|
+
): Promise<void> {
|
|
813
|
+
// If a sync is already in-flight, let it handle everything
|
|
814
|
+
if (this.syncPromise) {
|
|
815
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
816
|
+
attributes: { path: 'inflight_sync' },
|
|
817
|
+
});
|
|
818
|
+
this.triggerSyncInBackground(
|
|
819
|
+
{ trigger: 'ws' },
|
|
820
|
+
'ws delivery with in-flight sync'
|
|
821
|
+
);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// If there are pending outbox commits, need to push via HTTP
|
|
826
|
+
if (this.state.pendingCount > 0) {
|
|
827
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
828
|
+
attributes: { path: 'pending_outbox' },
|
|
829
|
+
});
|
|
830
|
+
this.triggerSyncInBackground(
|
|
831
|
+
{ trigger: 'ws' },
|
|
832
|
+
'ws delivery with pending outbox'
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// If afterPull plugins exist, inline WS changes may require transforms
|
|
838
|
+
// (e.g. decryption). Fall back to HTTP sync and do not apply inline payload.
|
|
839
|
+
const hasAfterPullPlugins = this.config.plugins?.some(
|
|
840
|
+
(p) => typeof p.afterPull === 'function'
|
|
841
|
+
);
|
|
842
|
+
if (hasAfterPullPlugins) {
|
|
843
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
844
|
+
attributes: { path: 'after_pull_plugins' },
|
|
845
|
+
});
|
|
846
|
+
this.triggerSyncInBackground(
|
|
847
|
+
{ trigger: 'ws' },
|
|
848
|
+
'ws delivery with afterPull plugins'
|
|
849
|
+
);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Apply changes + update cursor
|
|
854
|
+
const inlineApplyStartedAtMs = Date.now();
|
|
855
|
+
const applied = await this.applyWsDeliveredChanges(changes, cursor);
|
|
856
|
+
const inlineApplyDurationMs = Math.max(
|
|
857
|
+
0,
|
|
858
|
+
Date.now() - inlineApplyStartedAtMs
|
|
859
|
+
);
|
|
860
|
+
distributionSyncMetric(
|
|
861
|
+
'sync.client.ws.inline_apply.duration_ms',
|
|
862
|
+
inlineApplyDurationMs,
|
|
863
|
+
{
|
|
864
|
+
unit: 'millisecond',
|
|
865
|
+
}
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
if (!applied) {
|
|
869
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
870
|
+
attributes: { path: 'inline_fallback' },
|
|
871
|
+
});
|
|
872
|
+
this.triggerSyncInBackground(
|
|
873
|
+
{ trigger: 'ws' },
|
|
874
|
+
'ws inline apply fallback'
|
|
875
|
+
);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// All clear — skip HTTP pull entirely
|
|
880
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
881
|
+
attributes: { path: 'inline_applied' },
|
|
882
|
+
});
|
|
883
|
+
this.updateState({
|
|
884
|
+
lastSyncAt: Date.now(),
|
|
885
|
+
error: null,
|
|
886
|
+
retryCount: 0,
|
|
887
|
+
isRetrying: false,
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
this.emit('sync:complete', {
|
|
891
|
+
timestamp: Date.now(),
|
|
892
|
+
pushedCommits: 0,
|
|
893
|
+
pullRounds: 0,
|
|
894
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
this.refreshOutboxStats().catch((error) => {
|
|
898
|
+
console.warn(
|
|
899
|
+
'[SyncEngine] Failed to refresh outbox stats after WS apply:',
|
|
900
|
+
error
|
|
901
|
+
);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
655
905
|
private timestampCounter = 0;
|
|
656
906
|
|
|
657
907
|
private nextPreciseTimestamp(now: number): number {
|
|
@@ -758,7 +1008,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
758
1008
|
this.retryTimeoutId = setTimeout(() => {
|
|
759
1009
|
this.retryTimeoutId = null;
|
|
760
1010
|
if (!this.isDestroyed) {
|
|
761
|
-
this.
|
|
1011
|
+
this.triggerSyncInBackground(undefined, 'retry timer');
|
|
762
1012
|
}
|
|
763
1013
|
}, delay);
|
|
764
1014
|
}
|
|
@@ -768,13 +1018,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
768
1018
|
this.config.onError?.(error);
|
|
769
1019
|
}
|
|
770
1020
|
|
|
1021
|
+
private triggerSyncInBackground(
|
|
1022
|
+
opts?: { trigger?: 'ws' | 'local' | 'poll' },
|
|
1023
|
+
reason = 'background'
|
|
1024
|
+
): void {
|
|
1025
|
+
void this.sync(opts).catch((error) => {
|
|
1026
|
+
console.error(
|
|
1027
|
+
`[SyncEngine] Unexpected sync failure during ${reason}:`,
|
|
1028
|
+
error
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
771
1033
|
private setupPolling(): void {
|
|
772
1034
|
this.stopPolling();
|
|
773
1035
|
|
|
774
1036
|
const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
775
1037
|
this.pollerId = setInterval(() => {
|
|
776
1038
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
777
|
-
this.
|
|
1039
|
+
this.triggerSyncInBackground(undefined, 'polling interval');
|
|
778
1040
|
}
|
|
779
1041
|
}, interval);
|
|
780
1042
|
|
|
@@ -827,16 +1089,38 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
827
1089
|
{ clientId: this.config.clientId! },
|
|
828
1090
|
(event) => {
|
|
829
1091
|
if (event.event === 'sync') {
|
|
830
|
-
|
|
1092
|
+
countSyncMetric('sync.client.ws.events', 1, {
|
|
1093
|
+
attributes: { type: 'sync' },
|
|
1094
|
+
});
|
|
1095
|
+
const hasInlineChanges =
|
|
1096
|
+
Array.isArray(event.data.changes) && event.data.changes.length > 0;
|
|
1097
|
+
const cursor = event.data.cursor;
|
|
1098
|
+
|
|
1099
|
+
if (hasInlineChanges && typeof cursor === 'number') {
|
|
1100
|
+
// WS delivered changes + cursor — may skip HTTP pull
|
|
1101
|
+
this.handleWsDelivery(event.data.changes as SyncChange[], cursor);
|
|
1102
|
+
} else {
|
|
1103
|
+
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
1104
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
1105
|
+
attributes: { path: 'cursor_wakeup' },
|
|
1106
|
+
});
|
|
1107
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
|
|
1108
|
+
}
|
|
831
1109
|
}
|
|
832
1110
|
},
|
|
833
1111
|
(state) => {
|
|
834
1112
|
switch (state) {
|
|
835
|
-
case 'connected':
|
|
1113
|
+
case 'connected': {
|
|
1114
|
+
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
1115
|
+
this.hasRealtimeConnectedOnce = true;
|
|
836
1116
|
this.setConnectionState('connected');
|
|
837
1117
|
this.stopFallbackPolling();
|
|
838
|
-
this.
|
|
1118
|
+
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
1119
|
+
if (wasConnectedBefore) {
|
|
1120
|
+
this.scheduleRealtimeReconnectCatchupSync();
|
|
1121
|
+
}
|
|
839
1122
|
break;
|
|
1123
|
+
}
|
|
840
1124
|
case 'connecting':
|
|
841
1125
|
this.setConnectionState('connecting');
|
|
842
1126
|
break;
|
|
@@ -850,6 +1134,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
850
1134
|
}
|
|
851
1135
|
|
|
852
1136
|
private stopRealtime(): void {
|
|
1137
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
1138
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
1139
|
+
this.realtimeCatchupTimeoutId = null;
|
|
1140
|
+
}
|
|
853
1141
|
if (this.realtimePresenceUnsub) {
|
|
854
1142
|
this.realtimePresenceUnsub();
|
|
855
1143
|
this.realtimePresenceUnsub = null;
|
|
@@ -861,13 +1149,28 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
861
1149
|
this.stopFallbackPolling();
|
|
862
1150
|
}
|
|
863
1151
|
|
|
1152
|
+
private scheduleRealtimeReconnectCatchupSync(): void {
|
|
1153
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
1154
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
this.realtimeCatchupTimeoutId = setTimeout(() => {
|
|
1158
|
+
this.realtimeCatchupTimeoutId = null;
|
|
1159
|
+
|
|
1160
|
+
if (this.isDestroyed || !this.isEnabled()) return;
|
|
1161
|
+
if (this.state.connectionState !== 'connected') return;
|
|
1162
|
+
|
|
1163
|
+
this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
|
|
1164
|
+
}, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
864
1167
|
private startFallbackPolling(): void {
|
|
865
1168
|
if (this.fallbackPollerId) return;
|
|
866
1169
|
|
|
867
1170
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
868
1171
|
this.fallbackPollerId = setInterval(() => {
|
|
869
1172
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
870
|
-
this.
|
|
1173
|
+
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
871
1174
|
}
|
|
872
1175
|
}, interval);
|
|
873
1176
|
}
|
|
@@ -879,6 +1182,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
879
1182
|
}
|
|
880
1183
|
}
|
|
881
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|
|
1187
|
+
* Call this after deleting local data (e.g. reset flow) so that React hooks
|
|
1188
|
+
* recompute fingerprints from scratch instead of seeing stale timestamps.
|
|
1189
|
+
*/
|
|
1190
|
+
resetLocalState(): void {
|
|
1191
|
+
const tables = [...this.tableMutationTimestamps.keys()];
|
|
1192
|
+
this.mutationTimestamps.clear();
|
|
1193
|
+
this.tableMutationTimestamps.clear();
|
|
1194
|
+
|
|
1195
|
+
if (tables.length > 0) {
|
|
1196
|
+
this.emit('data:change', {
|
|
1197
|
+
scopes: tables,
|
|
1198
|
+
timestamp: Date.now(),
|
|
1199
|
+
});
|
|
1200
|
+
this.config.onDataChange?.(tables);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
882
1204
|
/**
|
|
883
1205
|
* Reconnect
|
|
884
1206
|
*/
|
|
@@ -901,10 +1223,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
901
1223
|
// Polling mode: restart the poller and trigger a sync immediately.
|
|
902
1224
|
if (this.state.transportMode === 'polling') {
|
|
903
1225
|
this.setupPolling();
|
|
904
|
-
|
|
905
|
-
this.sync().catch((err) => {
|
|
906
|
-
console.error('Unexpected error during reconnect sync:', err);
|
|
907
|
-
});
|
|
1226
|
+
this.triggerSyncInBackground(undefined, 'reconnect');
|
|
908
1227
|
}
|
|
909
1228
|
}
|
|
910
1229
|
|
|
@@ -1061,7 +1380,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1061
1380
|
): void {
|
|
1062
1381
|
this.config.subscriptions = subscriptions;
|
|
1063
1382
|
// Trigger a sync to apply new subscriptions
|
|
1064
|
-
this.
|
|
1383
|
+
this.triggerSyncInBackground(undefined, 'subscription update');
|
|
1065
1384
|
}
|
|
1066
1385
|
|
|
1067
1386
|
/**
|
|
@@ -1077,13 +1396,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1077
1396
|
}>
|
|
1078
1397
|
): Promise<void> {
|
|
1079
1398
|
const db = this.config.db;
|
|
1080
|
-
const
|
|
1399
|
+
const handlers = this.config.handlers;
|
|
1081
1400
|
const affectedTables = new Set<string>();
|
|
1082
1401
|
const now = Date.now();
|
|
1083
1402
|
|
|
1084
1403
|
await db.transaction().execute(async (trx) => {
|
|
1085
1404
|
for (const input of inputs) {
|
|
1086
|
-
const handler =
|
|
1405
|
+
const handler = handlers.get(input.table);
|
|
1087
1406
|
if (!handler) continue;
|
|
1088
1407
|
|
|
1089
1408
|
affectedTables.add(input.table);
|
package/src/engine/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
SyncPullResponse,
|
|
9
|
+
SyncPushRequest,
|
|
10
|
+
SyncPushResponse,
|
|
9
11
|
SyncSubscriptionRequest,
|
|
10
12
|
SyncTransport,
|
|
11
13
|
} from '@syncular/core';
|
|
@@ -137,8 +139,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
137
139
|
db: Kysely<DB>;
|
|
138
140
|
/** Sync transport */
|
|
139
141
|
transport: SyncTransport;
|
|
140
|
-
/** Client
|
|
141
|
-
|
|
142
|
+
/** Client table handler registry */
|
|
143
|
+
handlers: ClientTableRegistry<DB>;
|
|
142
144
|
/** Actor id for sync scoping (null/undefined disables sync) */
|
|
143
145
|
actorId: string | null | undefined;
|
|
144
146
|
/** Stable device/app installation id */
|
|
@@ -161,7 +163,11 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
161
163
|
migrate?: (db: Kysely<DB>) => Promise<void>;
|
|
162
164
|
/** Called when migration fails. Receives the error. */
|
|
163
165
|
onMigrationError?: (error: Error) => void;
|
|
164
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Enable realtime mode (WebSocket wake-ups).
|
|
168
|
+
* Default behavior is auto-enable when transport supports realtime.
|
|
169
|
+
* Set to false to force polling.
|
|
170
|
+
*/
|
|
165
171
|
realtimeEnabled?: boolean;
|
|
166
172
|
/** Fallback poll interval when realtime reconnecting */
|
|
167
173
|
realtimeFallbackPollMs?: number;
|
|
@@ -173,6 +179,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
173
179
|
onDataChange?: (scopes: string[]) => void;
|
|
174
180
|
/** Optional client plugins (e.g. encryption) */
|
|
175
181
|
plugins?: SyncClientPlugin[];
|
|
182
|
+
/** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
|
|
183
|
+
sha256?: (bytes: Uint8Array) => Promise<string>;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
/**
|
|
@@ -205,7 +213,12 @@ export interface RealtimeTransportLike extends SyncTransport {
|
|
|
205
213
|
args: { clientId: string },
|
|
206
214
|
onEvent: (event: {
|
|
207
215
|
event: string;
|
|
208
|
-
data: {
|
|
216
|
+
data: {
|
|
217
|
+
cursor?: number;
|
|
218
|
+
changes?: unknown[];
|
|
219
|
+
error?: string;
|
|
220
|
+
timestamp: number;
|
|
221
|
+
};
|
|
209
222
|
}) => void,
|
|
210
223
|
onStateChange?: (state: 'disconnected' | 'connecting' | 'connected') => void
|
|
211
224
|
): () => void;
|
|
@@ -227,6 +240,11 @@ export interface RealtimeTransportLike extends SyncTransport {
|
|
|
227
240
|
entries?: PresenceEntry[];
|
|
228
241
|
}) => void
|
|
229
242
|
): () => void;
|
|
243
|
+
/**
|
|
244
|
+
* Push a commit via WebSocket (bypasses HTTP).
|
|
245
|
+
* Returns `null` if WS is not connected or times out (caller should fall back to HTTP).
|
|
246
|
+
*/
|
|
247
|
+
pushViaWs?(request: SyncPushRequest): Promise<SyncPushResponse | null>;
|
|
230
248
|
}
|
|
231
249
|
|
|
232
250
|
/**
|