@syncular/client 0.0.1 → 0.0.2-127
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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-client.js","sourceRoot":"","sources":["../src/create-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
1
|
+
{"version":3,"file":"create-client.js","sourceRoot":"","sources":["../src/create-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AASH,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAE/D,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,SAAS,+BAA+B,CAAK,IAG5C,EAAe;IACd,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,IAAI,EAAE,CAAC;IAClD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,KAAK,MAAM,CAAC,IAAI,gBAAgB,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,YAAY,IAAI,CAAC,OAAO,CAAC,KAAK,4DAA4D;YACxF,qDAAqD;YACrD,8CACE,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAC9C,GAAG,CACN,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;IAC5B,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;AAAA,CACpC;AAED;;;GAGG;AACH,SAAS,iBAAiB,CAIxB,KAAa,EACb,MAAgB,EAChB,OAGC,EACkC;IACnC,OAAO,mBAAmB,CAAgB;QACxC,KAAK,EAAE,KAAkB;QACzB,MAAM,EAAE,MAA2B;QACnC,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,YAAY,EAAE,OAAO,CAAC,YAAY;KACnC,CAAC,CAAC;AAAA,CACJ;AAyFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,OAAgC,EACC;IACjC,MAAM,EACJ,EAAE,EACF,GAAG,GAAG,WAAW,EACjB,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EACN,MAAM,EACN,OAAO,EACP,QAAQ,GAAG,UAAU,EAAE,EACvB,SAAS,EAAE,eAAe,EAC1B,UAAU,EACV,IAAI,GAAG,EAAE,EACT,WAAW,EACX,OAAO,EACP,OAAO,EACP,YAAY,EACZ,YAAY,EACZ,SAAS,GAAG,IAAI,GACjB,GAAG,OAAO,CAAC;IAEZ,mBAAmB;IACnB,IAAI,CAAC,gBAAgB,IAAI,CAAC,MAAM,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACjE,CAAC;IAED,+CAA+C;IAC/C,MAAM,QAAQ,GACZ,gBAAgB;QAChB,MAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACpB,iBAAiB,CAAwB,KAAK,EAAE,MAAO,EAAE;YACvD,YAAY;YACZ,YAAY;SACb,CAAC,CACH,CAAC;IAEJ,qCAAqC;IACrC,MAAM,aAAa,GAAG,IAAI,mBAAmB,EAAM,CAAC;IACpD,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAClC,CAAC;IAED,4CAA4C;IAC5C,IAAI,SAAS,GAAG,eAAe,CAAC;IAChC,IAAI,CAAC,SAAS,IAAI,GAAG,EAAE,CAAC;QACtB,SAAS,GAAG,mBAAmB,CAAC;YAC9B,OAAO,EAAE,GAAG;YACZ,UAAU;SACX,CAAC,CAAC;IACL,CAAC;IAED,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;IAC9D,CAAC;IAED,oCAAoC;IACpC,MAAM,aAAa,GAAG,QAAQ;SAC3B,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QAChB,MAAM,GAAG,GAAG,OAAO,CAAC,SAAS,CAAC;QAC9B,6CAA6C;QAC7C,IAAI,GAAG,KAAK,KAAK;YAAE,OAAO,IAAI,CAAC;QAE/B,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;YACtC,sEAAsE;YACtE,4EAA4E;YAC5E,MAAM,MAAM,GAAG,+BAA+B,CAAC;gBAC7C,OAAO;gBACP,OAAO;aACR,CAAC,CAAC;YACH,OAAO;gBACL,EAAE,EAAE,OAAO,CAAC,KAAK;gBACjB,KAAK,EAAE,OAAO,CAAC,KAAK;gBACpB,MAAM;gBACN,MAAM,EAAE,EAAE;aACX,CAAC;QACJ,CAAC;QACD,6BAA6B;QAC7B,MAAM,MAAM,GAAgB,EAAE,CAAC;QAC/B,KAAK,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,CAAC;YACtE,IAAI,UAAU,KAAK,SAAS;gBAAE,SAAS;YACvC,MAAM,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC;QAChC,CAAC;QACD,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACb,YAAY,OAAO,CAAC,KAAK,yCAAyC;gBAChE,mDAAmD,CACtD,CAAC;QACJ,CAAC;QACD,OAAO;YACL,EAAE,EAAE,OAAO,CAAC,KAAK;YACjB,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,MAAM;YACN,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,EAAE;SACzB,CAAC;IAAA,CACH,CAAC;SACD,MAAM,CAAC,CAAC,GAAG,EAAkC,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,CAAC;IAEjE,gBAAgB;IAChB,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC;QACxB,EAAE;QACF,SAAS;QACT,aAAa;QACb,QAAQ;QACR,OAAO;QACP,aAAa;QACb,WAAW;QACX,OAAO;QACP,OAAO;QACP,YAAY;QACZ,YAAY;QACZ,eAAe,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI;QACtC,cAAc,EAAE,IAAI,CAAC,cAAc;KACpC,CAAC,CAAC;IAEH,aAAa;IACb,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;IAED,OAAO;QACL,MAAM;QACN,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE;QACzB,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE;KAChC,CAAC;AAAA,CACH"}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Event-driven sync engine that manages push/pull cycles, connection state,
|
|
5
5
|
* and provides a clean API for framework bindings to consume.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { type SyncSubscriptionRequest } from '@syncular/core';
|
|
8
8
|
import { type Kysely } from 'kysely';
|
|
9
9
|
import type { SyncClientDb } from '../schema';
|
|
10
10
|
import type { ConflictInfo, OutboxStats, PresenceEntry, SyncEngineConfig, SyncEngineState, SyncEventListener, SyncEventType, SyncResult } from './types';
|
|
@@ -21,12 +21,20 @@ export declare class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
21
21
|
private syncPromise;
|
|
22
22
|
private syncRequestedWhileRunning;
|
|
23
23
|
private retryTimeoutId;
|
|
24
|
+
private realtimeCatchupTimeoutId;
|
|
25
|
+
private hasRealtimeConnectedOnce;
|
|
24
26
|
/**
|
|
25
27
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
26
28
|
* Used for efficient fingerprint-based rerender optimization.
|
|
27
29
|
* Key format: `${table}:${rowId}`, Value: timestamp (Date.now())
|
|
28
30
|
*/
|
|
29
31
|
private mutationTimestamps;
|
|
32
|
+
/**
|
|
33
|
+
* In-memory map tracking table-level mutation timestamps.
|
|
34
|
+
* Used for coarse invalidation during large bootstrap snapshots to avoid
|
|
35
|
+
* storing timestamps for every row.
|
|
36
|
+
*/
|
|
37
|
+
private tableMutationTimestamps;
|
|
30
38
|
/**
|
|
31
39
|
* In-memory presence state by scope key.
|
|
32
40
|
* Updated via realtime presence events.
|
|
@@ -118,12 +126,18 @@ export declare class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
118
126
|
/**
|
|
119
127
|
* Trigger a manual sync
|
|
120
128
|
*/
|
|
121
|
-
sync(
|
|
129
|
+
sync(opts?: {
|
|
130
|
+
trigger?: 'ws' | 'local' | 'poll';
|
|
131
|
+
}): Promise<SyncResult>;
|
|
122
132
|
private performSyncLoop;
|
|
123
133
|
private performSyncOnce;
|
|
124
134
|
private extractChangedTables;
|
|
135
|
+
private applyWsDeliveredChanges;
|
|
136
|
+
private handleWsDelivery;
|
|
125
137
|
private timestampCounter;
|
|
138
|
+
private nextPreciseTimestamp;
|
|
126
139
|
private bumpMutationTimestamp;
|
|
140
|
+
private bumpTableMutationTimestamp;
|
|
127
141
|
/**
|
|
128
142
|
* Record local mutations that were already applied to the DB.
|
|
129
143
|
*
|
|
@@ -142,12 +156,20 @@ export declare class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
142
156
|
private recordMutationTimestampsFromPullResponse;
|
|
143
157
|
private scheduleRetry;
|
|
144
158
|
private handleError;
|
|
159
|
+
private triggerSyncInBackground;
|
|
145
160
|
private setupPolling;
|
|
146
161
|
private stopPolling;
|
|
147
162
|
private setupRealtime;
|
|
148
163
|
private stopRealtime;
|
|
164
|
+
private scheduleRealtimeReconnectCatchupSync;
|
|
149
165
|
private startFallbackPolling;
|
|
150
166
|
private stopFallbackPolling;
|
|
167
|
+
/**
|
|
168
|
+
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|
|
169
|
+
* Call this after deleting local data (e.g. reset flow) so that React hooks
|
|
170
|
+
* recompute fingerprints from scratch instead of seeing stale timestamps.
|
|
171
|
+
*/
|
|
172
|
+
resetLocalState(): void;
|
|
151
173
|
/**
|
|
152
174
|
* Reconnect
|
|
153
175
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SyncEngine.d.ts","sourceRoot":"","sources":["../../src/engine/SyncEngine.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"SyncEngine.d.ts","sourceRoot":"","sources":["../../src/engine/SyncEngine.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
* Event-driven sync engine that manages push/pull cycles, connection state,
|
|
5
5
|
* and provides a clean API for framework bindings to consume.
|
|
6
6
|
*/
|
|
7
|
+
import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, startSyncSpan, } from '@syncular/core';
|
|
7
8
|
import { sql } from 'kysely';
|
|
8
|
-
import { syncPushOnce } from '../push-engine';
|
|
9
|
-
import { syncOnce } from '../sync-loop';
|
|
9
|
+
import { syncPushOnce } from '../push-engine.js';
|
|
10
|
+
import { syncOnce } from '../sync-loop.js';
|
|
10
11
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
11
12
|
const DEFAULT_MAX_RETRIES = 5;
|
|
12
13
|
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
13
14
|
const MAX_RETRY_DELAY_MS = 60000;
|
|
14
15
|
const EXPONENTIAL_FACTOR = 2;
|
|
16
|
+
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
15
17
|
function calculateRetryDelay(attemptIndex) {
|
|
16
18
|
return Math.min(INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex, MAX_RETRY_DELAY_MS);
|
|
17
19
|
}
|
|
@@ -28,8 +30,8 @@ function createSyncError(code, message, cause) {
|
|
|
28
30
|
timestamp: Date.now(),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
|
-
function
|
|
32
|
-
return
|
|
33
|
+
function resolveSyncTriggerLabel(trigger) {
|
|
34
|
+
return trigger ?? 'auto';
|
|
33
35
|
}
|
|
34
36
|
export class SyncEngine {
|
|
35
37
|
config;
|
|
@@ -44,12 +46,20 @@ export class SyncEngine {
|
|
|
44
46
|
syncPromise = null;
|
|
45
47
|
syncRequestedWhileRunning = false;
|
|
46
48
|
retryTimeoutId = null;
|
|
49
|
+
realtimeCatchupTimeoutId = null;
|
|
50
|
+
hasRealtimeConnectedOnce = false;
|
|
47
51
|
/**
|
|
48
52
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
49
53
|
* Used for efficient fingerprint-based rerender optimization.
|
|
50
54
|
* Key format: `${table}:${rowId}`, Value: timestamp (Date.now())
|
|
51
55
|
*/
|
|
52
56
|
mutationTimestamps = new Map();
|
|
57
|
+
/**
|
|
58
|
+
* In-memory map tracking table-level mutation timestamps.
|
|
59
|
+
* Used for coarse invalidation during large bootstrap snapshots to avoid
|
|
60
|
+
* storing timestamps for every row.
|
|
61
|
+
*/
|
|
62
|
+
tableMutationTimestamps = new Map();
|
|
53
63
|
/**
|
|
54
64
|
* In-memory presence state by scope key.
|
|
55
65
|
* Updated via realtime presence events.
|
|
@@ -65,7 +75,9 @@ export class SyncEngine {
|
|
|
65
75
|
* Returns 0 if row has no recorded mutation timestamp.
|
|
66
76
|
*/
|
|
67
77
|
getMutationTimestamp(table, rowId) {
|
|
68
|
-
|
|
78
|
+
const rowTs = this.mutationTimestamps.get(`${table}:${rowId}`) ?? 0;
|
|
79
|
+
const tableTs = this.tableMutationTimestamps.get(table) ?? 0;
|
|
80
|
+
return Math.max(rowTs, tableTs);
|
|
69
81
|
}
|
|
70
82
|
/**
|
|
71
83
|
* Get presence entries for a scope key.
|
|
@@ -187,7 +199,7 @@ export class SyncEngine {
|
|
|
187
199
|
clientId.length > 0);
|
|
188
200
|
}
|
|
189
201
|
detectTransportMode() {
|
|
190
|
-
if (this.config.realtimeEnabled &&
|
|
202
|
+
if (this.config.realtimeEnabled !== false &&
|
|
191
203
|
isRealtimeTransport(this.config.transport)) {
|
|
192
204
|
return 'realtime';
|
|
193
205
|
}
|
|
@@ -345,11 +357,15 @@ export class SyncEngine {
|
|
|
345
357
|
clearTimeout(this.retryTimeoutId);
|
|
346
358
|
this.retryTimeoutId = null;
|
|
347
359
|
}
|
|
360
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
361
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
362
|
+
this.realtimeCatchupTimeoutId = null;
|
|
363
|
+
}
|
|
348
364
|
}
|
|
349
365
|
/**
|
|
350
366
|
* Trigger a manual sync
|
|
351
367
|
*/
|
|
352
|
-
async sync() {
|
|
368
|
+
async sync(opts) {
|
|
353
369
|
// Dedupe concurrent sync calls
|
|
354
370
|
if (this.syncPromise) {
|
|
355
371
|
// A sync is already in-flight; queue one more run so we don't miss
|
|
@@ -368,7 +384,7 @@ export class SyncEngine {
|
|
|
368
384
|
error: createSyncError('SYNC_ERROR', 'Sync not enabled'),
|
|
369
385
|
};
|
|
370
386
|
}
|
|
371
|
-
this.syncPromise = this.performSyncLoop();
|
|
387
|
+
this.syncPromise = this.performSyncLoop(opts?.trigger);
|
|
372
388
|
try {
|
|
373
389
|
return await this.syncPromise;
|
|
374
390
|
}
|
|
@@ -376,7 +392,7 @@ export class SyncEngine {
|
|
|
376
392
|
this.syncPromise = null;
|
|
377
393
|
}
|
|
378
394
|
}
|
|
379
|
-
async performSyncLoop() {
|
|
395
|
+
async performSyncLoop(trigger) {
|
|
380
396
|
let lastResult = {
|
|
381
397
|
success: false,
|
|
382
398
|
pushedCommits: 0,
|
|
@@ -386,7 +402,9 @@ export class SyncEngine {
|
|
|
386
402
|
};
|
|
387
403
|
do {
|
|
388
404
|
this.syncRequestedWhileRunning = false;
|
|
389
|
-
lastResult = await this.performSyncOnce();
|
|
405
|
+
lastResult = await this.performSyncOnce(trigger);
|
|
406
|
+
// After the first iteration, clear trigger context
|
|
407
|
+
trigger = undefined;
|
|
390
408
|
// If the sync failed, let retry logic handle backoff instead of tight looping.
|
|
391
409
|
if (!lastResult.success)
|
|
392
410
|
break;
|
|
@@ -395,22 +413,34 @@ export class SyncEngine {
|
|
|
395
413
|
this.isEnabled());
|
|
396
414
|
return lastResult;
|
|
397
415
|
}
|
|
398
|
-
async performSyncOnce() {
|
|
416
|
+
async performSyncOnce(trigger) {
|
|
399
417
|
const timestamp = Date.now();
|
|
418
|
+
const startedAtMs = timestamp;
|
|
419
|
+
const triggerLabel = resolveSyncTriggerLabel(trigger);
|
|
400
420
|
this.updateState({ isSyncing: true });
|
|
401
421
|
this.emit('sync:start', { timestamp });
|
|
422
|
+
countSyncMetric('sync.client.sync.attempts', 1, {
|
|
423
|
+
attributes: { trigger: triggerLabel },
|
|
424
|
+
});
|
|
402
425
|
try {
|
|
403
426
|
const pullApplyTimestamp = Date.now();
|
|
404
|
-
const result = await
|
|
427
|
+
const result = await startSyncSpan({
|
|
428
|
+
name: 'sync.client.sync',
|
|
429
|
+
op: 'sync.client.sync',
|
|
430
|
+
attributes: { trigger: triggerLabel },
|
|
431
|
+
}, () => syncOnce(this.config.db, this.config.transport, this.config.handlers, {
|
|
405
432
|
clientId: this.config.clientId,
|
|
406
433
|
actorId: this.config.actorId ?? undefined,
|
|
407
434
|
plugins: this.config.plugins,
|
|
408
|
-
subscriptions: this.config
|
|
435
|
+
subscriptions: this.config
|
|
436
|
+
.subscriptions,
|
|
409
437
|
limitCommits: this.config.limitCommits,
|
|
410
438
|
limitSnapshotRows: this.config.limitSnapshotRows,
|
|
411
439
|
maxSnapshotPages: this.config.maxSnapshotPages,
|
|
412
440
|
stateId: this.config.stateId,
|
|
413
|
-
|
|
441
|
+
sha256: this.config.sha256,
|
|
442
|
+
trigger,
|
|
443
|
+
}));
|
|
414
444
|
const syncResult = {
|
|
415
445
|
success: true,
|
|
416
446
|
pushedCommits: result.pushedCommits,
|
|
@@ -442,8 +472,30 @@ export class SyncEngine {
|
|
|
442
472
|
});
|
|
443
473
|
this.config.onDataChange?.(changedTables);
|
|
444
474
|
}
|
|
445
|
-
// Refresh outbox stats
|
|
446
|
-
|
|
475
|
+
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
476
|
+
this.refreshOutboxStats().catch((error) => {
|
|
477
|
+
console.warn('[SyncEngine] Failed to refresh outbox stats after sync:', error);
|
|
478
|
+
});
|
|
479
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
480
|
+
countSyncMetric('sync.client.sync.results', 1, {
|
|
481
|
+
attributes: {
|
|
482
|
+
trigger: triggerLabel,
|
|
483
|
+
status: 'success',
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
|
|
487
|
+
unit: 'millisecond',
|
|
488
|
+
attributes: {
|
|
489
|
+
trigger: triggerLabel,
|
|
490
|
+
status: 'success',
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
distributionSyncMetric('sync.client.sync.pushed_commits', result.pushedCommits, {
|
|
494
|
+
attributes: { trigger: triggerLabel },
|
|
495
|
+
});
|
|
496
|
+
distributionSyncMetric('sync.client.sync.pull_rounds', result.pullRounds, {
|
|
497
|
+
attributes: { trigger: triggerLabel },
|
|
498
|
+
});
|
|
447
499
|
return syncResult;
|
|
448
500
|
}
|
|
449
501
|
catch (err) {
|
|
@@ -455,6 +507,24 @@ export class SyncEngine {
|
|
|
455
507
|
isRetrying: false,
|
|
456
508
|
});
|
|
457
509
|
this.handleError(error);
|
|
510
|
+
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
511
|
+
countSyncMetric('sync.client.sync.results', 1, {
|
|
512
|
+
attributes: {
|
|
513
|
+
trigger: triggerLabel,
|
|
514
|
+
status: 'error',
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
distributionSyncMetric('sync.client.sync.duration_ms', durationMs, {
|
|
518
|
+
unit: 'millisecond',
|
|
519
|
+
attributes: {
|
|
520
|
+
trigger: triggerLabel,
|
|
521
|
+
status: 'error',
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
captureSyncException(err, {
|
|
525
|
+
event: 'sync.client.sync',
|
|
526
|
+
trigger: triggerLabel,
|
|
527
|
+
});
|
|
458
528
|
// Schedule retry if under max retries
|
|
459
529
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
460
530
|
if (this.state.retryCount < maxRetries) {
|
|
@@ -489,16 +559,141 @@ export class SyncEngine {
|
|
|
489
559
|
}
|
|
490
560
|
return Array.from(tables);
|
|
491
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Apply changes delivered inline over WebSocket for instant UI updates.
|
|
564
|
+
* Returns true if changes were applied and cursor updated successfully,
|
|
565
|
+
* false if anything failed (caller should fall back to HTTP sync).
|
|
566
|
+
*/
|
|
567
|
+
async applyWsDeliveredChanges(changes, cursor) {
|
|
568
|
+
try {
|
|
569
|
+
await this.config.db.transaction().execute(async (trx) => {
|
|
570
|
+
for (const change of changes) {
|
|
571
|
+
const handler = this.config.handlers.get(change.table);
|
|
572
|
+
if (!handler) {
|
|
573
|
+
throw new Error(`Missing client table handler for WS change table "${change.table}"`);
|
|
574
|
+
}
|
|
575
|
+
await handler.applyChange({ trx }, change);
|
|
576
|
+
}
|
|
577
|
+
// Update subscription cursors
|
|
578
|
+
const stateId = this.config.stateId ?? 'default';
|
|
579
|
+
await sql `
|
|
580
|
+
update ${sql.table('sync_subscription_state')}
|
|
581
|
+
set ${sql.ref('cursor')} = ${sql.val(cursor)}
|
|
582
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
583
|
+
and ${sql.ref('cursor')} < ${sql.val(cursor)}
|
|
584
|
+
`.execute(trx);
|
|
585
|
+
});
|
|
586
|
+
// Update mutation timestamps BEFORE emitting data:change so that
|
|
587
|
+
// React hooks re-querying the DB see fresh fingerprints immediately.
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
for (const change of changes) {
|
|
590
|
+
if (!change.table || !change.row_id)
|
|
591
|
+
continue;
|
|
592
|
+
if (change.op === 'delete') {
|
|
593
|
+
this.mutationTimestamps.delete(`${change.table}:${change.row_id}`);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
this.bumpMutationTimestamp(change.table, change.row_id, now);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Emit data change for immediate UI update
|
|
600
|
+
const changedTables = [...new Set(changes.map((c) => c.table))];
|
|
601
|
+
if (changedTables.length > 0) {
|
|
602
|
+
this.emit('data:change', {
|
|
603
|
+
scopes: changedTables,
|
|
604
|
+
timestamp: Date.now(),
|
|
605
|
+
});
|
|
606
|
+
this.config.onDataChange?.(changedTables);
|
|
607
|
+
}
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Handle WS-delivered changes: apply them and decide whether to skip HTTP pull.
|
|
616
|
+
* Falls back to full HTTP sync when conditions require it.
|
|
617
|
+
*/
|
|
618
|
+
async handleWsDelivery(changes, cursor) {
|
|
619
|
+
// If a sync is already in-flight, let it handle everything
|
|
620
|
+
if (this.syncPromise) {
|
|
621
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
622
|
+
attributes: { path: 'inflight_sync' },
|
|
623
|
+
});
|
|
624
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with in-flight sync');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// If there are pending outbox commits, need to push via HTTP
|
|
628
|
+
if (this.state.pendingCount > 0) {
|
|
629
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
630
|
+
attributes: { path: 'pending_outbox' },
|
|
631
|
+
});
|
|
632
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with pending outbox');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// If afterPull plugins exist, inline WS changes may require transforms
|
|
636
|
+
// (e.g. decryption). Fall back to HTTP sync and do not apply inline payload.
|
|
637
|
+
const hasAfterPullPlugins = this.config.plugins?.some((p) => typeof p.afterPull === 'function');
|
|
638
|
+
if (hasAfterPullPlugins) {
|
|
639
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
640
|
+
attributes: { path: 'after_pull_plugins' },
|
|
641
|
+
});
|
|
642
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws delivery with afterPull plugins');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Apply changes + update cursor
|
|
646
|
+
const inlineApplyStartedAtMs = Date.now();
|
|
647
|
+
const applied = await this.applyWsDeliveredChanges(changes, cursor);
|
|
648
|
+
const inlineApplyDurationMs = Math.max(0, Date.now() - inlineApplyStartedAtMs);
|
|
649
|
+
distributionSyncMetric('sync.client.ws.inline_apply.duration_ms', inlineApplyDurationMs, {
|
|
650
|
+
unit: 'millisecond',
|
|
651
|
+
});
|
|
652
|
+
if (!applied) {
|
|
653
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
654
|
+
attributes: { path: 'inline_fallback' },
|
|
655
|
+
});
|
|
656
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws inline apply fallback');
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
// All clear — skip HTTP pull entirely
|
|
660
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
661
|
+
attributes: { path: 'inline_applied' },
|
|
662
|
+
});
|
|
663
|
+
this.updateState({
|
|
664
|
+
lastSyncAt: Date.now(),
|
|
665
|
+
error: null,
|
|
666
|
+
retryCount: 0,
|
|
667
|
+
isRetrying: false,
|
|
668
|
+
});
|
|
669
|
+
this.emit('sync:complete', {
|
|
670
|
+
timestamp: Date.now(),
|
|
671
|
+
pushedCommits: 0,
|
|
672
|
+
pullRounds: 0,
|
|
673
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
674
|
+
});
|
|
675
|
+
this.refreshOutboxStats().catch((error) => {
|
|
676
|
+
console.warn('[SyncEngine] Failed to refresh outbox stats after WS apply:', error);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
492
679
|
timestampCounter = 0;
|
|
493
|
-
|
|
494
|
-
const key = `${table}:${rowId}`;
|
|
680
|
+
nextPreciseTimestamp(now) {
|
|
495
681
|
// Use sub-millisecond precision by combining timestamp with atomic counter
|
|
496
682
|
// This prevents race conditions in concurrent mutations while maintaining
|
|
497
|
-
// millisecond-level compatibility with existing code
|
|
498
|
-
|
|
683
|
+
// millisecond-level compatibility with existing code.
|
|
684
|
+
return now + (this.timestampCounter++ % 1000) / 1000;
|
|
685
|
+
}
|
|
686
|
+
bumpMutationTimestamp(table, rowId, now) {
|
|
687
|
+
const key = `${table}:${rowId}`;
|
|
688
|
+
const preciseNow = this.nextPreciseTimestamp(now);
|
|
499
689
|
const prev = this.mutationTimestamps.get(key) ?? 0;
|
|
500
690
|
this.mutationTimestamps.set(key, Math.max(preciseNow, prev + 0.001));
|
|
501
691
|
}
|
|
692
|
+
bumpTableMutationTimestamp(table, now) {
|
|
693
|
+
const preciseNow = this.nextPreciseTimestamp(now);
|
|
694
|
+
const prev = this.tableMutationTimestamps.get(table) ?? 0;
|
|
695
|
+
this.tableMutationTimestamps.set(table, Math.max(preciseNow, prev + 0.001));
|
|
696
|
+
}
|
|
502
697
|
/**
|
|
503
698
|
* Record local mutations that were already applied to the DB.
|
|
504
699
|
*
|
|
@@ -531,17 +726,12 @@ export class SyncEngine {
|
|
|
531
726
|
}
|
|
532
727
|
recordMutationTimestampsFromPullResponse(response, now) {
|
|
533
728
|
for (const sub of response.subscriptions ?? []) {
|
|
534
|
-
// Mark snapshot
|
|
535
|
-
//
|
|
729
|
+
// Mark snapshot tables as changed so bootstrap/resnapshot updates
|
|
730
|
+
// propagate without storing per-row timestamps for massive snapshots.
|
|
536
731
|
for (const snapshot of sub.snapshots ?? []) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const id = row?.id;
|
|
541
|
-
if (id == null)
|
|
542
|
-
continue;
|
|
543
|
-
this.bumpMutationTimestamp(table, String(id), now);
|
|
544
|
-
}
|
|
732
|
+
if (!snapshot.table)
|
|
733
|
+
continue;
|
|
734
|
+
this.bumpTableMutationTimestamp(snapshot.table, now);
|
|
545
735
|
}
|
|
546
736
|
for (const commit of sub.commits ?? []) {
|
|
547
737
|
for (const change of commit.changes ?? []) {
|
|
@@ -568,7 +758,7 @@ export class SyncEngine {
|
|
|
568
758
|
this.retryTimeoutId = setTimeout(() => {
|
|
569
759
|
this.retryTimeoutId = null;
|
|
570
760
|
if (!this.isDestroyed) {
|
|
571
|
-
this.
|
|
761
|
+
this.triggerSyncInBackground(undefined, 'retry timer');
|
|
572
762
|
}
|
|
573
763
|
}, delay);
|
|
574
764
|
}
|
|
@@ -576,12 +766,17 @@ export class SyncEngine {
|
|
|
576
766
|
this.emit('sync:error', error);
|
|
577
767
|
this.config.onError?.(error);
|
|
578
768
|
}
|
|
769
|
+
triggerSyncInBackground(opts, reason = 'background') {
|
|
770
|
+
void this.sync(opts).catch((error) => {
|
|
771
|
+
console.error(`[SyncEngine] Unexpected sync failure during ${reason}:`, error);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
579
774
|
setupPolling() {
|
|
580
775
|
this.stopPolling();
|
|
581
776
|
const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
582
777
|
this.pollerId = setInterval(() => {
|
|
583
778
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
584
|
-
this.
|
|
779
|
+
this.triggerSyncInBackground(undefined, 'polling interval');
|
|
585
780
|
}
|
|
586
781
|
}, interval);
|
|
587
782
|
this.setConnectionState('connected');
|
|
@@ -622,15 +817,36 @@ export class SyncEngine {
|
|
|
622
817
|
}
|
|
623
818
|
this.realtimeDisconnect = transport.connect({ clientId: this.config.clientId }, (event) => {
|
|
624
819
|
if (event.event === 'sync') {
|
|
625
|
-
|
|
820
|
+
countSyncMetric('sync.client.ws.events', 1, {
|
|
821
|
+
attributes: { type: 'sync' },
|
|
822
|
+
});
|
|
823
|
+
const hasInlineChanges = Array.isArray(event.data.changes) && event.data.changes.length > 0;
|
|
824
|
+
const cursor = event.data.cursor;
|
|
825
|
+
if (hasInlineChanges && typeof cursor === 'number') {
|
|
826
|
+
// WS delivered changes + cursor — may skip HTTP pull
|
|
827
|
+
this.handleWsDelivery(event.data.changes, cursor);
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
831
|
+
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
|
832
|
+
attributes: { path: 'cursor_wakeup' },
|
|
833
|
+
});
|
|
834
|
+
this.triggerSyncInBackground({ trigger: 'ws' }, 'ws cursor wakeup');
|
|
835
|
+
}
|
|
626
836
|
}
|
|
627
837
|
}, (state) => {
|
|
628
838
|
switch (state) {
|
|
629
|
-
case 'connected':
|
|
839
|
+
case 'connected': {
|
|
840
|
+
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
841
|
+
this.hasRealtimeConnectedOnce = true;
|
|
630
842
|
this.setConnectionState('connected');
|
|
631
843
|
this.stopFallbackPolling();
|
|
632
|
-
this.
|
|
844
|
+
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
845
|
+
if (wasConnectedBefore) {
|
|
846
|
+
this.scheduleRealtimeReconnectCatchupSync();
|
|
847
|
+
}
|
|
633
848
|
break;
|
|
849
|
+
}
|
|
634
850
|
case 'connecting':
|
|
635
851
|
this.setConnectionState('connecting');
|
|
636
852
|
break;
|
|
@@ -642,6 +858,10 @@ export class SyncEngine {
|
|
|
642
858
|
});
|
|
643
859
|
}
|
|
644
860
|
stopRealtime() {
|
|
861
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
862
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
863
|
+
this.realtimeCatchupTimeoutId = null;
|
|
864
|
+
}
|
|
645
865
|
if (this.realtimePresenceUnsub) {
|
|
646
866
|
this.realtimePresenceUnsub();
|
|
647
867
|
this.realtimePresenceUnsub = null;
|
|
@@ -652,13 +872,26 @@ export class SyncEngine {
|
|
|
652
872
|
}
|
|
653
873
|
this.stopFallbackPolling();
|
|
654
874
|
}
|
|
875
|
+
scheduleRealtimeReconnectCatchupSync() {
|
|
876
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
877
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
878
|
+
}
|
|
879
|
+
this.realtimeCatchupTimeoutId = setTimeout(() => {
|
|
880
|
+
this.realtimeCatchupTimeoutId = null;
|
|
881
|
+
if (this.isDestroyed || !this.isEnabled())
|
|
882
|
+
return;
|
|
883
|
+
if (this.state.connectionState !== 'connected')
|
|
884
|
+
return;
|
|
885
|
+
this.triggerSyncInBackground(undefined, 'realtime reconnect catchup');
|
|
886
|
+
}, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
|
|
887
|
+
}
|
|
655
888
|
startFallbackPolling() {
|
|
656
889
|
if (this.fallbackPollerId)
|
|
657
890
|
return;
|
|
658
891
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
659
892
|
this.fallbackPollerId = setInterval(() => {
|
|
660
893
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
661
|
-
this.
|
|
894
|
+
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
662
895
|
}
|
|
663
896
|
}, interval);
|
|
664
897
|
}
|
|
@@ -668,6 +901,23 @@ export class SyncEngine {
|
|
|
668
901
|
this.fallbackPollerId = null;
|
|
669
902
|
}
|
|
670
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|
|
906
|
+
* Call this after deleting local data (e.g. reset flow) so that React hooks
|
|
907
|
+
* recompute fingerprints from scratch instead of seeing stale timestamps.
|
|
908
|
+
*/
|
|
909
|
+
resetLocalState() {
|
|
910
|
+
const tables = [...this.tableMutationTimestamps.keys()];
|
|
911
|
+
this.mutationTimestamps.clear();
|
|
912
|
+
this.tableMutationTimestamps.clear();
|
|
913
|
+
if (tables.length > 0) {
|
|
914
|
+
this.emit('data:change', {
|
|
915
|
+
scopes: tables,
|
|
916
|
+
timestamp: Date.now(),
|
|
917
|
+
});
|
|
918
|
+
this.config.onDataChange?.(tables);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
671
921
|
/**
|
|
672
922
|
* Reconnect
|
|
673
923
|
*/
|
|
@@ -688,10 +938,7 @@ export class SyncEngine {
|
|
|
688
938
|
// Polling mode: restart the poller and trigger a sync immediately.
|
|
689
939
|
if (this.state.transportMode === 'polling') {
|
|
690
940
|
this.setupPolling();
|
|
691
|
-
|
|
692
|
-
this.sync().catch((err) => {
|
|
693
|
-
console.error('Unexpected error during reconnect sync:', err);
|
|
694
|
-
});
|
|
941
|
+
this.triggerSyncInBackground(undefined, 'reconnect');
|
|
695
942
|
}
|
|
696
943
|
}
|
|
697
944
|
/**
|
|
@@ -825,7 +1072,7 @@ export class SyncEngine {
|
|
|
825
1072
|
updateSubscriptions(subscriptions) {
|
|
826
1073
|
this.config.subscriptions = subscriptions;
|
|
827
1074
|
// Trigger a sync to apply new subscriptions
|
|
828
|
-
this.
|
|
1075
|
+
this.triggerSyncInBackground(undefined, 'subscription update');
|
|
829
1076
|
}
|
|
830
1077
|
/**
|
|
831
1078
|
* Apply local mutations immediately to the database and emit change events.
|
|
@@ -833,12 +1080,12 @@ export class SyncEngine {
|
|
|
833
1080
|
*/
|
|
834
1081
|
async applyLocalMutation(inputs) {
|
|
835
1082
|
const db = this.config.db;
|
|
836
|
-
const
|
|
1083
|
+
const handlers = this.config.handlers;
|
|
837
1084
|
const affectedTables = new Set();
|
|
838
1085
|
const now = Date.now();
|
|
839
1086
|
await db.transaction().execute(async (trx) => {
|
|
840
1087
|
for (const input of inputs) {
|
|
841
|
-
const handler =
|
|
1088
|
+
const handler = handlers.get(input.table);
|
|
842
1089
|
if (!handler)
|
|
843
1090
|
continue;
|
|
844
1091
|
affectedTables.add(input.table);
|