@syncular/client 0.0.1-60
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/blobs/index.d.ts +7 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +7 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +345 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +749 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +14 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +59 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +62 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/client.d.ts +338 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +834 -0
- package/dist/client.js.map +1 -0
- package/dist/conflicts.d.ts +31 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +118 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/create-client.d.ts +115 -0
- package/dist/create-client.d.ts.map +1 -0
- package/dist/create-client.js +162 -0
- package/dist/create-client.js.map +1 -0
- package/dist/engine/SyncEngine.d.ts +215 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -0
- package/dist/engine/SyncEngine.js +1066 -0
- package/dist/engine/SyncEngine.js.map +1 -0
- package/dist/engine/index.d.ts +6 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +6 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/types.d.ts +230 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +7 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +110 -0
- package/dist/handlers/create-handler.d.ts.map +1 -0
- package/dist/handlers/create-handler.js +140 -0
- package/dist/handlers/create-handler.js.map +1 -0
- package/dist/handlers/registry.d.ts +15 -0
- package/dist/handlers/registry.d.ts.map +1 -0
- package/dist/handlers/registry.js +29 -0
- package/dist/handlers/registry.js.map +1 -0
- package/dist/handlers/types.d.ts +83 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +5 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +19 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +106 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mutations.d.ts +138 -0
- package/dist/mutations.d.ts.map +1 -0
- package/dist/mutations.js +611 -0
- package/dist/mutations.js.map +1 -0
- package/dist/outbox.d.ts +112 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +304 -0
- package/dist/outbox.js.map +1 -0
- package/dist/plugins/incrementing-version.d.ts +34 -0
- package/dist/plugins/incrementing-version.d.ts.map +1 -0
- package/dist/plugins/incrementing-version.js +83 -0
- package/dist/plugins/incrementing-version.js.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/types.d.ts +49 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +15 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/proxy/connection.d.ts +33 -0
- package/dist/proxy/connection.d.ts.map +1 -0
- package/dist/proxy/connection.js +153 -0
- package/dist/proxy/connection.js.map +1 -0
- package/dist/proxy/dialect.d.ts +46 -0
- package/dist/proxy/dialect.d.ts.map +1 -0
- package/dist/proxy/dialect.js +58 -0
- package/dist/proxy/dialect.js.map +1 -0
- package/dist/proxy/driver.d.ts +42 -0
- package/dist/proxy/driver.d.ts.map +1 -0
- package/dist/proxy/driver.js +78 -0
- package/dist/proxy/driver.js.map +1 -0
- package/dist/proxy/index.d.ts +10 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +10 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutations.d.ts +9 -0
- package/dist/proxy/mutations.d.ts.map +1 -0
- package/dist/proxy/mutations.js +11 -0
- package/dist/proxy/mutations.js.map +1 -0
- package/dist/pull-engine.d.ts +45 -0
- package/dist/pull-engine.d.ts.map +1 -0
- package/dist/pull-engine.js +391 -0
- package/dist/pull-engine.js.map +1 -0
- package/dist/push-engine.d.ts +18 -0
- package/dist/push-engine.d.ts.map +1 -0
- package/dist/push-engine.js +155 -0
- package/dist/push-engine.js.map +1 -0
- package/dist/query/FingerprintCollector.d.ts +18 -0
- package/dist/query/FingerprintCollector.d.ts.map +1 -0
- package/dist/query/FingerprintCollector.js +28 -0
- package/dist/query/FingerprintCollector.js.map +1 -0
- package/dist/query/QueryContext.d.ts +33 -0
- package/dist/query/QueryContext.d.ts.map +1 -0
- package/dist/query/QueryContext.js +16 -0
- package/dist/query/QueryContext.js.map +1 -0
- package/dist/query/fingerprint.d.ts +61 -0
- package/dist/query/fingerprint.d.ts.map +1 -0
- package/dist/query/fingerprint.js +91 -0
- package/dist/query/fingerprint.js.map +1 -0
- package/dist/query/index.d.ts +7 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +7 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/tracked-select.d.ts +18 -0
- package/dist/query/tracked-select.d.ts.map +1 -0
- package/dist/query/tracked-select.js +90 -0
- package/dist/query/tracked-select.js.map +1 -0
- package/dist/schema.d.ts +83 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/sync-loop.d.ts +32 -0
- package/dist/sync-loop.d.ts.map +1 -0
- package/dist/sync-loop.js +249 -0
- package/dist/sync-loop.js.map +1 -0
- package/dist/utils/id.d.ts +8 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +19 -0
- package/dist/utils/id.js.map +1 -0
- package/package.json +58 -0
- package/src/blobs/index.ts +7 -0
- package/src/blobs/manager.ts +1027 -0
- package/src/blobs/migrate.ts +67 -0
- package/src/blobs/types.ts +84 -0
- package/src/client.ts +1222 -0
- package/src/conflicts.ts +180 -0
- package/src/create-client.ts +297 -0
- package/src/engine/SyncEngine.ts +1337 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/types.ts +268 -0
- package/src/handlers/create-handler.ts +287 -0
- package/src/handlers/registry.ts +36 -0
- package/src/handlers/types.ts +102 -0
- package/src/index.ts +25 -0
- package/src/migrate.ts +122 -0
- package/src/mutations.ts +926 -0
- package/src/outbox.ts +397 -0
- package/src/plugins/incrementing-version.ts +133 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/types.ts +63 -0
- package/src/proxy/connection.ts +191 -0
- package/src/proxy/dialect.ts +76 -0
- package/src/proxy/driver.ts +126 -0
- package/src/proxy/index.ts +10 -0
- package/src/proxy/mutations.ts +18 -0
- package/src/pull-engine.ts +518 -0
- package/src/push-engine.ts +201 -0
- package/src/query/FingerprintCollector.ts +29 -0
- package/src/query/QueryContext.ts +54 -0
- package/src/query/fingerprint.ts +109 -0
- package/src/query/index.ts +10 -0
- package/src/query/tracked-select.ts +139 -0
- package/src/schema.ts +94 -0
- package/src/sync-loop.ts +368 -0
- package/src/utils/id.ts +20 -0
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @syncular/client - Core sync engine
|
|
3
|
+
*
|
|
4
|
+
* Event-driven sync engine that manages push/pull cycles, connection state,
|
|
5
|
+
* and provides a clean API for framework bindings to consume.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SyncChange,
|
|
10
|
+
SyncPullResponse,
|
|
11
|
+
SyncSubscriptionRequest,
|
|
12
|
+
} from '@syncular/core';
|
|
13
|
+
import { type Kysely, sql } from 'kysely';
|
|
14
|
+
import { syncPushOnce } from '../push-engine';
|
|
15
|
+
import type {
|
|
16
|
+
ConflictResultStatus,
|
|
17
|
+
OutboxCommitStatus,
|
|
18
|
+
SyncClientDb,
|
|
19
|
+
} from '../schema';
|
|
20
|
+
import { syncOnce } from '../sync-loop';
|
|
21
|
+
import type {
|
|
22
|
+
ConflictInfo,
|
|
23
|
+
OutboxStats,
|
|
24
|
+
PresenceEntry,
|
|
25
|
+
RealtimeTransportLike,
|
|
26
|
+
SyncConnectionState,
|
|
27
|
+
SyncEngineConfig,
|
|
28
|
+
SyncEngineState,
|
|
29
|
+
SyncError,
|
|
30
|
+
SyncEventListener,
|
|
31
|
+
SyncEventPayloads,
|
|
32
|
+
SyncEventType,
|
|
33
|
+
SyncResult,
|
|
34
|
+
SyncTransportMode,
|
|
35
|
+
} from './types';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
38
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
39
|
+
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
40
|
+
const MAX_RETRY_DELAY_MS = 60000;
|
|
41
|
+
const EXPONENTIAL_FACTOR = 2;
|
|
42
|
+
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
43
|
+
|
|
44
|
+
function calculateRetryDelay(attemptIndex: number): number {
|
|
45
|
+
return Math.min(
|
|
46
|
+
INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex,
|
|
47
|
+
MAX_RETRY_DELAY_MS
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isRealtimeTransport(
|
|
52
|
+
transport: unknown
|
|
53
|
+
): transport is RealtimeTransportLike {
|
|
54
|
+
return (
|
|
55
|
+
typeof transport === 'object' &&
|
|
56
|
+
transport !== null &&
|
|
57
|
+
typeof (transport as RealtimeTransportLike).connect === 'function'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createSyncError(
|
|
62
|
+
code: SyncError['code'],
|
|
63
|
+
message: string,
|
|
64
|
+
cause?: Error
|
|
65
|
+
): SyncError {
|
|
66
|
+
return {
|
|
67
|
+
code,
|
|
68
|
+
message,
|
|
69
|
+
cause,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
75
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sync engine that orchestrates push/pull cycles with proper lifecycle management.
|
|
80
|
+
*
|
|
81
|
+
* Key features:
|
|
82
|
+
* - Event-driven architecture (no global mutable state)
|
|
83
|
+
* - Proper error handling (no silent catches)
|
|
84
|
+
* - Lifecycle managed by framework bindings
|
|
85
|
+
* - Type-safe event subscriptions
|
|
86
|
+
* - Presence tracking support
|
|
87
|
+
*/
|
|
88
|
+
type AnyEventListener = (payload: SyncEventPayloads[SyncEventType]) => void;
|
|
89
|
+
|
|
90
|
+
export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
91
|
+
private config: SyncEngineConfig<DB>;
|
|
92
|
+
private state: SyncEngineState;
|
|
93
|
+
private listeners: Map<SyncEventType, Set<AnyEventListener>>;
|
|
94
|
+
private pollerId: ReturnType<typeof setInterval> | null = null;
|
|
95
|
+
private fallbackPollerId: ReturnType<typeof setInterval> | null = null;
|
|
96
|
+
private realtimeDisconnect: (() => void) | null = null;
|
|
97
|
+
private realtimePresenceUnsub: (() => void) | null = null;
|
|
98
|
+
private isDestroyed = false;
|
|
99
|
+
private migrated = false;
|
|
100
|
+
private syncPromise: Promise<SyncResult> | null = null;
|
|
101
|
+
private syncRequestedWhileRunning = false;
|
|
102
|
+
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
private hasRealtimeConnectedOnce = false;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* In-memory map tracking local mutation timestamps by rowId.
|
|
108
|
+
* Used for efficient fingerprint-based rerender optimization.
|
|
109
|
+
* Key format: `${table}:${rowId}`, Value: timestamp (Date.now())
|
|
110
|
+
*/
|
|
111
|
+
private mutationTimestamps = new Map<string, number>();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* In-memory map tracking table-level mutation timestamps.
|
|
115
|
+
* Used for coarse invalidation during large bootstrap snapshots to avoid
|
|
116
|
+
* storing timestamps for every row.
|
|
117
|
+
*/
|
|
118
|
+
private tableMutationTimestamps = new Map<string, number>();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* In-memory presence state by scope key.
|
|
122
|
+
* Updated via realtime presence events.
|
|
123
|
+
*/
|
|
124
|
+
private presenceByScopeKey = new Map<string, PresenceEntry[]>();
|
|
125
|
+
|
|
126
|
+
constructor(config: SyncEngineConfig<DB>) {
|
|
127
|
+
this.config = config;
|
|
128
|
+
this.listeners = new Map();
|
|
129
|
+
this.state = this.createInitialState();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get mutation timestamp for a row (used by query hooks for fingerprinting).
|
|
134
|
+
* Returns 0 if row has no recorded mutation timestamp.
|
|
135
|
+
*/
|
|
136
|
+
getMutationTimestamp(table: string, rowId: string): number {
|
|
137
|
+
const rowTs = this.mutationTimestamps.get(`${table}:${rowId}`) ?? 0;
|
|
138
|
+
const tableTs = this.tableMutationTimestamps.get(table) ?? 0;
|
|
139
|
+
return Math.max(rowTs, tableTs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get presence entries for a scope key.
|
|
144
|
+
* Returns empty array if no presence data for the scope.
|
|
145
|
+
*/
|
|
146
|
+
getPresence<TMetadata = Record<string, unknown>>(
|
|
147
|
+
scopeKey: string
|
|
148
|
+
): PresenceEntry<TMetadata>[] {
|
|
149
|
+
return (this.presenceByScopeKey.get(scopeKey) ??
|
|
150
|
+
[]) as PresenceEntry<TMetadata>[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Update presence for a scope key (called by realtime transport).
|
|
155
|
+
* Emits presence:change event for listeners.
|
|
156
|
+
*/
|
|
157
|
+
updatePresence(scopeKey: string, presence: PresenceEntry[]): void {
|
|
158
|
+
this.presenceByScopeKey.set(scopeKey, presence);
|
|
159
|
+
this.emit('presence:change', { scopeKey, presence });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Join presence for a scope key.
|
|
164
|
+
* Sends via transport (if available) and updates local state optimistically.
|
|
165
|
+
*/
|
|
166
|
+
joinPresence(scopeKey: string, metadata?: Record<string, unknown>): void {
|
|
167
|
+
if (isRealtimeTransport(this.config.transport)) {
|
|
168
|
+
const transport = this.config.transport as RealtimeTransportLike;
|
|
169
|
+
transport.sendPresenceJoin?.(scopeKey, metadata);
|
|
170
|
+
}
|
|
171
|
+
// Optimistic local update
|
|
172
|
+
this.handlePresenceEvent({
|
|
173
|
+
action: 'join',
|
|
174
|
+
scopeKey,
|
|
175
|
+
clientId: this.config.clientId!,
|
|
176
|
+
actorId: this.config.actorId!,
|
|
177
|
+
metadata,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Leave presence for a scope key.
|
|
183
|
+
*/
|
|
184
|
+
leavePresence(scopeKey: string): void {
|
|
185
|
+
if (isRealtimeTransport(this.config.transport)) {
|
|
186
|
+
const transport = this.config.transport as RealtimeTransportLike;
|
|
187
|
+
transport.sendPresenceLeave?.(scopeKey);
|
|
188
|
+
}
|
|
189
|
+
this.handlePresenceEvent({
|
|
190
|
+
action: 'leave',
|
|
191
|
+
scopeKey,
|
|
192
|
+
clientId: this.config.clientId!,
|
|
193
|
+
actorId: this.config.actorId!,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Update presence metadata for a scope key.
|
|
199
|
+
*/
|
|
200
|
+
updatePresenceMetadata(
|
|
201
|
+
scopeKey: string,
|
|
202
|
+
metadata: Record<string, unknown>
|
|
203
|
+
): void {
|
|
204
|
+
if (isRealtimeTransport(this.config.transport)) {
|
|
205
|
+
const transport = this.config.transport as RealtimeTransportLike;
|
|
206
|
+
transport.sendPresenceUpdate?.(scopeKey, metadata);
|
|
207
|
+
}
|
|
208
|
+
this.handlePresenceEvent({
|
|
209
|
+
action: 'update',
|
|
210
|
+
scopeKey,
|
|
211
|
+
clientId: this.config.clientId!,
|
|
212
|
+
actorId: this.config.actorId!,
|
|
213
|
+
metadata,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Handle a single presence event (join/leave/update).
|
|
219
|
+
* Updates the in-memory presence state and emits change event.
|
|
220
|
+
*/
|
|
221
|
+
handlePresenceEvent(event: {
|
|
222
|
+
action: 'join' | 'leave' | 'update';
|
|
223
|
+
scopeKey: string;
|
|
224
|
+
clientId: string;
|
|
225
|
+
actorId: string;
|
|
226
|
+
metadata?: Record<string, unknown>;
|
|
227
|
+
}): void {
|
|
228
|
+
const current = this.presenceByScopeKey.get(event.scopeKey) ?? [];
|
|
229
|
+
|
|
230
|
+
let updated: PresenceEntry[];
|
|
231
|
+
switch (event.action) {
|
|
232
|
+
case 'join':
|
|
233
|
+
// Add new entry (remove existing if present to update)
|
|
234
|
+
updated = [
|
|
235
|
+
...current.filter((e) => e.clientId !== event.clientId),
|
|
236
|
+
{
|
|
237
|
+
clientId: event.clientId,
|
|
238
|
+
actorId: event.actorId,
|
|
239
|
+
joinedAt: Date.now(),
|
|
240
|
+
metadata: event.metadata,
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
break;
|
|
244
|
+
case 'leave':
|
|
245
|
+
updated = current.filter((e) => e.clientId !== event.clientId);
|
|
246
|
+
break;
|
|
247
|
+
case 'update':
|
|
248
|
+
updated = current.map((e) =>
|
|
249
|
+
e.clientId === event.clientId ? { ...e, metadata: event.metadata } : e
|
|
250
|
+
);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.presenceByScopeKey.set(event.scopeKey, updated);
|
|
255
|
+
this.emit('presence:change', {
|
|
256
|
+
scopeKey: event.scopeKey,
|
|
257
|
+
presence: updated,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private createInitialState(): SyncEngineState {
|
|
262
|
+
const enabled = this.isEnabled();
|
|
263
|
+
return {
|
|
264
|
+
enabled,
|
|
265
|
+
isSyncing: false,
|
|
266
|
+
connectionState: enabled ? 'disconnected' : 'disconnected',
|
|
267
|
+
transportMode: this.detectTransportMode(),
|
|
268
|
+
lastSyncAt: null,
|
|
269
|
+
error: null,
|
|
270
|
+
pendingCount: 0,
|
|
271
|
+
retryCount: 0,
|
|
272
|
+
isRetrying: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private isEnabled(): boolean {
|
|
277
|
+
const { actorId, clientId } = this.config;
|
|
278
|
+
return (
|
|
279
|
+
typeof actorId === 'string' &&
|
|
280
|
+
actorId.length > 0 &&
|
|
281
|
+
typeof clientId === 'string' &&
|
|
282
|
+
clientId.length > 0
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private detectTransportMode(): SyncTransportMode {
|
|
287
|
+
if (
|
|
288
|
+
this.config.realtimeEnabled !== false &&
|
|
289
|
+
isRealtimeTransport(this.config.transport)
|
|
290
|
+
) {
|
|
291
|
+
return 'realtime';
|
|
292
|
+
}
|
|
293
|
+
return 'polling';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get current engine state.
|
|
298
|
+
* Returns the same object reference to avoid useSyncExternalStore infinite loops.
|
|
299
|
+
*/
|
|
300
|
+
getState(): Readonly<SyncEngineState> {
|
|
301
|
+
return this.state;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get database instance
|
|
306
|
+
*/
|
|
307
|
+
getDb(): Kysely<DB> {
|
|
308
|
+
return this.config.db;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get current actor id (sync scoping).
|
|
313
|
+
*/
|
|
314
|
+
getActorId(): string | null | undefined {
|
|
315
|
+
return this.config.actorId;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get current client id (device/app install id).
|
|
320
|
+
*/
|
|
321
|
+
getClientId(): string | null | undefined {
|
|
322
|
+
return this.config.clientId;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Subscribe to sync events
|
|
327
|
+
*/
|
|
328
|
+
on<T extends SyncEventType>(
|
|
329
|
+
event: T,
|
|
330
|
+
listener: SyncEventListener<T>
|
|
331
|
+
): () => void {
|
|
332
|
+
if (!this.listeners.has(event)) {
|
|
333
|
+
this.listeners.set(event, new Set());
|
|
334
|
+
}
|
|
335
|
+
const wrapped: AnyEventListener = (payload) => {
|
|
336
|
+
listener(payload as SyncEventPayloads[T]);
|
|
337
|
+
};
|
|
338
|
+
this.listeners.get(event)!.add(wrapped);
|
|
339
|
+
|
|
340
|
+
return () => {
|
|
341
|
+
this.listeners.get(event)?.delete(wrapped);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Subscribe to any state change (for useSyncExternalStore)
|
|
347
|
+
*/
|
|
348
|
+
subscribe(callback: () => void): () => void {
|
|
349
|
+
// Subscribe to state:change which is emitted by updateState()
|
|
350
|
+
return this.on('state:change', callback);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private emit<T extends SyncEventType>(
|
|
354
|
+
event: T,
|
|
355
|
+
payload: SyncEventPayloads[T]
|
|
356
|
+
): void {
|
|
357
|
+
const eventListeners = this.listeners.get(event);
|
|
358
|
+
if (eventListeners) {
|
|
359
|
+
for (const listener of eventListeners) {
|
|
360
|
+
try {
|
|
361
|
+
listener(payload);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error(`[SyncEngine] Error in ${event} listener:`, err);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private updateState(partial: Partial<SyncEngineState>): void {
|
|
370
|
+
this.state = { ...this.state, ...partial };
|
|
371
|
+
// Emit state:change to notify useSyncExternalStore subscribers
|
|
372
|
+
this.emit('state:change', {});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private setConnectionState(state: SyncConnectionState): void {
|
|
376
|
+
const previous = this.state.connectionState;
|
|
377
|
+
if (previous !== state) {
|
|
378
|
+
this.updateState({ connectionState: state });
|
|
379
|
+
this.emit('connection:change', { previous, current: state });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Start the sync engine
|
|
385
|
+
*/
|
|
386
|
+
async start(): Promise<void> {
|
|
387
|
+
if (this.isDestroyed) {
|
|
388
|
+
throw new Error('SyncEngine has been destroyed');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!this.isEnabled()) {
|
|
392
|
+
this.updateState({ enabled: false });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.updateState({ enabled: true });
|
|
397
|
+
|
|
398
|
+
// Run migration if provided
|
|
399
|
+
if (this.config.migrate && !this.migrated) {
|
|
400
|
+
// Best-effort: push any pending outbox commits before migration
|
|
401
|
+
// (migration may reset the DB, so we try to save unsynced changes)
|
|
402
|
+
try {
|
|
403
|
+
const hasOutbox = await sql`
|
|
404
|
+
select 1 from ${sql.table('sync_outbox_commits')} limit 1
|
|
405
|
+
`
|
|
406
|
+
.execute(this.config.db)
|
|
407
|
+
.then((r) => r.rows.length > 0)
|
|
408
|
+
.catch(() => false);
|
|
409
|
+
|
|
410
|
+
if (hasOutbox) {
|
|
411
|
+
// Push all pending commits (best effort)
|
|
412
|
+
let pushed = true;
|
|
413
|
+
while (pushed) {
|
|
414
|
+
const result = await syncPushOnce(
|
|
415
|
+
this.config.db,
|
|
416
|
+
this.config.transport,
|
|
417
|
+
{
|
|
418
|
+
clientId: this.config.clientId!,
|
|
419
|
+
actorId: this.config.actorId ?? undefined,
|
|
420
|
+
plugins: this.config.plugins,
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
pushed = result.pushed;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
// Best-effort: if push fails (network down, table missing), continue
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
await this.config.migrate(this.config.db);
|
|
432
|
+
this.migrated = true;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
const migrationError =
|
|
435
|
+
err instanceof Error ? err : new Error(String(err));
|
|
436
|
+
this.config.onMigrationError?.(migrationError);
|
|
437
|
+
const error = createSyncError(
|
|
438
|
+
'SYNC_ERROR',
|
|
439
|
+
'Migration failed',
|
|
440
|
+
migrationError
|
|
441
|
+
);
|
|
442
|
+
this.handleError(error);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Setup transport-specific handling
|
|
448
|
+
if (this.state.transportMode === 'realtime') {
|
|
449
|
+
this.setupRealtime();
|
|
450
|
+
} else {
|
|
451
|
+
this.setupPolling();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Initial sync
|
|
455
|
+
await this.sync();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Stop the sync engine (cleanup without destroy)
|
|
460
|
+
*/
|
|
461
|
+
stop(): void {
|
|
462
|
+
this.stopPolling();
|
|
463
|
+
this.stopRealtime();
|
|
464
|
+
this.setConnectionState('disconnected');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Destroy the engine (cannot be restarted)
|
|
469
|
+
*/
|
|
470
|
+
destroy(): void {
|
|
471
|
+
this.stop();
|
|
472
|
+
this.listeners.clear();
|
|
473
|
+
this.isDestroyed = true;
|
|
474
|
+
|
|
475
|
+
if (this.retryTimeoutId) {
|
|
476
|
+
clearTimeout(this.retryTimeoutId);
|
|
477
|
+
this.retryTimeoutId = null;
|
|
478
|
+
}
|
|
479
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
480
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
481
|
+
this.realtimeCatchupTimeoutId = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Trigger a manual sync
|
|
487
|
+
*/
|
|
488
|
+
async sync(opts?: {
|
|
489
|
+
trigger?: 'ws' | 'local' | 'poll';
|
|
490
|
+
}): Promise<SyncResult> {
|
|
491
|
+
// Dedupe concurrent sync calls
|
|
492
|
+
if (this.syncPromise) {
|
|
493
|
+
// A sync is already in-flight; queue one more run so we don't miss
|
|
494
|
+
// mutations enqueued during the current cycle (important in realtime mode).
|
|
495
|
+
this.syncRequestedWhileRunning = true;
|
|
496
|
+
return this.syncPromise;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (
|
|
500
|
+
!this.isEnabled() ||
|
|
501
|
+
this.isDestroyed ||
|
|
502
|
+
this.state.connectionState === 'disconnected'
|
|
503
|
+
) {
|
|
504
|
+
return {
|
|
505
|
+
success: false,
|
|
506
|
+
pushedCommits: 0,
|
|
507
|
+
pullRounds: 0,
|
|
508
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
509
|
+
error: createSyncError('SYNC_ERROR', 'Sync not enabled'),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this.syncPromise = this.performSyncLoop(opts?.trigger);
|
|
514
|
+
try {
|
|
515
|
+
return await this.syncPromise;
|
|
516
|
+
} finally {
|
|
517
|
+
this.syncPromise = null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async performSyncLoop(
|
|
522
|
+
trigger?: 'ws' | 'local' | 'poll'
|
|
523
|
+
): Promise<SyncResult> {
|
|
524
|
+
let lastResult: SyncResult = {
|
|
525
|
+
success: false,
|
|
526
|
+
pushedCommits: 0,
|
|
527
|
+
pullRounds: 0,
|
|
528
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
529
|
+
error: createSyncError('SYNC_ERROR', 'Sync not started'),
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
do {
|
|
533
|
+
this.syncRequestedWhileRunning = false;
|
|
534
|
+
lastResult = await this.performSyncOnce(trigger);
|
|
535
|
+
// After the first iteration, clear trigger context
|
|
536
|
+
trigger = undefined;
|
|
537
|
+
// If the sync failed, let retry logic handle backoff instead of tight looping.
|
|
538
|
+
if (!lastResult.success) break;
|
|
539
|
+
} while (
|
|
540
|
+
this.syncRequestedWhileRunning &&
|
|
541
|
+
!this.isDestroyed &&
|
|
542
|
+
this.isEnabled()
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
return lastResult;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private async performSyncOnce(
|
|
549
|
+
trigger?: 'ws' | 'local' | 'poll'
|
|
550
|
+
): Promise<SyncResult> {
|
|
551
|
+
const timestamp = Date.now();
|
|
552
|
+
this.updateState({ isSyncing: true });
|
|
553
|
+
this.emit('sync:start', { timestamp });
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const pullApplyTimestamp = Date.now();
|
|
557
|
+
const result = await syncOnce(
|
|
558
|
+
this.config.db,
|
|
559
|
+
this.config.transport,
|
|
560
|
+
this.config.shapes,
|
|
561
|
+
{
|
|
562
|
+
clientId: this.config.clientId!,
|
|
563
|
+
actorId: this.config.actorId ?? undefined,
|
|
564
|
+
plugins: this.config.plugins,
|
|
565
|
+
subscriptions: this.config.subscriptions as SyncSubscriptionRequest[],
|
|
566
|
+
limitCommits: this.config.limitCommits,
|
|
567
|
+
limitSnapshotRows: this.config.limitSnapshotRows,
|
|
568
|
+
maxSnapshotPages: this.config.maxSnapshotPages,
|
|
569
|
+
stateId: this.config.stateId,
|
|
570
|
+
trigger,
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
const syncResult: SyncResult = {
|
|
575
|
+
success: true,
|
|
576
|
+
pushedCommits: result.pushedCommits,
|
|
577
|
+
pullRounds: result.pullRounds,
|
|
578
|
+
pullResponse: result.pullResponse,
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// Update fingerprint mutation timestamps for server-applied changes so wa-sqlite
|
|
582
|
+
// query hooks rerender on remote changes (not just local mutations).
|
|
583
|
+
this.recordMutationTimestampsFromPullResponse(
|
|
584
|
+
result.pullResponse,
|
|
585
|
+
pullApplyTimestamp
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
this.updateState({
|
|
589
|
+
isSyncing: false,
|
|
590
|
+
lastSyncAt: Date.now(),
|
|
591
|
+
error: null,
|
|
592
|
+
retryCount: 0,
|
|
593
|
+
isRetrying: false,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
this.emit('sync:complete', {
|
|
597
|
+
timestamp: Date.now(),
|
|
598
|
+
pushedCommits: result.pushedCommits,
|
|
599
|
+
pullRounds: result.pullRounds,
|
|
600
|
+
pullResponse: result.pullResponse,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Emit data change for any tables that had changes
|
|
604
|
+
const changedTables = this.extractChangedTables(result.pullResponse);
|
|
605
|
+
if (changedTables.length > 0) {
|
|
606
|
+
this.emit('data:change', {
|
|
607
|
+
scopes: changedTables,
|
|
608
|
+
timestamp: Date.now(),
|
|
609
|
+
});
|
|
610
|
+
this.config.onDataChange?.(changedTables);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
614
|
+
this.refreshOutboxStats().catch(() => {});
|
|
615
|
+
|
|
616
|
+
return syncResult;
|
|
617
|
+
} catch (err) {
|
|
618
|
+
const error = createSyncError(
|
|
619
|
+
'SYNC_ERROR',
|
|
620
|
+
err instanceof Error ? err.message : 'Sync failed',
|
|
621
|
+
err instanceof Error ? err : undefined
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
this.updateState({
|
|
625
|
+
isSyncing: false,
|
|
626
|
+
error,
|
|
627
|
+
retryCount: this.state.retryCount + 1,
|
|
628
|
+
isRetrying: false,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
this.handleError(error);
|
|
632
|
+
|
|
633
|
+
// Schedule retry if under max retries
|
|
634
|
+
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
635
|
+
if (this.state.retryCount < maxRetries) {
|
|
636
|
+
this.scheduleRetry();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
success: false,
|
|
641
|
+
pushedCommits: 0,
|
|
642
|
+
pullRounds: 0,
|
|
643
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
644
|
+
error,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private extractChangedTables(response: SyncPullResponse): string[] {
|
|
650
|
+
const tables = new Set<string>();
|
|
651
|
+
for (const sub of response.subscriptions ?? []) {
|
|
652
|
+
// Extract tables from snapshots
|
|
653
|
+
for (const snapshot of sub.snapshots ?? []) {
|
|
654
|
+
if (snapshot.table) {
|
|
655
|
+
tables.add(snapshot.table);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Extract tables from commits
|
|
659
|
+
for (const commit of sub.commits ?? []) {
|
|
660
|
+
for (const change of commit.changes ?? []) {
|
|
661
|
+
if (change.table) {
|
|
662
|
+
tables.add(change.table);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return Array.from(tables);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Apply changes delivered inline over WebSocket for instant UI updates.
|
|
673
|
+
* Returns true if changes were applied and cursor updated successfully,
|
|
674
|
+
* false if anything failed (caller should fall back to HTTP sync).
|
|
675
|
+
*/
|
|
676
|
+
private async applyWsDeliveredChanges(
|
|
677
|
+
changes: SyncChange[],
|
|
678
|
+
cursor: number
|
|
679
|
+
): Promise<boolean> {
|
|
680
|
+
try {
|
|
681
|
+
await this.config.db.transaction().execute(async (trx) => {
|
|
682
|
+
for (const change of changes) {
|
|
683
|
+
try {
|
|
684
|
+
const handler = this.config.shapes.get(change.table);
|
|
685
|
+
if (!handler) continue;
|
|
686
|
+
await handler.applyChange({ trx }, change);
|
|
687
|
+
} catch {
|
|
688
|
+
// Best-effort: individual change failures are fine
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Update subscription cursors
|
|
693
|
+
const stateId = this.config.stateId ?? 'default';
|
|
694
|
+
await sql`
|
|
695
|
+
update ${sql.table('sync_subscription_state')}
|
|
696
|
+
set ${sql.ref('cursor')} = ${sql.val(cursor)}
|
|
697
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
698
|
+
and ${sql.ref('cursor')} < ${sql.val(cursor)}
|
|
699
|
+
`.execute(trx);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Update mutation timestamps BEFORE emitting data:change so that
|
|
703
|
+
// React hooks re-querying the DB see fresh fingerprints immediately.
|
|
704
|
+
const now = Date.now();
|
|
705
|
+
for (const change of changes) {
|
|
706
|
+
if (!change.table || !change.row_id) continue;
|
|
707
|
+
if (change.op === 'delete') {
|
|
708
|
+
this.mutationTimestamps.delete(`${change.table}:${change.row_id}`);
|
|
709
|
+
} else {
|
|
710
|
+
this.bumpMutationTimestamp(change.table, change.row_id, now);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Emit data change for immediate UI update
|
|
715
|
+
const changedTables = [...new Set(changes.map((c) => c.table))];
|
|
716
|
+
if (changedTables.length > 0) {
|
|
717
|
+
this.emit('data:change', {
|
|
718
|
+
scopes: changedTables,
|
|
719
|
+
timestamp: Date.now(),
|
|
720
|
+
});
|
|
721
|
+
this.config.onDataChange?.(changedTables);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return true;
|
|
725
|
+
} catch {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Handle WS-delivered changes: apply them and decide whether to skip HTTP pull.
|
|
732
|
+
* Falls back to full HTTP sync when conditions require it.
|
|
733
|
+
*/
|
|
734
|
+
private async handleWsDelivery(
|
|
735
|
+
changes: SyncChange[],
|
|
736
|
+
cursor: number
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
// If a sync is already in-flight, let it handle everything
|
|
739
|
+
if (this.syncPromise) {
|
|
740
|
+
this.sync({ trigger: 'ws' });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// If there are pending outbox commits, need to push via HTTP
|
|
745
|
+
if (this.state.pendingCount > 0) {
|
|
746
|
+
this.sync({ trigger: 'ws' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// If afterPull plugins exist, inline WS changes may require transforms
|
|
751
|
+
// (e.g. decryption). Fall back to HTTP sync and do not apply inline payload.
|
|
752
|
+
const hasAfterPullPlugins = this.config.plugins?.some(
|
|
753
|
+
(p) => typeof p.afterPull === 'function'
|
|
754
|
+
);
|
|
755
|
+
if (hasAfterPullPlugins) {
|
|
756
|
+
this.sync({ trigger: 'ws' });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Apply changes + update cursor
|
|
761
|
+
const applied = await this.applyWsDeliveredChanges(changes, cursor);
|
|
762
|
+
if (!applied) {
|
|
763
|
+
this.sync({ trigger: 'ws' });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// All clear — skip HTTP pull entirely
|
|
768
|
+
this.updateState({
|
|
769
|
+
lastSyncAt: Date.now(),
|
|
770
|
+
error: null,
|
|
771
|
+
retryCount: 0,
|
|
772
|
+
isRetrying: false,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
this.emit('sync:complete', {
|
|
776
|
+
timestamp: Date.now(),
|
|
777
|
+
pushedCommits: 0,
|
|
778
|
+
pullRounds: 0,
|
|
779
|
+
pullResponse: { ok: true, subscriptions: [] },
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
this.refreshOutboxStats().catch(() => {});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private timestampCounter = 0;
|
|
786
|
+
|
|
787
|
+
private nextPreciseTimestamp(now: number): number {
|
|
788
|
+
// Use sub-millisecond precision by combining timestamp with atomic counter
|
|
789
|
+
// This prevents race conditions in concurrent mutations while maintaining
|
|
790
|
+
// millisecond-level compatibility with existing code.
|
|
791
|
+
return now + (this.timestampCounter++ % 1000) / 1000;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private bumpMutationTimestamp(
|
|
795
|
+
table: string,
|
|
796
|
+
rowId: string,
|
|
797
|
+
now: number
|
|
798
|
+
): void {
|
|
799
|
+
const key = `${table}:${rowId}`;
|
|
800
|
+
const preciseNow = this.nextPreciseTimestamp(now);
|
|
801
|
+
const prev = this.mutationTimestamps.get(key) ?? 0;
|
|
802
|
+
this.mutationTimestamps.set(key, Math.max(preciseNow, prev + 0.001));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private bumpTableMutationTimestamp(table: string, now: number): void {
|
|
806
|
+
const preciseNow = this.nextPreciseTimestamp(now);
|
|
807
|
+
const prev = this.tableMutationTimestamps.get(table) ?? 0;
|
|
808
|
+
this.tableMutationTimestamps.set(table, Math.max(preciseNow, prev + 0.001));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Record local mutations that were already applied to the DB.
|
|
813
|
+
*
|
|
814
|
+
* This updates in-memory mutation timestamps (for fingerprint-based rerenders),
|
|
815
|
+
* and emits a single `data:change` event for the affected tables.
|
|
816
|
+
*
|
|
817
|
+
* This is intentionally separate from applyLocalMutation() so callers that
|
|
818
|
+
* perform their own DB transactions (e.g. `useMutations`) can still keep UI
|
|
819
|
+
* updates correct without double-writing.
|
|
820
|
+
*/
|
|
821
|
+
recordLocalMutations(
|
|
822
|
+
inputs: Array<{
|
|
823
|
+
table: string;
|
|
824
|
+
rowId: string;
|
|
825
|
+
op: 'upsert' | 'delete';
|
|
826
|
+
}>,
|
|
827
|
+
now = Date.now()
|
|
828
|
+
): void {
|
|
829
|
+
const affectedTables = new Set<string>();
|
|
830
|
+
|
|
831
|
+
for (const input of inputs) {
|
|
832
|
+
if (!input.table || !input.rowId) continue;
|
|
833
|
+
affectedTables.add(input.table);
|
|
834
|
+
|
|
835
|
+
if (input.op === 'delete') {
|
|
836
|
+
this.mutationTimestamps.delete(`${input.table}:${input.rowId}`);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
this.bumpMutationTimestamp(input.table, input.rowId, now);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (affectedTables.size > 0) {
|
|
844
|
+
this.emit('data:change', {
|
|
845
|
+
scopes: Array.from(affectedTables),
|
|
846
|
+
timestamp: Date.now(),
|
|
847
|
+
});
|
|
848
|
+
this.config.onDataChange?.(Array.from(affectedTables));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private recordMutationTimestampsFromPullResponse(
|
|
853
|
+
response: SyncPullResponse,
|
|
854
|
+
now: number
|
|
855
|
+
): void {
|
|
856
|
+
for (const sub of response.subscriptions ?? []) {
|
|
857
|
+
// Mark snapshot tables as changed so bootstrap/resnapshot updates
|
|
858
|
+
// propagate without storing per-row timestamps for massive snapshots.
|
|
859
|
+
for (const snapshot of sub.snapshots ?? []) {
|
|
860
|
+
if (!snapshot.table) continue;
|
|
861
|
+
this.bumpTableMutationTimestamp(snapshot.table, now);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
for (const commit of sub.commits ?? []) {
|
|
865
|
+
for (const change of commit.changes ?? []) {
|
|
866
|
+
const table = change.table;
|
|
867
|
+
const rowId = change.row_id;
|
|
868
|
+
if (!table || !rowId) continue;
|
|
869
|
+
|
|
870
|
+
if (change.op === 'delete') {
|
|
871
|
+
this.mutationTimestamps.delete(`${table}:${rowId}`);
|
|
872
|
+
} else {
|
|
873
|
+
this.bumpMutationTimestamp(table, rowId, now);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private scheduleRetry(): void {
|
|
881
|
+
if (this.retryTimeoutId) {
|
|
882
|
+
clearTimeout(this.retryTimeoutId);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const delay = calculateRetryDelay(this.state.retryCount);
|
|
886
|
+
this.updateState({ isRetrying: true });
|
|
887
|
+
|
|
888
|
+
this.retryTimeoutId = setTimeout(() => {
|
|
889
|
+
this.retryTimeoutId = null;
|
|
890
|
+
if (!this.isDestroyed) {
|
|
891
|
+
this.sync();
|
|
892
|
+
}
|
|
893
|
+
}, delay);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private handleError(error: SyncError): void {
|
|
897
|
+
this.emit('sync:error', error);
|
|
898
|
+
this.config.onError?.(error);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private setupPolling(): void {
|
|
902
|
+
this.stopPolling();
|
|
903
|
+
|
|
904
|
+
const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
905
|
+
this.pollerId = setInterval(() => {
|
|
906
|
+
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
907
|
+
this.sync();
|
|
908
|
+
}
|
|
909
|
+
}, interval);
|
|
910
|
+
|
|
911
|
+
this.setConnectionState('connected');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private stopPolling(): void {
|
|
915
|
+
if (this.pollerId) {
|
|
916
|
+
clearInterval(this.pollerId);
|
|
917
|
+
this.pollerId = null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
private setupRealtime(): void {
|
|
922
|
+
if (!isRealtimeTransport(this.config.transport)) {
|
|
923
|
+
console.warn(
|
|
924
|
+
'[SyncEngine] realtimeEnabled=true but transport does not support realtime. Falling back to polling.'
|
|
925
|
+
);
|
|
926
|
+
this.updateState({ transportMode: 'polling' });
|
|
927
|
+
this.setupPolling();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
this.setConnectionState('connecting');
|
|
932
|
+
|
|
933
|
+
const transport = this.config.transport as RealtimeTransportLike;
|
|
934
|
+
|
|
935
|
+
// Wire up presence events if transport supports them
|
|
936
|
+
if (transport.onPresenceEvent) {
|
|
937
|
+
this.realtimePresenceUnsub = transport.onPresenceEvent((event) => {
|
|
938
|
+
if (event.action === 'snapshot' && event.entries) {
|
|
939
|
+
this.updatePresence(event.scopeKey, event.entries);
|
|
940
|
+
} else if (
|
|
941
|
+
event.action === 'join' ||
|
|
942
|
+
event.action === 'leave' ||
|
|
943
|
+
event.action === 'update'
|
|
944
|
+
) {
|
|
945
|
+
this.handlePresenceEvent({
|
|
946
|
+
action: event.action,
|
|
947
|
+
scopeKey: event.scopeKey,
|
|
948
|
+
clientId: event.clientId ?? '',
|
|
949
|
+
actorId: event.actorId ?? '',
|
|
950
|
+
metadata: event.metadata,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
this.realtimeDisconnect = transport.connect(
|
|
957
|
+
{ clientId: this.config.clientId! },
|
|
958
|
+
(event) => {
|
|
959
|
+
if (event.event === 'sync') {
|
|
960
|
+
const hasInlineChanges =
|
|
961
|
+
Array.isArray(event.data.changes) && event.data.changes.length > 0;
|
|
962
|
+
const cursor = event.data.cursor;
|
|
963
|
+
|
|
964
|
+
if (hasInlineChanges && typeof cursor === 'number') {
|
|
965
|
+
// WS delivered changes + cursor — may skip HTTP pull
|
|
966
|
+
this.handleWsDelivery(event.data.changes as SyncChange[], cursor);
|
|
967
|
+
} else {
|
|
968
|
+
// Cursor-only wake-up or no cursor — must HTTP sync
|
|
969
|
+
this.sync({ trigger: 'ws' });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
(state) => {
|
|
974
|
+
switch (state) {
|
|
975
|
+
case 'connected': {
|
|
976
|
+
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
977
|
+
this.hasRealtimeConnectedOnce = true;
|
|
978
|
+
this.setConnectionState('connected');
|
|
979
|
+
this.stopFallbackPolling();
|
|
980
|
+
this.sync();
|
|
981
|
+
if (wasConnectedBefore) {
|
|
982
|
+
this.scheduleRealtimeReconnectCatchupSync();
|
|
983
|
+
}
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case 'connecting':
|
|
987
|
+
this.setConnectionState('connecting');
|
|
988
|
+
break;
|
|
989
|
+
case 'disconnected':
|
|
990
|
+
this.setConnectionState('reconnecting');
|
|
991
|
+
this.startFallbackPolling();
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private stopRealtime(): void {
|
|
999
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
1000
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
1001
|
+
this.realtimeCatchupTimeoutId = null;
|
|
1002
|
+
}
|
|
1003
|
+
if (this.realtimePresenceUnsub) {
|
|
1004
|
+
this.realtimePresenceUnsub();
|
|
1005
|
+
this.realtimePresenceUnsub = null;
|
|
1006
|
+
}
|
|
1007
|
+
if (this.realtimeDisconnect) {
|
|
1008
|
+
this.realtimeDisconnect();
|
|
1009
|
+
this.realtimeDisconnect = null;
|
|
1010
|
+
}
|
|
1011
|
+
this.stopFallbackPolling();
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private scheduleRealtimeReconnectCatchupSync(): void {
|
|
1015
|
+
if (this.realtimeCatchupTimeoutId) {
|
|
1016
|
+
clearTimeout(this.realtimeCatchupTimeoutId);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
this.realtimeCatchupTimeoutId = setTimeout(() => {
|
|
1020
|
+
this.realtimeCatchupTimeoutId = null;
|
|
1021
|
+
|
|
1022
|
+
if (this.isDestroyed || !this.isEnabled()) return;
|
|
1023
|
+
if (this.state.connectionState !== 'connected') return;
|
|
1024
|
+
|
|
1025
|
+
this.sync().catch(() => {
|
|
1026
|
+
// Best-effort catch-up sync after reconnect.
|
|
1027
|
+
});
|
|
1028
|
+
}, REALTIME_RECONNECT_CATCHUP_DELAY_MS);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private startFallbackPolling(): void {
|
|
1032
|
+
if (this.fallbackPollerId) return;
|
|
1033
|
+
|
|
1034
|
+
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
1035
|
+
this.fallbackPollerId = setInterval(() => {
|
|
1036
|
+
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
1037
|
+
this.sync();
|
|
1038
|
+
}
|
|
1039
|
+
}, interval);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private stopFallbackPolling(): void {
|
|
1043
|
+
if (this.fallbackPollerId) {
|
|
1044
|
+
clearInterval(this.fallbackPollerId);
|
|
1045
|
+
this.fallbackPollerId = null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|
|
1051
|
+
* Call this after deleting local data (e.g. reset flow) so that React hooks
|
|
1052
|
+
* recompute fingerprints from scratch instead of seeing stale timestamps.
|
|
1053
|
+
*/
|
|
1054
|
+
resetLocalState(): void {
|
|
1055
|
+
const tables = [...this.tableMutationTimestamps.keys()];
|
|
1056
|
+
this.mutationTimestamps.clear();
|
|
1057
|
+
this.tableMutationTimestamps.clear();
|
|
1058
|
+
|
|
1059
|
+
if (tables.length > 0) {
|
|
1060
|
+
this.emit('data:change', {
|
|
1061
|
+
scopes: tables,
|
|
1062
|
+
timestamp: Date.now(),
|
|
1063
|
+
});
|
|
1064
|
+
this.config.onDataChange?.(tables);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Reconnect
|
|
1070
|
+
*/
|
|
1071
|
+
reconnect(): void {
|
|
1072
|
+
if (this.isDestroyed || !this.isEnabled()) return;
|
|
1073
|
+
|
|
1074
|
+
if (
|
|
1075
|
+
this.state.transportMode === 'realtime' &&
|
|
1076
|
+
isRealtimeTransport(this.config.transport)
|
|
1077
|
+
) {
|
|
1078
|
+
// If we previously disconnected, we need to re-register callbacks via connect().
|
|
1079
|
+
if (!this.realtimeDisconnect) {
|
|
1080
|
+
this.setupRealtime();
|
|
1081
|
+
} else {
|
|
1082
|
+
this.config.transport.reconnect();
|
|
1083
|
+
}
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Polling mode: restart the poller and trigger a sync immediately.
|
|
1088
|
+
if (this.state.transportMode === 'polling') {
|
|
1089
|
+
this.setupPolling();
|
|
1090
|
+
// Trigger sync in background - errors are handled internally by sync()
|
|
1091
|
+
this.sync().catch((err) => {
|
|
1092
|
+
console.error('Unexpected error during reconnect sync:', err);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Disconnect (pause syncing)
|
|
1099
|
+
*/
|
|
1100
|
+
disconnect(): void {
|
|
1101
|
+
this.stop();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Refresh outbox statistics
|
|
1106
|
+
*/
|
|
1107
|
+
async refreshOutboxStats(options?: { emit?: boolean }): Promise<OutboxStats> {
|
|
1108
|
+
const db = this.config.db;
|
|
1109
|
+
|
|
1110
|
+
const res = await sql<{ status: OutboxCommitStatus; count: number }>`
|
|
1111
|
+
select
|
|
1112
|
+
${sql.ref('status')},
|
|
1113
|
+
count(${sql.ref('id')}) as ${sql.ref('count')}
|
|
1114
|
+
from ${sql.table('sync_outbox_commits')}
|
|
1115
|
+
group by ${sql.ref('status')}
|
|
1116
|
+
`.execute(db);
|
|
1117
|
+
const rows = res.rows;
|
|
1118
|
+
|
|
1119
|
+
const stats: OutboxStats = {
|
|
1120
|
+
pending: 0,
|
|
1121
|
+
sending: 0,
|
|
1122
|
+
failed: 0,
|
|
1123
|
+
acked: 0,
|
|
1124
|
+
total: 0,
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
for (const row of rows) {
|
|
1128
|
+
const count = Number(row.count);
|
|
1129
|
+
switch (row.status) {
|
|
1130
|
+
case 'pending':
|
|
1131
|
+
stats.pending = count;
|
|
1132
|
+
break;
|
|
1133
|
+
case 'sending':
|
|
1134
|
+
stats.sending = count;
|
|
1135
|
+
break;
|
|
1136
|
+
case 'failed':
|
|
1137
|
+
stats.failed = count;
|
|
1138
|
+
break;
|
|
1139
|
+
case 'acked':
|
|
1140
|
+
stats.acked = count;
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
stats.total += count;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
this.updateState({ pendingCount: stats.pending + stats.failed });
|
|
1147
|
+
if (options?.emit !== false) {
|
|
1148
|
+
this.emit('outbox:change', {
|
|
1149
|
+
pendingCount: stats.pending,
|
|
1150
|
+
sendingCount: stats.sending,
|
|
1151
|
+
failedCount: stats.failed,
|
|
1152
|
+
ackedCount: stats.acked,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return stats;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Get pending conflicts with operation details from outbox
|
|
1161
|
+
*/
|
|
1162
|
+
async getConflicts(): Promise<ConflictInfo[]> {
|
|
1163
|
+
// Join with outbox to get operation details
|
|
1164
|
+
const res = await sql<{
|
|
1165
|
+
id: string;
|
|
1166
|
+
outbox_commit_id: string;
|
|
1167
|
+
client_commit_id: string;
|
|
1168
|
+
op_index: number;
|
|
1169
|
+
result_status: ConflictResultStatus;
|
|
1170
|
+
message: string;
|
|
1171
|
+
code: string | null;
|
|
1172
|
+
server_version: number | null;
|
|
1173
|
+
server_row_json: string | null;
|
|
1174
|
+
created_at: number;
|
|
1175
|
+
operations_json: string;
|
|
1176
|
+
}>`
|
|
1177
|
+
select
|
|
1178
|
+
${sql.ref('c.id')},
|
|
1179
|
+
${sql.ref('c.outbox_commit_id')},
|
|
1180
|
+
${sql.ref('c.client_commit_id')},
|
|
1181
|
+
${sql.ref('c.op_index')},
|
|
1182
|
+
${sql.ref('c.result_status')},
|
|
1183
|
+
${sql.ref('c.message')},
|
|
1184
|
+
${sql.ref('c.code')},
|
|
1185
|
+
${sql.ref('c.server_version')},
|
|
1186
|
+
${sql.ref('c.server_row_json')},
|
|
1187
|
+
${sql.ref('c.created_at')},
|
|
1188
|
+
${sql.ref('oc.operations_json')}
|
|
1189
|
+
from ${sql.table('sync_conflicts')} as ${sql.ref('c')}
|
|
1190
|
+
inner join ${sql.table('sync_outbox_commits')} as ${sql.ref('oc')}
|
|
1191
|
+
on ${sql.ref('oc.id')} = ${sql.ref('c.outbox_commit_id')}
|
|
1192
|
+
where ${sql.ref('c.resolved_at')} is null
|
|
1193
|
+
order by ${sql.ref('c.created_at')} desc
|
|
1194
|
+
`.execute(this.config.db);
|
|
1195
|
+
const rows = res.rows;
|
|
1196
|
+
|
|
1197
|
+
return rows.map((row) => {
|
|
1198
|
+
// Extract operation details from outbox
|
|
1199
|
+
let table = '';
|
|
1200
|
+
let rowId = '';
|
|
1201
|
+
let localPayload: Record<string, unknown> | null = null;
|
|
1202
|
+
|
|
1203
|
+
if (row.operations_json) {
|
|
1204
|
+
try {
|
|
1205
|
+
const operations: unknown = JSON.parse(row.operations_json);
|
|
1206
|
+
if (Array.isArray(operations)) {
|
|
1207
|
+
const op = operations[row.op_index];
|
|
1208
|
+
if (isRecord(op)) {
|
|
1209
|
+
if (typeof op.table === 'string') table = op.table;
|
|
1210
|
+
if (typeof op.row_id === 'string') rowId = op.row_id;
|
|
1211
|
+
localPayload =
|
|
1212
|
+
op.payload === null
|
|
1213
|
+
? null
|
|
1214
|
+
: isRecord(op.payload)
|
|
1215
|
+
? op.payload
|
|
1216
|
+
: null;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Ignore parse errors
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
return {
|
|
1225
|
+
id: row.id,
|
|
1226
|
+
outboxCommitId: row.outbox_commit_id,
|
|
1227
|
+
clientCommitId: row.client_commit_id,
|
|
1228
|
+
opIndex: row.op_index,
|
|
1229
|
+
resultStatus: row.result_status,
|
|
1230
|
+
message: row.message,
|
|
1231
|
+
code: row.code,
|
|
1232
|
+
serverVersion: row.server_version,
|
|
1233
|
+
serverRowJson: row.server_row_json,
|
|
1234
|
+
createdAt: row.created_at,
|
|
1235
|
+
table,
|
|
1236
|
+
rowId,
|
|
1237
|
+
localPayload,
|
|
1238
|
+
};
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Update subscriptions dynamically
|
|
1244
|
+
*/
|
|
1245
|
+
updateSubscriptions(
|
|
1246
|
+
subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>
|
|
1247
|
+
): void {
|
|
1248
|
+
this.config.subscriptions = subscriptions;
|
|
1249
|
+
// Trigger a sync to apply new subscriptions
|
|
1250
|
+
this.sync();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Apply local mutations immediately to the database and emit change events.
|
|
1255
|
+
* Used for instant UI updates before the sync cycle completes.
|
|
1256
|
+
*/
|
|
1257
|
+
async applyLocalMutation(
|
|
1258
|
+
inputs: Array<{
|
|
1259
|
+
table: string;
|
|
1260
|
+
rowId: string;
|
|
1261
|
+
op: 'upsert' | 'delete';
|
|
1262
|
+
payload?: Record<string, unknown> | null;
|
|
1263
|
+
}>
|
|
1264
|
+
): Promise<void> {
|
|
1265
|
+
const db = this.config.db;
|
|
1266
|
+
const shapes = this.config.shapes;
|
|
1267
|
+
const affectedTables = new Set<string>();
|
|
1268
|
+
const now = Date.now();
|
|
1269
|
+
|
|
1270
|
+
await db.transaction().execute(async (trx) => {
|
|
1271
|
+
for (const input of inputs) {
|
|
1272
|
+
const handler = shapes.get(input.table);
|
|
1273
|
+
if (!handler) continue;
|
|
1274
|
+
|
|
1275
|
+
affectedTables.add(input.table);
|
|
1276
|
+
|
|
1277
|
+
const change: SyncChange = {
|
|
1278
|
+
table: input.table,
|
|
1279
|
+
row_id: input.rowId,
|
|
1280
|
+
op: input.op,
|
|
1281
|
+
scopes: {},
|
|
1282
|
+
// For delete ops, row_json should be null; for upserts, default to empty object
|
|
1283
|
+
row_json: input.op === 'delete' ? null : (input.payload ?? {}),
|
|
1284
|
+
// null indicates local optimistic change (no server version yet)
|
|
1285
|
+
row_version: null,
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
await handler.applyChange({ trx }, change);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// Track mutation timestamps for fingerprint-based rerender optimization (in-memory only)
|
|
1293
|
+
this.recordLocalMutations(
|
|
1294
|
+
inputs
|
|
1295
|
+
.filter((i) => affectedTables.has(i.table))
|
|
1296
|
+
.map((i) => ({ table: i.table, rowId: i.rowId, op: i.op })),
|
|
1297
|
+
now
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Clear failed commits from the outbox.
|
|
1303
|
+
* Use this to discard commits that keep failing (e.g., version conflicts).
|
|
1304
|
+
*/
|
|
1305
|
+
async clearFailedCommits(): Promise<number> {
|
|
1306
|
+
const db = this.config.db;
|
|
1307
|
+
|
|
1308
|
+
const res = await sql`
|
|
1309
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
1310
|
+
where ${sql.ref('status')} = ${sql.val('failed')}
|
|
1311
|
+
`.execute(db);
|
|
1312
|
+
const count = Number(res.numAffectedRows ?? 0);
|
|
1313
|
+
|
|
1314
|
+
await this.refreshOutboxStats();
|
|
1315
|
+
return count;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Clear all pending and failed commits from the outbox.
|
|
1320
|
+
* Use this to reset the outbox completely (e.g., for testing).
|
|
1321
|
+
*/
|
|
1322
|
+
async clearAllCommits(): Promise<number> {
|
|
1323
|
+
const db = this.config.db;
|
|
1324
|
+
|
|
1325
|
+
const res = await sql`
|
|
1326
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
1327
|
+
where ${sql.ref('status')} in (${sql.join([
|
|
1328
|
+
sql.val('pending'),
|
|
1329
|
+
sql.val('failed'),
|
|
1330
|
+
])})
|
|
1331
|
+
`.execute(db);
|
|
1332
|
+
const count = Number(res.numAffectedRows ?? 0);
|
|
1333
|
+
|
|
1334
|
+
await this.refreshOutboxStats();
|
|
1335
|
+
return count;
|
|
1336
|
+
}
|
|
1337
|
+
}
|