@powersync/common 0.0.0-dev-20250210155038 → 0.0.0-dev-20250416114737
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 +1 -4
- package/dist/bundle.mjs +3 -3
- package/lib/client/AbstractPowerSyncDatabase.d.ts +88 -6
- package/lib/client/AbstractPowerSyncDatabase.js +131 -20
- package/lib/client/SQLOpenFactory.d.ts +3 -0
- package/lib/client/sync/bucket/BucketStorageAdapter.d.ts +6 -1
- package/lib/client/sync/bucket/SqliteBucketStorage.d.ts +2 -2
- package/lib/client/sync/bucket/SqliteBucketStorage.js +34 -10
- package/lib/client/sync/stream/AbstractRemote.d.ts +16 -1
- package/lib/client/sync/stream/AbstractRemote.js +20 -7
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +11 -4
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +169 -84
- package/lib/client/sync/stream/streaming-sync-types.d.ts +8 -1
- package/lib/client/sync/stream/streaming-sync-types.js +3 -0
- package/lib/db/DBAdapter.d.ts +17 -1
- package/lib/db/crud/SyncStatus.d.ts +96 -6
- package/lib/db/crud/SyncStatus.js +89 -8
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/utils/DataStream.js +5 -6
- package/lib/utils/Logger.d.ts +16 -0
- package/lib/utils/Logger.js +21 -0
- package/lib/utils/{throttle.d.ts → async.d.ts} +1 -0
- package/lib/utils/{throttle.js → async.js} +10 -0
- package/package.json +1 -1
|
@@ -16,6 +16,21 @@ export type SyncStreamOptions = {
|
|
|
16
16
|
abortSignal?: AbortSignal;
|
|
17
17
|
fetchOptions?: Request;
|
|
18
18
|
};
|
|
19
|
+
export declare enum FetchStrategy {
|
|
20
|
+
/**
|
|
21
|
+
* Queues multiple sync events before processing, reducing round-trips.
|
|
22
|
+
* This comes at the cost of more processing overhead, which may cause ACK timeouts on older/weaker devices for big enough datasets.
|
|
23
|
+
*/
|
|
24
|
+
Buffered = "buffered",
|
|
25
|
+
/**
|
|
26
|
+
* Processes each sync event immediately before requesting the next.
|
|
27
|
+
* This reduces processing overhead and improves real-time responsiveness.
|
|
28
|
+
*/
|
|
29
|
+
Sequential = "sequential"
|
|
30
|
+
}
|
|
31
|
+
export type SocketSyncStreamOptions = SyncStreamOptions & {
|
|
32
|
+
fetchStrategy: FetchStrategy;
|
|
33
|
+
};
|
|
19
34
|
export type FetchImplementation = typeof fetch;
|
|
20
35
|
/**
|
|
21
36
|
* Class wrapper for providing a fetch implementation.
|
|
@@ -72,7 +87,7 @@ export declare abstract class AbstractRemote {
|
|
|
72
87
|
/**
|
|
73
88
|
* Connects to the sync/stream websocket endpoint
|
|
74
89
|
*/
|
|
75
|
-
socketStream(options:
|
|
90
|
+
socketStream(options: SocketSyncStreamOptions): Promise<DataStream<StreamingSyncLine>>;
|
|
76
91
|
/**
|
|
77
92
|
* Connects to the sync/stream http endpoint
|
|
78
93
|
*/
|
|
@@ -10,13 +10,25 @@ const POWERSYNC_TRAILING_SLASH_MATCH = /\/+$/;
|
|
|
10
10
|
const POWERSYNC_JS_VERSION = PACKAGE.version;
|
|
11
11
|
// Refresh at least 30 sec before it expires
|
|
12
12
|
const REFRESH_CREDENTIALS_SAFETY_PERIOD_MS = 30_000;
|
|
13
|
-
const SYNC_QUEUE_REQUEST_N = 10;
|
|
14
13
|
const SYNC_QUEUE_REQUEST_LOW_WATER = 5;
|
|
15
14
|
// Keep alive message is sent every period
|
|
16
15
|
const KEEP_ALIVE_MS = 20_000;
|
|
17
16
|
// The ACK must be received in this period
|
|
18
17
|
const KEEP_ALIVE_LIFETIME_MS = 30_000;
|
|
19
18
|
export const DEFAULT_REMOTE_LOGGER = Logger.get('PowerSyncRemote');
|
|
19
|
+
export var FetchStrategy;
|
|
20
|
+
(function (FetchStrategy) {
|
|
21
|
+
/**
|
|
22
|
+
* Queues multiple sync events before processing, reducing round-trips.
|
|
23
|
+
* This comes at the cost of more processing overhead, which may cause ACK timeouts on older/weaker devices for big enough datasets.
|
|
24
|
+
*/
|
|
25
|
+
FetchStrategy["Buffered"] = "buffered";
|
|
26
|
+
/**
|
|
27
|
+
* Processes each sync event immediately before requesting the next.
|
|
28
|
+
* This reduces processing overhead and improves real-time responsiveness.
|
|
29
|
+
*/
|
|
30
|
+
FetchStrategy["Sequential"] = "sequential";
|
|
31
|
+
})(FetchStrategy || (FetchStrategy = {}));
|
|
20
32
|
/**
|
|
21
33
|
* Class wrapper for providing a fetch implementation.
|
|
22
34
|
* The class wrapper is used to distinguish the fetchImplementation
|
|
@@ -145,7 +157,8 @@ export class AbstractRemote {
|
|
|
145
157
|
* Connects to the sync/stream websocket endpoint
|
|
146
158
|
*/
|
|
147
159
|
async socketStream(options) {
|
|
148
|
-
const { path } = options;
|
|
160
|
+
const { path, fetchStrategy = FetchStrategy.Buffered } = options;
|
|
161
|
+
const syncQueueRequestSize = fetchStrategy == FetchStrategy.Buffered ? 10 : 1;
|
|
149
162
|
const request = await this.buildRequest(path);
|
|
150
163
|
const bson = await this.getBSON();
|
|
151
164
|
// Add the user agent in the setup payload - we can't set custom
|
|
@@ -198,7 +211,7 @@ export class AbstractRemote {
|
|
|
198
211
|
// Helps to prevent double close scenarios
|
|
199
212
|
rsocket.onClose(() => (socketIsClosed = true));
|
|
200
213
|
// We initially request this amount and expect these to arrive eventually
|
|
201
|
-
let pendingEventsCount =
|
|
214
|
+
let pendingEventsCount = syncQueueRequestSize;
|
|
202
215
|
const disposeClosedListener = stream.registerListener({
|
|
203
216
|
closed: () => {
|
|
204
217
|
closeSocket();
|
|
@@ -212,7 +225,7 @@ export class AbstractRemote {
|
|
|
212
225
|
metadata: Buffer.from(bson.serialize({
|
|
213
226
|
path
|
|
214
227
|
}))
|
|
215
|
-
},
|
|
228
|
+
}, syncQueueRequestSize, // The initial N amount
|
|
216
229
|
{
|
|
217
230
|
onError: (e) => {
|
|
218
231
|
// Don't log closed as an error
|
|
@@ -251,10 +264,10 @@ export class AbstractRemote {
|
|
|
251
264
|
const l = stream.registerListener({
|
|
252
265
|
lowWater: async () => {
|
|
253
266
|
// Request to fill up the queue
|
|
254
|
-
const required =
|
|
267
|
+
const required = syncQueueRequestSize - pendingEventsCount;
|
|
255
268
|
if (required > 0) {
|
|
256
|
-
socket.request(
|
|
257
|
-
pendingEventsCount =
|
|
269
|
+
socket.request(syncQueueRequestSize - pendingEventsCount);
|
|
270
|
+
pendingEventsCount = syncQueueRequestSize;
|
|
258
271
|
}
|
|
259
272
|
},
|
|
260
273
|
closed: () => {
|
|
@@ -2,7 +2,7 @@ import Logger, { ILogger } from 'js-logger';
|
|
|
2
2
|
import { SyncStatus, SyncStatusOptions } from '../../../db/crud/SyncStatus.js';
|
|
3
3
|
import { BaseListener, BaseObserver, Disposable } from '../../../utils/BaseObserver.js';
|
|
4
4
|
import { BucketStorageAdapter } from '../bucket/BucketStorageAdapter.js';
|
|
5
|
-
import { AbstractRemote } from './AbstractRemote.js';
|
|
5
|
+
import { AbstractRemote, FetchStrategy } from './AbstractRemote.js';
|
|
6
6
|
import { StreamingSyncRequestParameterType } from './streaming-sync-types.js';
|
|
7
7
|
export declare enum LockType {
|
|
8
8
|
CRUD = "crud",
|
|
@@ -56,6 +56,10 @@ export interface BaseConnectionOptions {
|
|
|
56
56
|
* Defaults to a HTTP streaming connection.
|
|
57
57
|
*/
|
|
58
58
|
connectionMethod?: SyncStreamConnectionMethod;
|
|
59
|
+
/**
|
|
60
|
+
* The fetch strategy to use when streaming updates from the PowerSync backend instance.
|
|
61
|
+
*/
|
|
62
|
+
fetchStrategy?: FetchStrategy;
|
|
59
63
|
/**
|
|
60
64
|
* These parameters are passed to the sync rules, and will be available under the`user_parameters` object.
|
|
61
65
|
*/
|
|
@@ -95,6 +99,7 @@ export interface StreamingSyncImplementation extends BaseObserver<StreamingSyncI
|
|
|
95
99
|
triggerCrudUpload: () => void;
|
|
96
100
|
waitForReady(): Promise<void>;
|
|
97
101
|
waitForStatus(status: SyncStatusOptions): Promise<void>;
|
|
102
|
+
waitUntilStatusMatches(predicate: (status: SyncStatus) => boolean): Promise<void>;
|
|
98
103
|
}
|
|
99
104
|
export declare const DEFAULT_CRUD_UPLOAD_THROTTLE_MS = 1000;
|
|
100
105
|
export declare const DEFAULT_RETRY_DELAY_MS = 5000;
|
|
@@ -111,11 +116,13 @@ export declare abstract class AbstractStreamingSyncImplementation extends BaseOb
|
|
|
111
116
|
protected abortController: AbortController | null;
|
|
112
117
|
protected crudUpdateListener?: () => void;
|
|
113
118
|
protected streamingSyncPromise?: Promise<void>;
|
|
119
|
+
private pendingCrudUpload?;
|
|
114
120
|
syncStatus: SyncStatus;
|
|
115
121
|
triggerCrudUpload: () => void;
|
|
116
122
|
constructor(options: AbstractStreamingSyncImplementationOptions);
|
|
117
123
|
waitForReady(): Promise<void>;
|
|
118
124
|
waitForStatus(status: SyncStatusOptions): Promise<void>;
|
|
125
|
+
waitUntilStatusMatches(predicate: (status: SyncStatus) => boolean): Promise<void>;
|
|
119
126
|
get lastSyncedAt(): Date | undefined;
|
|
120
127
|
get isConnected(): boolean;
|
|
121
128
|
protected get logger(): Logger.ILogger;
|
|
@@ -130,9 +137,9 @@ export declare abstract class AbstractStreamingSyncImplementation extends BaseOb
|
|
|
130
137
|
* @deprecated use [connect instead]
|
|
131
138
|
*/
|
|
132
139
|
streamingSync(signal?: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void>;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
private collectLocalBucketState;
|
|
141
|
+
protected streamingSyncIteration(signal: AbortSignal, options?: PowerSyncConnectionOptions): Promise<void>;
|
|
142
|
+
private applyCheckpoint;
|
|
136
143
|
protected updateSyncStatus(options: SyncStatusOptions): void;
|
|
137
144
|
private delayRetry;
|
|
138
145
|
}
|
|
@@ -2,9 +2,10 @@ import Logger from 'js-logger';
|
|
|
2
2
|
import { SyncStatus } from '../../../db/crud/SyncStatus.js';
|
|
3
3
|
import { AbortOperation } from '../../../utils/AbortOperation.js';
|
|
4
4
|
import { BaseObserver } from '../../../utils/BaseObserver.js';
|
|
5
|
-
import { throttleLeadingTrailing } from '../../../utils/
|
|
5
|
+
import { onAbortPromise, throttleLeadingTrailing } from '../../../utils/async.js';
|
|
6
6
|
import { SyncDataBucket } from '../bucket/SyncDataBucket.js';
|
|
7
|
-
import {
|
|
7
|
+
import { FetchStrategy } from './AbstractRemote.js';
|
|
8
|
+
import { isStreamingKeepalive, isStreamingSyncCheckpoint, isStreamingSyncCheckpointComplete, isStreamingSyncCheckpointDiff, isStreamingSyncCheckpointPartiallyComplete, isStreamingSyncData } from './streaming-sync-types.js';
|
|
8
9
|
export var LockType;
|
|
9
10
|
(function (LockType) {
|
|
10
11
|
LockType["CRUD"] = "crud";
|
|
@@ -24,14 +25,21 @@ export const DEFAULT_STREAMING_SYNC_OPTIONS = {
|
|
|
24
25
|
};
|
|
25
26
|
export const DEFAULT_STREAM_CONNECTION_OPTIONS = {
|
|
26
27
|
connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET,
|
|
28
|
+
fetchStrategy: FetchStrategy.Buffered,
|
|
27
29
|
params: {}
|
|
28
30
|
};
|
|
31
|
+
// The priority we assume when we receive checkpoint lines where no priority is set.
|
|
32
|
+
// This is the default priority used by the sync service, but can be set to an arbitrary
|
|
33
|
+
// value since sync services without priorities also won't send partial sync completion
|
|
34
|
+
// messages.
|
|
35
|
+
const FALLBACK_PRIORITY = 3;
|
|
29
36
|
export class AbstractStreamingSyncImplementation extends BaseObserver {
|
|
30
37
|
_lastSyncedAt;
|
|
31
38
|
options;
|
|
32
39
|
abortController;
|
|
33
40
|
crudUpdateListener;
|
|
34
41
|
streamingSyncPromise;
|
|
42
|
+
pendingCrudUpload;
|
|
35
43
|
syncStatus;
|
|
36
44
|
triggerCrudUpload;
|
|
37
45
|
constructor(options) {
|
|
@@ -48,31 +56,45 @@ export class AbstractStreamingSyncImplementation extends BaseObserver {
|
|
|
48
56
|
});
|
|
49
57
|
this.abortController = null;
|
|
50
58
|
this.triggerCrudUpload = throttleLeadingTrailing(() => {
|
|
51
|
-
if (!this.syncStatus.connected || this.
|
|
59
|
+
if (!this.syncStatus.connected || this.pendingCrudUpload != null) {
|
|
52
60
|
return;
|
|
53
61
|
}
|
|
54
|
-
this.
|
|
62
|
+
this.pendingCrudUpload = new Promise((resolve) => {
|
|
63
|
+
this._uploadAllCrud().finally(() => {
|
|
64
|
+
this.pendingCrudUpload = undefined;
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
55
68
|
}, this.options.crudUploadThrottleMs);
|
|
56
69
|
}
|
|
57
70
|
async waitForReady() { }
|
|
58
71
|
waitForStatus(status) {
|
|
72
|
+
return this.waitUntilStatusMatches((currentStatus) => {
|
|
73
|
+
/**
|
|
74
|
+
* Match only the partial status options provided in the
|
|
75
|
+
* matching status
|
|
76
|
+
*/
|
|
77
|
+
const matchPartialObject = (compA, compB) => {
|
|
78
|
+
return Object.entries(compA).every(([key, value]) => {
|
|
79
|
+
const comparisonBValue = compB[key];
|
|
80
|
+
if (typeof value == 'object' && typeof comparisonBValue == 'object') {
|
|
81
|
+
return matchPartialObject(value, comparisonBValue);
|
|
82
|
+
}
|
|
83
|
+
return value == comparisonBValue;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
return matchPartialObject(status, currentStatus);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
waitUntilStatusMatches(predicate) {
|
|
59
90
|
return new Promise((resolve) => {
|
|
91
|
+
if (predicate(this.syncStatus)) {
|
|
92
|
+
resolve();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
60
95
|
const l = this.registerListener({
|
|
61
96
|
statusChanged: (updatedStatus) => {
|
|
62
|
-
|
|
63
|
-
* Match only the partial status options provided in the
|
|
64
|
-
* matching status
|
|
65
|
-
*/
|
|
66
|
-
const matchPartialObject = (compA, compB) => {
|
|
67
|
-
return Object.entries(compA).every(([key, value]) => {
|
|
68
|
-
const comparisonBValue = compB[key];
|
|
69
|
-
if (typeof value == 'object' && typeof comparisonBValue == 'object') {
|
|
70
|
-
return matchPartialObject(value, comparisonBValue);
|
|
71
|
-
}
|
|
72
|
-
return value == comparisonBValue;
|
|
73
|
-
});
|
|
74
|
-
};
|
|
75
|
-
if (matchPartialObject(status, updatedStatus.toJSON())) {
|
|
97
|
+
if (predicate(updatedStatus)) {
|
|
76
98
|
resolve();
|
|
77
99
|
l?.();
|
|
78
100
|
}
|
|
@@ -132,6 +154,11 @@ The next upload iteration will be delayed.`);
|
|
|
132
154
|
}
|
|
133
155
|
checkedCrudItem = nextCrudItem;
|
|
134
156
|
await this.options.uploadCrud();
|
|
157
|
+
this.updateSyncStatus({
|
|
158
|
+
dataFlow: {
|
|
159
|
+
uploadError: undefined
|
|
160
|
+
}
|
|
161
|
+
});
|
|
135
162
|
}
|
|
136
163
|
else {
|
|
137
164
|
// Uploading is completed
|
|
@@ -143,7 +170,8 @@ The next upload iteration will be delayed.`);
|
|
|
143
170
|
checkedCrudItem = undefined;
|
|
144
171
|
this.updateSyncStatus({
|
|
145
172
|
dataFlow: {
|
|
146
|
-
uploading: false
|
|
173
|
+
uploading: false,
|
|
174
|
+
uploadError: ex
|
|
147
175
|
}
|
|
148
176
|
});
|
|
149
177
|
await this.delayRetry();
|
|
@@ -258,16 +286,8 @@ The next upload iteration will be delayed.`);
|
|
|
258
286
|
if (signal?.aborted) {
|
|
259
287
|
break;
|
|
260
288
|
}
|
|
261
|
-
|
|
262
|
-
if
|
|
263
|
-
/**
|
|
264
|
-
* A sync error ocurred that we cannot recover from here.
|
|
265
|
-
* This loop must terminate.
|
|
266
|
-
* The nestedAbortController will close any open network requests and streams below.
|
|
267
|
-
*/
|
|
268
|
-
break;
|
|
269
|
-
}
|
|
270
|
-
// Continue immediately
|
|
289
|
+
await this.streamingSyncIteration(nestedAbortController.signal, options);
|
|
290
|
+
// Continue immediately, streamingSyncIteration will wait before completing if necessary.
|
|
271
291
|
}
|
|
272
292
|
catch (ex) {
|
|
273
293
|
/**
|
|
@@ -284,6 +304,11 @@ The next upload iteration will be delayed.`);
|
|
|
284
304
|
else {
|
|
285
305
|
this.logger.error(ex);
|
|
286
306
|
}
|
|
307
|
+
this.updateSyncStatus({
|
|
308
|
+
dataFlow: {
|
|
309
|
+
downloadError: ex
|
|
310
|
+
}
|
|
311
|
+
});
|
|
287
312
|
// On error, wait a little before retrying
|
|
288
313
|
await this.delayRetry();
|
|
289
314
|
}
|
|
@@ -301,8 +326,20 @@ The next upload iteration will be delayed.`);
|
|
|
301
326
|
// Mark as disconnected if here
|
|
302
327
|
this.updateSyncStatus({ connected: false, connecting: false });
|
|
303
328
|
}
|
|
329
|
+
async collectLocalBucketState() {
|
|
330
|
+
const bucketEntries = await this.options.adapter.getBucketStates();
|
|
331
|
+
const req = bucketEntries.map((entry) => ({
|
|
332
|
+
name: entry.bucket,
|
|
333
|
+
after: entry.op_id
|
|
334
|
+
}));
|
|
335
|
+
const localDescriptions = new Map();
|
|
336
|
+
for (const entry of bucketEntries) {
|
|
337
|
+
localDescriptions.set(entry.bucket, null);
|
|
338
|
+
}
|
|
339
|
+
return [req, localDescriptions];
|
|
340
|
+
}
|
|
304
341
|
async streamingSyncIteration(signal, options) {
|
|
305
|
-
|
|
342
|
+
await this.obtainLock({
|
|
306
343
|
type: LockType.SYNC,
|
|
307
344
|
signal,
|
|
308
345
|
callback: async () => {
|
|
@@ -312,20 +349,11 @@ The next upload iteration will be delayed.`);
|
|
|
312
349
|
};
|
|
313
350
|
this.logger.debug('Streaming sync iteration started');
|
|
314
351
|
this.options.adapter.startSession();
|
|
315
|
-
|
|
316
|
-
const initialBuckets = new Map();
|
|
317
|
-
bucketEntries.forEach((entry) => {
|
|
318
|
-
initialBuckets.set(entry.bucket, entry.op_id);
|
|
319
|
-
});
|
|
320
|
-
const req = Array.from(initialBuckets.entries()).map(([bucket, after]) => ({
|
|
321
|
-
name: bucket,
|
|
322
|
-
after: after
|
|
323
|
-
}));
|
|
352
|
+
let [req, bucketMap] = await this.collectLocalBucketState();
|
|
324
353
|
// These are compared by reference
|
|
325
354
|
let targetCheckpoint = null;
|
|
326
355
|
let validatedCheckpoint = null;
|
|
327
356
|
let appliedCheckpoint = null;
|
|
328
|
-
let bucketSet = new Set(initialBuckets.keys());
|
|
329
357
|
const clientId = await this.options.adapter.getClientId();
|
|
330
358
|
this.logger.debug('Requesting stream from server');
|
|
331
359
|
const syncOptions = {
|
|
@@ -339,15 +367,22 @@ The next upload iteration will be delayed.`);
|
|
|
339
367
|
client_id: clientId
|
|
340
368
|
}
|
|
341
369
|
};
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
370
|
+
let stream;
|
|
371
|
+
if (resolvedOptions?.connectionMethod == SyncStreamConnectionMethod.HTTP) {
|
|
372
|
+
stream = await this.options.remote.postStream(syncOptions);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
stream = await this.options.remote.socketStream({
|
|
376
|
+
...syncOptions,
|
|
377
|
+
...{ fetchStrategy: resolvedOptions.fetchStrategy }
|
|
378
|
+
});
|
|
379
|
+
}
|
|
345
380
|
this.logger.debug('Stream established. Processing events');
|
|
346
381
|
while (!stream.closed) {
|
|
347
382
|
const line = await stream.read();
|
|
348
383
|
if (!line) {
|
|
349
384
|
// The stream has closed while waiting
|
|
350
|
-
return
|
|
385
|
+
return;
|
|
351
386
|
}
|
|
352
387
|
// A connection is active and messages are being received
|
|
353
388
|
if (!this.syncStatus.connected) {
|
|
@@ -359,45 +394,61 @@ The next upload iteration will be delayed.`);
|
|
|
359
394
|
}
|
|
360
395
|
if (isStreamingSyncCheckpoint(line)) {
|
|
361
396
|
targetCheckpoint = line.checkpoint;
|
|
362
|
-
const bucketsToDelete = new Set(
|
|
363
|
-
const newBuckets = new
|
|
397
|
+
const bucketsToDelete = new Set(bucketMap.keys());
|
|
398
|
+
const newBuckets = new Map();
|
|
364
399
|
for (const checksum of line.checkpoint.buckets) {
|
|
365
|
-
newBuckets.
|
|
400
|
+
newBuckets.set(checksum.bucket, {
|
|
401
|
+
name: checksum.bucket,
|
|
402
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
403
|
+
});
|
|
366
404
|
bucketsToDelete.delete(checksum.bucket);
|
|
367
405
|
}
|
|
368
406
|
if (bucketsToDelete.size > 0) {
|
|
369
407
|
this.logger.debug('Removing buckets', [...bucketsToDelete]);
|
|
370
408
|
}
|
|
371
|
-
|
|
409
|
+
bucketMap = newBuckets;
|
|
372
410
|
await this.options.adapter.removeBuckets([...bucketsToDelete]);
|
|
373
411
|
await this.options.adapter.setTargetCheckpoint(targetCheckpoint);
|
|
374
412
|
}
|
|
375
413
|
else if (isStreamingSyncCheckpointComplete(line)) {
|
|
376
|
-
this.
|
|
377
|
-
|
|
414
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
415
|
+
if (result.endIteration) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
else if (result.applied) {
|
|
419
|
+
appliedCheckpoint = targetCheckpoint;
|
|
420
|
+
}
|
|
421
|
+
validatedCheckpoint = targetCheckpoint;
|
|
422
|
+
}
|
|
423
|
+
else if (isStreamingSyncCheckpointPartiallyComplete(line)) {
|
|
424
|
+
const priority = line.partial_checkpoint_complete.priority;
|
|
425
|
+
this.logger.debug('Partial checkpoint complete', priority);
|
|
426
|
+
const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint, priority);
|
|
378
427
|
if (!result.checkpointValid) {
|
|
379
428
|
// This means checksums failed. Start again with a new checkpoint.
|
|
380
429
|
// TODO: better back-off
|
|
381
430
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
382
|
-
return
|
|
431
|
+
return;
|
|
383
432
|
}
|
|
384
433
|
else if (!result.ready) {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// landing here the whole time
|
|
434
|
+
// If we have pending uploads, we can't complete new checkpoints outside of priority 0.
|
|
435
|
+
// We'll resolve this for a complete checkpoint.
|
|
388
436
|
}
|
|
389
437
|
else {
|
|
390
|
-
|
|
391
|
-
this.logger.debug('
|
|
438
|
+
// We'll keep on downloading, but can report that this priority is synced now.
|
|
439
|
+
this.logger.debug('partial checkpoint validation succeeded');
|
|
440
|
+
// All states with a higher priority can be deleted since this partial sync includes them.
|
|
441
|
+
const priorityStates = this.syncStatus.priorityStatusEntries.filter((s) => s.priority <= priority);
|
|
442
|
+
priorityStates.push({
|
|
443
|
+
priority,
|
|
444
|
+
lastSyncedAt: new Date(),
|
|
445
|
+
hasSynced: true
|
|
446
|
+
});
|
|
392
447
|
this.updateSyncStatus({
|
|
393
448
|
connected: true,
|
|
394
|
-
|
|
395
|
-
dataFlow: {
|
|
396
|
-
downloading: false
|
|
397
|
-
}
|
|
449
|
+
priorityStatusEntries: priorityStates
|
|
398
450
|
});
|
|
399
451
|
}
|
|
400
|
-
validatedCheckpoint = targetCheckpoint;
|
|
401
452
|
}
|
|
402
453
|
else if (isStreamingSyncCheckpointDiff(line)) {
|
|
403
454
|
// TODO: It may be faster to just keep track of the diff, instead of the entire checkpoint
|
|
@@ -421,7 +472,11 @@ The next upload iteration will be delayed.`);
|
|
|
421
472
|
write_checkpoint: diff.write_checkpoint
|
|
422
473
|
};
|
|
423
474
|
targetCheckpoint = newCheckpoint;
|
|
424
|
-
|
|
475
|
+
bucketMap = new Map();
|
|
476
|
+
newBuckets.forEach((checksum, name) => bucketMap.set(name, {
|
|
477
|
+
name: checksum.bucket,
|
|
478
|
+
priority: checksum.priority ?? FALLBACK_PRIORITY
|
|
479
|
+
}));
|
|
425
480
|
const bucketsToDelete = diff.removed_buckets;
|
|
426
481
|
if (bucketsToDelete.length > 0) {
|
|
427
482
|
this.logger.debug('Remove buckets', bucketsToDelete);
|
|
@@ -448,7 +503,7 @@ The next upload iteration will be delayed.`);
|
|
|
448
503
|
* (uses the same one), this should have some delay.
|
|
449
504
|
*/
|
|
450
505
|
await this.delayRetry();
|
|
451
|
-
return
|
|
506
|
+
return;
|
|
452
507
|
}
|
|
453
508
|
this.triggerCrudUpload();
|
|
454
509
|
}
|
|
@@ -457,40 +512,69 @@ The next upload iteration will be delayed.`);
|
|
|
457
512
|
if (targetCheckpoint === appliedCheckpoint) {
|
|
458
513
|
this.updateSyncStatus({
|
|
459
514
|
connected: true,
|
|
460
|
-
lastSyncedAt: new Date()
|
|
515
|
+
lastSyncedAt: new Date(),
|
|
516
|
+
priorityStatusEntries: [],
|
|
517
|
+
dataFlow: {
|
|
518
|
+
downloadError: undefined
|
|
519
|
+
}
|
|
461
520
|
});
|
|
462
521
|
}
|
|
463
522
|
else if (validatedCheckpoint === targetCheckpoint) {
|
|
464
|
-
const result = await this.
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
// TODO: better back-off
|
|
468
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
469
|
-
return { retry: false };
|
|
523
|
+
const result = await this.applyCheckpoint(targetCheckpoint, signal);
|
|
524
|
+
if (result.endIteration) {
|
|
525
|
+
return;
|
|
470
526
|
}
|
|
471
|
-
else if (
|
|
472
|
-
// Checksums valid, but need more data for a consistent checkpoint.
|
|
473
|
-
// Continue waiting.
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
527
|
+
else if (result.applied) {
|
|
476
528
|
appliedCheckpoint = targetCheckpoint;
|
|
477
|
-
this.updateSyncStatus({
|
|
478
|
-
connected: true,
|
|
479
|
-
lastSyncedAt: new Date(),
|
|
480
|
-
dataFlow: {
|
|
481
|
-
downloading: false
|
|
482
|
-
}
|
|
483
|
-
});
|
|
484
529
|
}
|
|
485
530
|
}
|
|
486
531
|
}
|
|
487
532
|
}
|
|
488
533
|
this.logger.debug('Stream input empty');
|
|
489
534
|
// Connection closed. Likely due to auth issue.
|
|
490
|
-
return
|
|
535
|
+
return;
|
|
491
536
|
}
|
|
492
537
|
});
|
|
493
538
|
}
|
|
539
|
+
async applyCheckpoint(checkpoint, abort) {
|
|
540
|
+
let result = await this.options.adapter.syncLocalDatabase(checkpoint);
|
|
541
|
+
const pending = this.pendingCrudUpload;
|
|
542
|
+
if (!result.checkpointValid) {
|
|
543
|
+
this.logger.debug('Checksum mismatch in checkpoint, will reconnect');
|
|
544
|
+
// This means checksums failed. Start again with a new checkpoint.
|
|
545
|
+
// TODO: better back-off
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
547
|
+
return { applied: false, endIteration: true };
|
|
548
|
+
}
|
|
549
|
+
else if (!result.ready && pending != null) {
|
|
550
|
+
// We have pending entries in the local upload queue or are waiting to confirm a write
|
|
551
|
+
// checkpoint, which prevented this checkpoint from applying. Wait for that to complete and
|
|
552
|
+
// try again.
|
|
553
|
+
this.logger.debug('Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying.');
|
|
554
|
+
await Promise.race([pending, onAbortPromise(abort)]);
|
|
555
|
+
if (abort.aborted) {
|
|
556
|
+
return { applied: false, endIteration: true };
|
|
557
|
+
}
|
|
558
|
+
// Try again now that uploads have completed.
|
|
559
|
+
result = await this.options.adapter.syncLocalDatabase(checkpoint);
|
|
560
|
+
}
|
|
561
|
+
if (result.checkpointValid && result.ready) {
|
|
562
|
+
this.logger.debug('validated checkpoint', checkpoint);
|
|
563
|
+
this.updateSyncStatus({
|
|
564
|
+
connected: true,
|
|
565
|
+
lastSyncedAt: new Date(),
|
|
566
|
+
dataFlow: {
|
|
567
|
+
downloading: false,
|
|
568
|
+
downloadError: undefined
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
return { applied: true, endIteration: false };
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
this.logger.debug('Could not apply checkpoint. Waiting for next sync complete line.');
|
|
575
|
+
return { applied: false, endIteration: false };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
494
578
|
updateSyncStatus(options) {
|
|
495
579
|
const updatedStatus = new SyncStatus({
|
|
496
580
|
connected: options.connected ?? this.syncStatus.connected,
|
|
@@ -499,7 +583,8 @@ The next upload iteration will be delayed.`);
|
|
|
499
583
|
dataFlow: {
|
|
500
584
|
...this.syncStatus.dataFlowStatus,
|
|
501
585
|
...options.dataFlow
|
|
502
|
-
}
|
|
586
|
+
},
|
|
587
|
+
priorityStatusEntries: options.priorityStatusEntries ?? this.syncStatus.priorityStatusEntries
|
|
503
588
|
});
|
|
504
589
|
if (!this.syncStatus.isEqual(updatedStatus)) {
|
|
505
590
|
this.syncStatus = updatedStatus;
|
|
@@ -90,11 +90,17 @@ export interface StreamingSyncCheckpointComplete {
|
|
|
90
90
|
last_op_id: OpId;
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
export interface StreamingSyncCheckpointPartiallyComplete {
|
|
94
|
+
partial_checkpoint_complete: {
|
|
95
|
+
priority: number;
|
|
96
|
+
last_op_id: OpId;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
93
99
|
export interface StreamingSyncKeepalive {
|
|
94
100
|
/** If specified, token expires in this many seconds. */
|
|
95
101
|
token_expires_in: number;
|
|
96
102
|
}
|
|
97
|
-
export type StreamingSyncLine = StreamingSyncDataJSON | StreamingSyncCheckpoint | StreamingSyncCheckpointDiff | StreamingSyncCheckpointComplete | StreamingSyncKeepalive;
|
|
103
|
+
export type StreamingSyncLine = StreamingSyncDataJSON | StreamingSyncCheckpoint | StreamingSyncCheckpointDiff | StreamingSyncCheckpointComplete | StreamingSyncCheckpointPartiallyComplete | StreamingSyncKeepalive;
|
|
98
104
|
export interface BucketRequest {
|
|
99
105
|
name: string;
|
|
100
106
|
/**
|
|
@@ -106,6 +112,7 @@ export declare function isStreamingSyncData(line: StreamingSyncLine): line is St
|
|
|
106
112
|
export declare function isStreamingKeepalive(line: StreamingSyncLine): line is StreamingSyncKeepalive;
|
|
107
113
|
export declare function isStreamingSyncCheckpoint(line: StreamingSyncLine): line is StreamingSyncCheckpoint;
|
|
108
114
|
export declare function isStreamingSyncCheckpointComplete(line: StreamingSyncLine): line is StreamingSyncCheckpointComplete;
|
|
115
|
+
export declare function isStreamingSyncCheckpointPartiallyComplete(line: StreamingSyncLine): line is StreamingSyncCheckpointPartiallyComplete;
|
|
109
116
|
export declare function isStreamingSyncCheckpointDiff(line: StreamingSyncLine): line is StreamingSyncCheckpointDiff;
|
|
110
117
|
export declare function isContinueCheckpointRequest(request: SyncRequest): request is ContinueCheckpointRequest;
|
|
111
118
|
export declare function isSyncNewCheckpointRequest(request: SyncRequest): request is SyncNewCheckpointRequest;
|
|
@@ -10,6 +10,9 @@ export function isStreamingSyncCheckpoint(line) {
|
|
|
10
10
|
export function isStreamingSyncCheckpointComplete(line) {
|
|
11
11
|
return line.checkpoint_complete != null;
|
|
12
12
|
}
|
|
13
|
+
export function isStreamingSyncCheckpointPartiallyComplete(line) {
|
|
14
|
+
return line.partial_checkpoint_complete != null;
|
|
15
|
+
}
|
|
13
16
|
export function isStreamingSyncCheckpointDiff(line) {
|
|
14
17
|
return line.checkpoint_diff != null;
|
|
15
18
|
}
|
package/lib/db/DBAdapter.d.ts
CHANGED
|
@@ -39,6 +39,21 @@ export interface DBGetUtils {
|
|
|
39
39
|
export interface LockContext extends DBGetUtils {
|
|
40
40
|
/** Execute a single write statement. */
|
|
41
41
|
execute: (query: string, params?: any[] | undefined) => Promise<QueryResult>;
|
|
42
|
+
/**
|
|
43
|
+
* Execute a single write statement and return raw results.
|
|
44
|
+
* Unlike `execute`, which returns an object with structured key-value pairs,
|
|
45
|
+
* `executeRaw` returns a nested array of raw values, where each row is
|
|
46
|
+
* represented as an array of column values without field names.
|
|
47
|
+
*
|
|
48
|
+
* Example result:
|
|
49
|
+
*
|
|
50
|
+
* ```[ [ '1', 'list 1', '33', 'Post content', '1' ] ]```
|
|
51
|
+
*
|
|
52
|
+
* Where as `execute`'s `rows._array` would have been:
|
|
53
|
+
*
|
|
54
|
+
* ```[ { id: '33', name: 'list 1', content: 'Post content', list_id: '1' } ]```
|
|
55
|
+
*/
|
|
56
|
+
executeRaw: (query: string, params?: any[] | undefined) => Promise<any[][]>;
|
|
42
57
|
}
|
|
43
58
|
export interface Transaction extends LockContext {
|
|
44
59
|
/** Commit multiple changes to the local DB using the Transaction context. */
|
|
@@ -82,8 +97,9 @@ export interface DBLockOptions {
|
|
|
82
97
|
timeoutMs?: number;
|
|
83
98
|
}
|
|
84
99
|
export interface DBAdapter extends BaseObserverInterface<DBAdapterListener>, DBGetUtils {
|
|
85
|
-
close: () => void
|
|
100
|
+
close: () => void | Promise<void>;
|
|
86
101
|
execute: (query: string, params?: any[]) => Promise<QueryResult>;
|
|
102
|
+
executeRaw: (query: string, params?: any[]) => Promise<any[][]>;
|
|
87
103
|
executeBatch: (query: string, params?: any[][]) => Promise<QueryResult>;
|
|
88
104
|
name: string;
|
|
89
105
|
readLock: <T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions) => Promise<T>;
|