@syncular/client 0.0.6-136 → 0.0.6-139
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/dist/client.d.ts +8 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -20
- package/dist/client.js.map +1 -1
- package/dist/create-client.d.ts +5 -4
- package/dist/create-client.d.ts.map +1 -1
- package/dist/create-client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +8 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +154 -19
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +23 -5
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/handlers/types.d.ts +9 -0
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +9 -1
- package/dist/pull-engine.js.map +1 -1
- package/dist/push-engine.d.ts +2 -0
- package/dist/push-engine.d.ts.map +1 -1
- package/dist/push-engine.js +52 -3
- package/dist/push-engine.js.map +1 -1
- package/dist/sync-loop.d.ts +2 -0
- package/dist/sync-loop.d.ts.map +1 -1
- package/dist/sync-loop.js +48 -2
- package/dist/sync-loop.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +43 -6
- package/src/client.ts +15 -27
- package/src/create-client.ts +5 -4
- package/src/engine/SyncEngine.test.ts +103 -4
- package/src/engine/SyncEngine.ts +207 -21
- package/src/engine/types.ts +26 -4
- package/src/handlers/types.ts +9 -0
- package/src/pull-engine.test.ts +94 -0
- package/src/pull-engine.ts +12 -1
- package/src/push-engine.ts +67 -3
- package/src/sync-loop.ts +70 -2
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
ConflictInfo,
|
|
38
38
|
OutboxStats,
|
|
39
39
|
PresenceEntry,
|
|
40
|
+
PushResultInfo,
|
|
40
41
|
RealtimeTransportLike,
|
|
41
42
|
SubscriptionProgress,
|
|
42
43
|
SyncAwaitBootstrapOptions,
|
|
@@ -70,6 +71,7 @@ const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
|
70
71
|
const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
|
|
71
72
|
const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
|
|
72
73
|
const MAX_INSPECTOR_EVENT_LIMIT = 500;
|
|
74
|
+
const DEFAULT_DATA_CHANGE_DEBOUNCE_MS = 10;
|
|
73
75
|
|
|
74
76
|
function calculateRetryDelay(attemptIndex: number): number {
|
|
75
77
|
return Math.min(
|
|
@@ -303,6 +305,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
303
305
|
private dataChangeDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
|
|
304
306
|
null;
|
|
305
307
|
private pendingDataChangeScopes = new Set<string>();
|
|
308
|
+
private batchDataChangeUntilReconnectSettles = false;
|
|
306
309
|
private hasRealtimeConnectedOnce = false;
|
|
307
310
|
private transportHealth: TransportHealth = {
|
|
308
311
|
mode: 'disconnected',
|
|
@@ -313,6 +316,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
313
316
|
};
|
|
314
317
|
private activeBootstrapSubscriptions = new Set<string>();
|
|
315
318
|
private bootstrapStartedAt = new Map<string, number>();
|
|
319
|
+
private emittedConflictIds = new Set<string>();
|
|
316
320
|
private inspectorEvents: SyncInspectorEvent[] = [];
|
|
317
321
|
private nextInspectorEventId = 1;
|
|
318
322
|
|
|
@@ -809,19 +813,41 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
809
813
|
this.emit('state:change', {});
|
|
810
814
|
}
|
|
811
815
|
|
|
812
|
-
private
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
816
|
+
private normalizeDataChangeDebounceMs(
|
|
817
|
+
value: number | false | undefined
|
|
818
|
+
): number | false | undefined {
|
|
819
|
+
if (value === undefined) return undefined;
|
|
820
|
+
if (value === false) return false;
|
|
821
|
+
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private resolveReconnectDataChangeDebounceMs(): number | false {
|
|
825
|
+
const reconnectDebounce = this.normalizeDataChangeDebounceMs(
|
|
826
|
+
this.config.dataChangeDebounceMsWhenReconnecting
|
|
827
|
+
);
|
|
828
|
+
if (reconnectDebounce !== undefined) {
|
|
829
|
+
return reconnectDebounce;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const syncingDebounce = this.normalizeDataChangeDebounceMs(
|
|
833
|
+
this.config.dataChangeDebounceMsWhenSyncing
|
|
834
|
+
);
|
|
835
|
+
if (syncingDebounce !== undefined) {
|
|
836
|
+
return syncingDebounce;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return (
|
|
840
|
+
this.normalizeDataChangeDebounceMs(this.config.dataChangeDebounceMs) ??
|
|
841
|
+
DEFAULT_DATA_CHANGE_DEBOUNCE_MS
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private resolveDataChangeDebounceMs(): number | false {
|
|
846
|
+
const normalize = (value: number | false | undefined) =>
|
|
847
|
+
this.normalizeDataChangeDebounceMs(value);
|
|
817
848
|
|
|
818
849
|
if (this.state.connectionState === 'reconnecting') {
|
|
819
|
-
|
|
820
|
-
this.config.dataChangeDebounceMsWhenReconnecting
|
|
821
|
-
);
|
|
822
|
-
if (reconnectDebounce !== undefined) {
|
|
823
|
-
return reconnectDebounce;
|
|
824
|
-
}
|
|
850
|
+
return this.resolveReconnectDataChangeDebounceMs();
|
|
825
851
|
}
|
|
826
852
|
|
|
827
853
|
if (this.state.isSyncing) {
|
|
@@ -833,7 +859,24 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
833
859
|
}
|
|
834
860
|
}
|
|
835
861
|
|
|
836
|
-
return
|
|
862
|
+
return (
|
|
863
|
+
normalize(this.config.dataChangeDebounceMs) ??
|
|
864
|
+
DEFAULT_DATA_CHANGE_DEBOUNCE_MS
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private flushReconnectBatchedDataChangesIfReady(): void {
|
|
869
|
+
if (!this.batchDataChangeUntilReconnectSettles) return;
|
|
870
|
+
if (this.state.isSyncing || this.state.connectionState === 'reconnecting') {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
this.batchDataChangeUntilReconnectSettles = false;
|
|
875
|
+
if (this.dataChangeDebounceTimeoutId) {
|
|
876
|
+
clearTimeout(this.dataChangeDebounceTimeoutId);
|
|
877
|
+
this.dataChangeDebounceTimeoutId = null;
|
|
878
|
+
}
|
|
879
|
+
this.flushDataChange();
|
|
837
880
|
}
|
|
838
881
|
|
|
839
882
|
private emitDataChange(scopes: Iterable<string>): void {
|
|
@@ -847,7 +890,19 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
847
890
|
if (normalizedScopes.size === 0) return;
|
|
848
891
|
|
|
849
892
|
const debounceMs = this.resolveDataChangeDebounceMs();
|
|
850
|
-
|
|
893
|
+
const shouldBatchWithoutTimer =
|
|
894
|
+
(this.batchDataChangeUntilReconnectSettles ||
|
|
895
|
+
this.state.connectionState === 'reconnecting') &&
|
|
896
|
+
debounceMs !== false &&
|
|
897
|
+
debounceMs > 0;
|
|
898
|
+
if (shouldBatchWithoutTimer) {
|
|
899
|
+
for (const scope of normalizedScopes) {
|
|
900
|
+
this.pendingDataChangeScopes.add(scope);
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (debounceMs === false || debounceMs <= 0) {
|
|
851
906
|
this.flushDataChange(normalizedScopes);
|
|
852
907
|
return;
|
|
853
908
|
}
|
|
@@ -1133,6 +1188,49 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1133
1188
|
}
|
|
1134
1189
|
}
|
|
1135
1190
|
|
|
1191
|
+
private emitPushResult(result: PushResultInfo): void {
|
|
1192
|
+
this.emit('push:result', result);
|
|
1193
|
+
this.config.onPushResult?.(result);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private async emitNewConflicts(): Promise<void> {
|
|
1197
|
+
const conflicts = await this.getConflicts();
|
|
1198
|
+
const activeIds = new Set(conflicts.map((conflict) => conflict.id));
|
|
1199
|
+
|
|
1200
|
+
for (const id of this.emittedConflictIds) {
|
|
1201
|
+
if (!activeIds.has(id)) {
|
|
1202
|
+
this.emittedConflictIds.delete(id);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const sorted = [...conflicts].sort((left, right) => {
|
|
1207
|
+
if (left.createdAt !== right.createdAt) {
|
|
1208
|
+
return left.createdAt - right.createdAt;
|
|
1209
|
+
}
|
|
1210
|
+
return left.opIndex - right.opIndex;
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
for (const conflict of sorted) {
|
|
1214
|
+
if (this.emittedConflictIds.has(conflict.id)) {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
this.emittedConflictIds.add(conflict.id);
|
|
1218
|
+
this.emit('conflict:new', conflict);
|
|
1219
|
+
this.config.onConflict?.(conflict);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
private async emitNewConflictsSafe(context: string): Promise<void> {
|
|
1224
|
+
try {
|
|
1225
|
+
await this.emitNewConflicts();
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
console.warn(
|
|
1228
|
+
`[SyncEngine] Failed to emit conflict:new during ${context}`,
|
|
1229
|
+
error
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1136
1234
|
private async resolveResetTargets(
|
|
1137
1235
|
options: SyncResetOptions
|
|
1138
1236
|
): Promise<SubscriptionState[]> {
|
|
@@ -1279,6 +1377,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1279
1377
|
|
|
1280
1378
|
this.resetLocalState();
|
|
1281
1379
|
await this.refreshOutboxStats();
|
|
1380
|
+
if (result.deletedConflicts > 0) {
|
|
1381
|
+
this.emittedConflictIds.clear();
|
|
1382
|
+
}
|
|
1282
1383
|
this.updateState({ error: null });
|
|
1283
1384
|
|
|
1284
1385
|
return result;
|
|
@@ -1399,9 +1500,41 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1399
1500
|
|
|
1400
1501
|
private setConnectionState(state: SyncConnectionState): void {
|
|
1401
1502
|
const previous = this.state.connectionState;
|
|
1402
|
-
if (previous
|
|
1403
|
-
|
|
1404
|
-
|
|
1503
|
+
if (previous === state) return;
|
|
1504
|
+
|
|
1505
|
+
const reconnectDebounceMs = this.resolveReconnectDataChangeDebounceMs();
|
|
1506
|
+
if (
|
|
1507
|
+
state === 'reconnecting' &&
|
|
1508
|
+
reconnectDebounceMs !== false &&
|
|
1509
|
+
reconnectDebounceMs > 0
|
|
1510
|
+
) {
|
|
1511
|
+
this.batchDataChangeUntilReconnectSettles = true;
|
|
1512
|
+
if (this.dataChangeDebounceTimeoutId) {
|
|
1513
|
+
clearTimeout(this.dataChangeDebounceTimeoutId);
|
|
1514
|
+
this.dataChangeDebounceTimeoutId = null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
this.updateState({ connectionState: state });
|
|
1519
|
+
this.emit('connection:change', { previous, current: state });
|
|
1520
|
+
|
|
1521
|
+
if (previous === 'reconnecting' && state === 'connected') {
|
|
1522
|
+
queueMicrotask(() => {
|
|
1523
|
+
this.flushReconnectBatchedDataChangesIfReady();
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (previous === 'reconnecting' && state !== 'connected') {
|
|
1528
|
+
this.batchDataChangeUntilReconnectSettles = false;
|
|
1529
|
+
if (this.dataChangeDebounceTimeoutId) {
|
|
1530
|
+
clearTimeout(this.dataChangeDebounceTimeoutId);
|
|
1531
|
+
this.dataChangeDebounceTimeoutId = null;
|
|
1532
|
+
}
|
|
1533
|
+
this.flushDataChange();
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (state === 'disconnected') {
|
|
1537
|
+
this.batchDataChangeUntilReconnectSettles = false;
|
|
1405
1538
|
}
|
|
1406
1539
|
}
|
|
1407
1540
|
|
|
@@ -1446,6 +1579,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1446
1579
|
}
|
|
1447
1580
|
);
|
|
1448
1581
|
pushed = result.pushed;
|
|
1582
|
+
if (result.pushResult) {
|
|
1583
|
+
this.emitPushResult(result.pushResult);
|
|
1584
|
+
}
|
|
1449
1585
|
}
|
|
1450
1586
|
}
|
|
1451
1587
|
} catch {
|
|
@@ -1636,6 +1772,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1636
1772
|
)
|
|
1637
1773
|
);
|
|
1638
1774
|
|
|
1775
|
+
for (const pushResult of result.pushResults) {
|
|
1776
|
+
this.emitPushResult(pushResult);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1639
1779
|
const syncResult: SyncResult = {
|
|
1640
1780
|
success: true,
|
|
1641
1781
|
pushedCommits: result.pushedCommits,
|
|
@@ -1674,6 +1814,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1674
1814
|
this.emitDataChange(changedTables);
|
|
1675
1815
|
}
|
|
1676
1816
|
this.handleBootstrapLifecycle(result.pullResponse);
|
|
1817
|
+
await this.emitNewConflictsSafe('sync success');
|
|
1677
1818
|
|
|
1678
1819
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
1679
1820
|
this.refreshOutboxStats().catch((error) => {
|
|
@@ -1711,6 +1852,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1711
1852
|
attributes: { trigger: triggerLabel },
|
|
1712
1853
|
}
|
|
1713
1854
|
);
|
|
1855
|
+
this.flushReconnectBatchedDataChangesIfReady();
|
|
1714
1856
|
|
|
1715
1857
|
return syncResult;
|
|
1716
1858
|
} catch (err) {
|
|
@@ -1732,6 +1874,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1732
1874
|
});
|
|
1733
1875
|
|
|
1734
1876
|
this.handleError(error);
|
|
1877
|
+
await this.emitNewConflictsSafe('sync error');
|
|
1735
1878
|
|
|
1736
1879
|
const durationMs = Math.max(0, Date.now() - startedAtMs);
|
|
1737
1880
|
countSyncMetric('sync.client.sync.results', 1, {
|
|
@@ -1757,6 +1900,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1757
1900
|
if (error.retryable && this.state.retryCount < maxRetries) {
|
|
1758
1901
|
this.scheduleRetry();
|
|
1759
1902
|
}
|
|
1903
|
+
this.flushReconnectBatchedDataChangesIfReady();
|
|
1760
1904
|
|
|
1761
1905
|
return {
|
|
1762
1906
|
success: false,
|
|
@@ -1797,9 +1941,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1797
1941
|
*/
|
|
1798
1942
|
private async applyWsDeliveredChanges(
|
|
1799
1943
|
changes: SyncChange[],
|
|
1800
|
-
cursor: number
|
|
1944
|
+
cursor: number,
|
|
1945
|
+
metadata?: {
|
|
1946
|
+
commitSeq?: number;
|
|
1947
|
+
actorId?: string | null;
|
|
1948
|
+
createdAt?: string | null;
|
|
1949
|
+
}
|
|
1801
1950
|
): Promise<boolean> {
|
|
1802
1951
|
try {
|
|
1952
|
+
const commitSeq = metadata?.commitSeq ?? cursor;
|
|
1953
|
+
const actorId = metadata?.actorId ?? null;
|
|
1954
|
+
const createdAt = metadata?.createdAt ?? null;
|
|
1803
1955
|
await this.config.db.transaction().execute(async (trx) => {
|
|
1804
1956
|
for (const change of changes) {
|
|
1805
1957
|
const handler = getClientHandler(this.config.handlers, change.table);
|
|
@@ -1808,7 +1960,15 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1808
1960
|
`Missing client table handler for WS change table "${change.table}"`
|
|
1809
1961
|
);
|
|
1810
1962
|
}
|
|
1811
|
-
await handler.applyChange(
|
|
1963
|
+
await handler.applyChange(
|
|
1964
|
+
{
|
|
1965
|
+
trx,
|
|
1966
|
+
commitSeq,
|
|
1967
|
+
actorId,
|
|
1968
|
+
createdAt,
|
|
1969
|
+
},
|
|
1970
|
+
change
|
|
1971
|
+
);
|
|
1812
1972
|
}
|
|
1813
1973
|
|
|
1814
1974
|
// Update subscription cursors
|
|
@@ -1851,7 +2011,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1851
2011
|
*/
|
|
1852
2012
|
private async handleWsDelivery(
|
|
1853
2013
|
changes: SyncChange[],
|
|
1854
|
-
cursor: number
|
|
2014
|
+
cursor: number,
|
|
2015
|
+
metadata?: {
|
|
2016
|
+
commitSeq?: number;
|
|
2017
|
+
actorId?: string | null;
|
|
2018
|
+
createdAt?: string | null;
|
|
2019
|
+
}
|
|
1855
2020
|
): Promise<void> {
|
|
1856
2021
|
// If a sync is already in-flight, let it handle everything
|
|
1857
2022
|
if (this.syncPromise) {
|
|
@@ -1895,7 +2060,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1895
2060
|
|
|
1896
2061
|
// Apply changes + update cursor
|
|
1897
2062
|
const inlineApplyStartedAtMs = Date.now();
|
|
1898
|
-
const applied = await this.applyWsDeliveredChanges(
|
|
2063
|
+
const applied = await this.applyWsDeliveredChanges(
|
|
2064
|
+
changes,
|
|
2065
|
+
cursor,
|
|
2066
|
+
metadata
|
|
2067
|
+
);
|
|
1899
2068
|
const inlineApplyDurationMs = Math.max(
|
|
1900
2069
|
0,
|
|
1901
2070
|
Date.now() - inlineApplyStartedAtMs
|
|
@@ -2161,10 +2330,27 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
2161
2330
|
const hasInlineChanges =
|
|
2162
2331
|
Array.isArray(event.data.changes) && event.data.changes.length > 0;
|
|
2163
2332
|
const cursor = event.data.cursor;
|
|
2333
|
+
const commitSeqRaw = event.data.commitSeq;
|
|
2334
|
+
const commitSeq =
|
|
2335
|
+
typeof commitSeqRaw === 'number'
|
|
2336
|
+
? commitSeqRaw
|
|
2337
|
+
: typeof cursor === 'number'
|
|
2338
|
+
? cursor
|
|
2339
|
+
: undefined;
|
|
2340
|
+
const actorId =
|
|
2341
|
+
typeof event.data.actorId === 'string' ? event.data.actorId : null;
|
|
2342
|
+
const createdAt =
|
|
2343
|
+
typeof event.data.createdAt === 'string'
|
|
2344
|
+
? event.data.createdAt
|
|
2345
|
+
: null;
|
|
2164
2346
|
|
|
2165
2347
|
if (hasInlineChanges && typeof cursor === 'number') {
|
|
2166
2348
|
// WS delivered changes + cursor — may skip HTTP pull
|
|
2167
|
-
this.handleWsDelivery(event.data.changes as SyncChange[], cursor
|
|
2349
|
+
this.handleWsDelivery(event.data.changes as SyncChange[], cursor, {
|
|
2350
|
+
commitSeq,
|
|
2351
|
+
actorId,
|
|
2352
|
+
createdAt,
|
|
2353
|
+
});
|
|
2168
2354
|
} else {
|
|
2169
2355
|
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
2170
2356
|
countSyncMetric('sync.client.ws.delivery.events', 1, {
|
package/src/engine/types.ts
CHANGED
|
@@ -140,14 +140,28 @@ export type SyncEventType =
|
|
|
140
140
|
| 'sync:complete'
|
|
141
141
|
| 'sync:live'
|
|
142
142
|
| 'sync:error'
|
|
143
|
+
| 'push:result'
|
|
143
144
|
| 'bootstrap:start'
|
|
144
145
|
| 'bootstrap:progress'
|
|
145
146
|
| 'bootstrap:complete'
|
|
146
147
|
| 'connection:change'
|
|
147
148
|
| 'outbox:change'
|
|
148
149
|
| 'data:change'
|
|
150
|
+
| 'conflict:new'
|
|
149
151
|
| 'presence:change';
|
|
150
152
|
|
|
153
|
+
export type PushResultStatus = 'applied' | 'cached' | 'rejected' | 'retriable';
|
|
154
|
+
|
|
155
|
+
export interface PushResultInfo {
|
|
156
|
+
outboxCommitId: string;
|
|
157
|
+
clientCommitId: string;
|
|
158
|
+
status: PushResultStatus;
|
|
159
|
+
commitSeq: number | null;
|
|
160
|
+
results: SyncPushResponse['results'];
|
|
161
|
+
errorCode: string | null;
|
|
162
|
+
timestamp: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* Presence entry for a client connected to a scope
|
|
153
167
|
*/
|
|
@@ -172,6 +186,7 @@ export interface SyncEventPayloads {
|
|
|
172
186
|
};
|
|
173
187
|
'sync:live': { timestamp: number };
|
|
174
188
|
'sync:error': SyncError;
|
|
189
|
+
'push:result': PushResultInfo;
|
|
175
190
|
'bootstrap:start': {
|
|
176
191
|
timestamp: number;
|
|
177
192
|
stateId: string;
|
|
@@ -203,6 +218,7 @@ export interface SyncEventPayloads {
|
|
|
203
218
|
scopes: string[];
|
|
204
219
|
timestamp: number;
|
|
205
220
|
};
|
|
221
|
+
'conflict:new': ConflictInfo;
|
|
206
222
|
'presence:change': {
|
|
207
223
|
scopeKey: string;
|
|
208
224
|
presence: PresenceEntry[];
|
|
@@ -260,25 +276,28 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
|
|
|
260
276
|
onError?: (error: SyncError) => void;
|
|
261
277
|
/** Conflict callback */
|
|
262
278
|
onConflict?: (conflict: ConflictInfo) => void;
|
|
279
|
+
/** Per-commit push outcome callback */
|
|
280
|
+
onPushResult?: (result: PushResultInfo) => void;
|
|
263
281
|
/** Data change callback */
|
|
264
282
|
onDataChange?: (scopes: string[]) => void;
|
|
265
283
|
/**
|
|
266
284
|
* Debounce window for coalescing `data:change` emissions.
|
|
267
|
-
* -
|
|
285
|
+
* - default: `10`
|
|
286
|
+
* - `0`/`false`: emit immediately (disable debounce)
|
|
268
287
|
* - `>0`: merge scopes and emit once per window
|
|
269
288
|
*/
|
|
270
|
-
dataChangeDebounceMs?: number;
|
|
289
|
+
dataChangeDebounceMs?: number | false;
|
|
271
290
|
/**
|
|
272
291
|
* Override debounce window while `isSyncing === true`.
|
|
273
292
|
* If omitted, `dataChangeDebounceMs` is used.
|
|
274
293
|
*/
|
|
275
|
-
dataChangeDebounceMsWhenSyncing?: number;
|
|
294
|
+
dataChangeDebounceMsWhenSyncing?: number | false;
|
|
276
295
|
/**
|
|
277
296
|
* Override debounce window while `connectionState === "reconnecting"`.
|
|
278
297
|
* If omitted, `dataChangeDebounceMsWhenSyncing` (if syncing) or
|
|
279
298
|
* `dataChangeDebounceMs` is used.
|
|
280
299
|
*/
|
|
281
|
-
dataChangeDebounceMsWhenReconnecting?: number;
|
|
300
|
+
dataChangeDebounceMsWhenReconnecting?: number | false;
|
|
282
301
|
/** Optional client plugins (e.g. encryption) */
|
|
283
302
|
plugins?: SyncClientPlugin[];
|
|
284
303
|
/** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
|
|
@@ -317,8 +336,11 @@ export interface RealtimeTransportLike extends SyncTransport {
|
|
|
317
336
|
event: string;
|
|
318
337
|
data: {
|
|
319
338
|
cursor?: number;
|
|
339
|
+
commitSeq?: number;
|
|
320
340
|
changes?: unknown[];
|
|
321
341
|
error?: string;
|
|
342
|
+
actorId?: string;
|
|
343
|
+
createdAt?: string;
|
|
322
344
|
timestamp: number;
|
|
323
345
|
};
|
|
324
346
|
}) => void,
|
package/src/handlers/types.ts
CHANGED
|
@@ -16,6 +16,15 @@ import type { Transaction } from 'kysely';
|
|
|
16
16
|
export interface ClientHandlerContext<DB> {
|
|
17
17
|
/** Database transaction */
|
|
18
18
|
trx: Transaction<DB>;
|
|
19
|
+
/**
|
|
20
|
+
* Commit metadata for server-delivered changes.
|
|
21
|
+
* Undefined for local optimistic changes.
|
|
22
|
+
*/
|
|
23
|
+
commitSeq?: number | null;
|
|
24
|
+
/** Actor that authored the server commit, when available. */
|
|
25
|
+
actorId?: string | null;
|
|
26
|
+
/** Commit creation timestamp (ISO string), when available. */
|
|
27
|
+
createdAt?: string | null;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
package/src/pull-engine.test.ts
CHANGED
|
@@ -818,4 +818,98 @@ describe('applyPullResponse chunk streaming', () => {
|
|
|
818
818
|
.executeTakeFirst();
|
|
819
819
|
expect(Number(state?.cursor ?? -1)).toBe(2);
|
|
820
820
|
});
|
|
821
|
+
|
|
822
|
+
it('passes commit metadata to applyChange handler context', async () => {
|
|
823
|
+
const transport: SyncTransport = {
|
|
824
|
+
async sync() {
|
|
825
|
+
return {};
|
|
826
|
+
},
|
|
827
|
+
async fetchSnapshotChunk() {
|
|
828
|
+
return new Uint8Array();
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const appliedContexts: Array<{
|
|
833
|
+
commitSeq: number | null | undefined;
|
|
834
|
+
actorId: string | null | undefined;
|
|
835
|
+
createdAt: string | null | undefined;
|
|
836
|
+
}> = [];
|
|
837
|
+
|
|
838
|
+
const handlers: ClientHandlerCollection<TestDb> = [
|
|
839
|
+
{
|
|
840
|
+
table: 'items',
|
|
841
|
+
async applySnapshot() {},
|
|
842
|
+
async clearAll() {},
|
|
843
|
+
async applyChange(ctx) {
|
|
844
|
+
appliedContexts.push({
|
|
845
|
+
commitSeq: ctx.commitSeq,
|
|
846
|
+
actorId: ctx.actorId,
|
|
847
|
+
createdAt: ctx.createdAt,
|
|
848
|
+
});
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
];
|
|
852
|
+
|
|
853
|
+
const options = {
|
|
854
|
+
clientId: 'client-1',
|
|
855
|
+
subscriptions: [
|
|
856
|
+
{
|
|
857
|
+
id: 'items-sub',
|
|
858
|
+
table: 'items',
|
|
859
|
+
scopes: {},
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
stateId: 'default',
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const pullState = await buildPullRequest(db, options);
|
|
866
|
+
const response: SyncPullResponse = {
|
|
867
|
+
ok: true,
|
|
868
|
+
subscriptions: [
|
|
869
|
+
{
|
|
870
|
+
id: 'items-sub',
|
|
871
|
+
status: 'active',
|
|
872
|
+
scopes: {},
|
|
873
|
+
bootstrap: false,
|
|
874
|
+
bootstrapState: null,
|
|
875
|
+
nextCursor: 7,
|
|
876
|
+
commits: [
|
|
877
|
+
{
|
|
878
|
+
commitSeq: 7,
|
|
879
|
+
actorId: 'remote-user',
|
|
880
|
+
createdAt: '2026-02-28T12:00:00.000Z',
|
|
881
|
+
changes: [
|
|
882
|
+
{
|
|
883
|
+
table: 'items',
|
|
884
|
+
row_id: 'item-ctx',
|
|
885
|
+
op: 'upsert',
|
|
886
|
+
row_version: 1,
|
|
887
|
+
row_json: { id: 'item-ctx', name: 'ctx-test' },
|
|
888
|
+
scopes: {},
|
|
889
|
+
},
|
|
890
|
+
],
|
|
891
|
+
},
|
|
892
|
+
],
|
|
893
|
+
snapshots: [],
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
await applyPullResponse(
|
|
899
|
+
db,
|
|
900
|
+
transport,
|
|
901
|
+
handlers,
|
|
902
|
+
options,
|
|
903
|
+
pullState,
|
|
904
|
+
response
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
expect(appliedContexts).toEqual([
|
|
908
|
+
{
|
|
909
|
+
commitSeq: 7,
|
|
910
|
+
actorId: 'remote-user',
|
|
911
|
+
createdAt: '2026-02-28T12:00:00.000Z',
|
|
912
|
+
},
|
|
913
|
+
]);
|
|
914
|
+
});
|
|
821
915
|
});
|
package/src/pull-engine.ts
CHANGED
|
@@ -888,9 +888,20 @@ export async function applyPullResponse<DB extends SyncClientDb>(
|
|
|
888
888
|
} else {
|
|
889
889
|
// Apply incremental changes
|
|
890
890
|
for (const commit of sub.commits) {
|
|
891
|
+
const commitSeq = commit.commitSeq ?? null;
|
|
892
|
+
const actorId = commit.actorId ?? null;
|
|
893
|
+
const createdAt = commit.createdAt ?? null;
|
|
891
894
|
for (const change of commit.changes) {
|
|
892
895
|
const handler = getClientHandlerOrThrow(handlers, change.table);
|
|
893
|
-
await handler.applyChange(
|
|
896
|
+
await handler.applyChange(
|
|
897
|
+
{
|
|
898
|
+
trx,
|
|
899
|
+
commitSeq,
|
|
900
|
+
actorId,
|
|
901
|
+
createdAt,
|
|
902
|
+
},
|
|
903
|
+
change
|
|
904
|
+
);
|
|
894
905
|
}
|
|
895
906
|
}
|
|
896
907
|
}
|