@powersync/service-core 1.23.0 → 1.23.1
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/CHANGELOG.md +6 -0
- package/dist/replication/AbstractReplicator.d.ts +12 -1
- package/dist/replication/AbstractReplicator.js +106 -47
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/endpoints/admin.js +2 -1
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.d.ts +3 -0
- package/dist/storage/PersistedSyncConfigContent.js +2 -0
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/package.json +1 -1
- package/src/replication/AbstractReplicator.ts +126 -47
- package/src/routes/endpoints/admin.ts +2 -1
- package/src/storage/PersistedSyncConfigContent.ts +4 -0
- package/test/src/diagnostics.test.ts +13 -12
- package/test/src/routes/admin.test.ts +12 -10
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { container, ErrorCode, logger } from '@powersync/lib-services-framework';
|
|
2
2
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
3
3
|
import { hrtime } from 'node:process';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
5
|
import winston from 'winston';
|
|
5
6
|
import { MetricsEngine } from '../metrics/MetricsEngine.js';
|
|
6
7
|
import * as storage from '../storage/storage-index.js';
|
|
@@ -13,6 +14,12 @@ import { ConnectionTestResult } from './ReplicationModule.js';
|
|
|
13
14
|
// 1 minute
|
|
14
15
|
const PING_INTERVAL = 1_000_000_000n * 60n;
|
|
15
16
|
|
|
17
|
+
// In the initial startup period, we use a short refresh interval. This helps to take over replication quickly in the case
|
|
18
|
+
// of rolling deploys. After the initial period, we switch to a longer refresh interval to reduce load.
|
|
19
|
+
const FAST_REFRESH_INTERVAL_MS = 500;
|
|
20
|
+
const FAST_REFRESH_TIMEOUT_MS = 120_000;
|
|
21
|
+
const REFRESH_INTERVAL_MS = 5_000;
|
|
22
|
+
|
|
16
23
|
export interface CreateJobOptions {
|
|
17
24
|
lock: storage.ReplicationLock;
|
|
18
25
|
storage: storage.SyncRulesBucketStorage;
|
|
@@ -36,7 +43,7 @@ export interface AbstractReplicatorOptions {
|
|
|
36
43
|
*/
|
|
37
44
|
export abstract class AbstractReplicator<T extends AbstractReplicationJob = AbstractReplicationJob> {
|
|
38
45
|
protected logger: winston.Logger;
|
|
39
|
-
private
|
|
46
|
+
private lastReplicationStreamInfoLogs = new Map<string, string>();
|
|
40
47
|
/**
|
|
41
48
|
* Map of replication jobs by replication stream job id. Usually there is only one running job, but there could be two
|
|
42
49
|
* when transitioning to a new replication stream.
|
|
@@ -131,17 +138,20 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
private async runLoop() {
|
|
134
|
-
const
|
|
141
|
+
const loadedSyncConfig = await this.syncRuleProvider.get();
|
|
135
142
|
|
|
136
143
|
let configuredLock: storage.ReplicationLock | undefined = undefined;
|
|
137
|
-
if (
|
|
144
|
+
if (loadedSyncConfig != null) {
|
|
138
145
|
this.logger.info('Loaded sync config');
|
|
139
146
|
try {
|
|
140
147
|
// Configure new sync config, if they have changed.
|
|
141
148
|
// In that case, also immediately take out a lock, so that another process doesn't start replication on it.
|
|
149
|
+
// This upfront lock is mostly superseded by the replicationStreamLoadedSyncConfigMatch() check. However, that doesn't cover old
|
|
150
|
+
// versions before that check was added, so we keep the lock for now - for where te service version and sync config is updated at
|
|
151
|
+
// the same time.
|
|
142
152
|
|
|
143
153
|
const { lock } = await this.storage.configureSyncRules(
|
|
144
|
-
storage.updateSyncRulesFromYaml(
|
|
154
|
+
storage.updateSyncRulesFromYaml(loadedSyncConfig, { lock: true, validate: this.syncRuleProvider.exitOnError })
|
|
145
155
|
);
|
|
146
156
|
if (lock) {
|
|
147
157
|
configuredLock = lock;
|
|
@@ -155,10 +165,15 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
155
165
|
} else {
|
|
156
166
|
this.logger.info('No sync streams or rules configured - configure via API');
|
|
157
167
|
}
|
|
168
|
+
let useFastRefresh = true;
|
|
169
|
+
const fastRefreshDeadline = Date.now() + FAST_REFRESH_TIMEOUT_MS;
|
|
158
170
|
while (!this.stopped) {
|
|
159
171
|
await container.probes.touch();
|
|
160
172
|
try {
|
|
161
|
-
await this.refresh({
|
|
173
|
+
const refreshResult = await this.refresh({ configuredLock, loadedSyncConfig });
|
|
174
|
+
if (refreshResult.replicationJobStarted || Date.now() >= fastRefreshDeadline) {
|
|
175
|
+
useFastRefresh = false;
|
|
176
|
+
}
|
|
162
177
|
// The lock is only valid on the first refresh.
|
|
163
178
|
configuredLock = undefined;
|
|
164
179
|
|
|
@@ -177,24 +192,40 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
177
192
|
} catch (e) {
|
|
178
193
|
this.logger.error('Failed to refresh replication jobs', e);
|
|
179
194
|
}
|
|
180
|
-
|
|
195
|
+
if (Date.now() >= fastRefreshDeadline) {
|
|
196
|
+
useFastRefresh = false;
|
|
197
|
+
}
|
|
198
|
+
await sleep(useFastRefresh ? FAST_REFRESH_INTERVAL_MS : REFRESH_INTERVAL_MS);
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
|
|
184
|
-
private async refresh(options?: {
|
|
202
|
+
private async refresh(options?: {
|
|
203
|
+
configuredLock?: storage.ReplicationLock;
|
|
204
|
+
loadedSyncConfig?: string;
|
|
205
|
+
}): Promise<{ replicationJobStarted: boolean }> {
|
|
185
206
|
if (this.stopped) {
|
|
186
|
-
return;
|
|
207
|
+
return { replicationJobStarted: false };
|
|
187
208
|
}
|
|
188
209
|
|
|
189
|
-
let configuredLock = options?.
|
|
210
|
+
let configuredLock = options?.configuredLock;
|
|
211
|
+
let replicationJobStarted = false;
|
|
190
212
|
|
|
191
213
|
const existingJobs = new Map<string, T>(this.replicationJobs.entries());
|
|
192
214
|
const replicatingStreams = await this.storage.getReplicatingReplicationStreams();
|
|
193
215
|
const newJobs = new Map<string, T>();
|
|
216
|
+
const streamsToStart: storage.PersistedReplicationStream[] = [];
|
|
194
217
|
let activeJob: T | undefined = undefined;
|
|
195
218
|
for (let replicationStream of replicatingStreams) {
|
|
196
219
|
const jobId = replicationStream.replicationJobId;
|
|
197
220
|
const existingJob = existingJobs.get(jobId);
|
|
221
|
+
const syncConfigMismatchMessage = 'Ignoring replication stream for sync config not loaded by this process';
|
|
222
|
+
if (!this.shouldHandleReplicationStream(replicationStream, options?.loadedSyncConfig)) {
|
|
223
|
+
this.logReplicationStreamInfoOnce(replicationStream, 'sync-config-mismatch', () => {
|
|
224
|
+
replicationStream.logger.info(syncConfigMismatchMessage);
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
this.clearReplicationStreamInfoLog(replicationStream, 'sync-config-mismatch');
|
|
198
229
|
if (replicationStream.state == storage.SyncRuleState.ACTIVE && activeJob == null) {
|
|
199
230
|
activeJob = existingJob;
|
|
200
231
|
}
|
|
@@ -208,47 +239,12 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
208
239
|
existingJobs.delete(jobId);
|
|
209
240
|
} else {
|
|
210
241
|
// New sync config was found (or resume after restart)
|
|
211
|
-
|
|
212
|
-
let lock: storage.ReplicationLock;
|
|
213
|
-
if (configuredLock?.sync_rules_id == replicationStream.replicationStreamId) {
|
|
214
|
-
lock = configuredLock;
|
|
215
|
-
} else {
|
|
216
|
-
lock = await replicationStream.lock();
|
|
217
|
-
}
|
|
218
|
-
const syncRuleStorage = this.storage.getInstance(replicationStream);
|
|
219
|
-
const newJob = this.createJob({
|
|
220
|
-
lock: lock,
|
|
221
|
-
storage: syncRuleStorage
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
newJobs.set(jobId, newJob);
|
|
225
|
-
newJob.start();
|
|
226
|
-
if (replicationStream.state == storage.SyncRuleState.ACTIVE) {
|
|
227
|
-
activeJob = newJob;
|
|
228
|
-
}
|
|
229
|
-
this.lockAlerted = false;
|
|
230
|
-
} catch (e) {
|
|
231
|
-
if (e?.errorData?.code === ErrorCode.PSYNC_S1003) {
|
|
232
|
-
if (!this.lockAlerted) {
|
|
233
|
-
replicationStream.logger.info(`[${e.errorData.code}] ${e.errorData.description}`);
|
|
234
|
-
this.lockAlerted = true;
|
|
235
|
-
}
|
|
236
|
-
} else {
|
|
237
|
-
// Could be a sync config parse error,
|
|
238
|
-
// for example from stricter validation that was added.
|
|
239
|
-
// This will be retried every couple of seconds.
|
|
240
|
-
// When new (valid) sync config is deployed and processed, this one be disabled.
|
|
241
|
-
replicationStream.logger.error('Failed to start replication for new sync config', e);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
242
|
+
streamsToStart.push(replicationStream);
|
|
244
243
|
}
|
|
245
244
|
}
|
|
246
245
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// Stop any orphaned jobs that no longer have a replication stream.
|
|
251
|
-
// Termination happens below
|
|
246
|
+
// Stop any orphaned jobs that no longer have a replication stream before starting replacements.
|
|
247
|
+
// Termination happens below.
|
|
252
248
|
for (let job of existingJobs.values()) {
|
|
253
249
|
// Old - stop and clean up
|
|
254
250
|
try {
|
|
@@ -259,6 +255,46 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
259
255
|
}
|
|
260
256
|
}
|
|
261
257
|
|
|
258
|
+
for (let replicationStream of streamsToStart) {
|
|
259
|
+
const jobId = replicationStream.replicationJobId;
|
|
260
|
+
try {
|
|
261
|
+
let lock: storage.ReplicationLock;
|
|
262
|
+
if (configuredLock?.sync_rules_id == replicationStream.replicationStreamId) {
|
|
263
|
+
lock = configuredLock;
|
|
264
|
+
} else {
|
|
265
|
+
lock = await replicationStream.lock();
|
|
266
|
+
}
|
|
267
|
+
const syncRuleStorage = this.storage.getInstance(replicationStream);
|
|
268
|
+
const newJob = this.createJob({
|
|
269
|
+
lock: lock,
|
|
270
|
+
storage: syncRuleStorage
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
newJobs.set(jobId, newJob);
|
|
274
|
+
newJob.start();
|
|
275
|
+
replicationJobStarted = true;
|
|
276
|
+
if (replicationStream.state == storage.SyncRuleState.ACTIVE) {
|
|
277
|
+
activeJob = newJob;
|
|
278
|
+
}
|
|
279
|
+
this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
if (e?.errorData?.code === ErrorCode.PSYNC_S1003) {
|
|
282
|
+
this.logReplicationStreamInfoOnce(replicationStream, 'replication-stream-locked', () => {
|
|
283
|
+
replicationStream.logger.info(`[${e.errorData.code}] ${e.errorData.description}`);
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
// Could be a sync config parse error,
|
|
287
|
+
// for example from stricter validation that was added.
|
|
288
|
+
// This will be retried every couple of seconds.
|
|
289
|
+
// When new (valid) sync config is deployed and processed, this one be disabled.
|
|
290
|
+
replicationStream.logger.error('Failed to start replication for new sync config', e);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.replicationJobs = newJobs;
|
|
296
|
+
this.activeReplicationJob = activeJob;
|
|
297
|
+
|
|
262
298
|
// Replication stream stopped previously, including by a different process.
|
|
263
299
|
const stopped = await this.storage.getStoppedReplicationStreams();
|
|
264
300
|
for (let replicationStream of stopped) {
|
|
@@ -280,6 +316,49 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
280
316
|
});
|
|
281
317
|
this.clearingJobs.set(replicationStream.replicationStreamId, promise);
|
|
282
318
|
}
|
|
319
|
+
|
|
320
|
+
return { replicationJobStarted };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private logReplicationStreamInfoOnce(
|
|
324
|
+
replicationStream: storage.PersistedReplicationStream,
|
|
325
|
+
category: string,
|
|
326
|
+
log: () => void
|
|
327
|
+
) {
|
|
328
|
+
if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
log();
|
|
333
|
+
this.lastReplicationStreamInfoLogs.set(replicationStream.replicationStreamName, category);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private clearReplicationStreamInfoLog(replicationStream: storage.PersistedReplicationStream, category: string) {
|
|
337
|
+
if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
|
|
338
|
+
this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* When we load sync config from the filesystem/config source, we ignore any config loaded by other processes.
|
|
344
|
+
* The idea is that if a different process loads updated config, that process should process it, not this one.
|
|
345
|
+
*
|
|
346
|
+
* This specifically helps for cases with rolling deploys.
|
|
347
|
+
*
|
|
348
|
+
* This does not apply when sync config is loaded from the database/API, instead of in the config.
|
|
349
|
+
*/
|
|
350
|
+
private shouldHandleReplicationStream(
|
|
351
|
+
replicationStream: storage.PersistedReplicationStream,
|
|
352
|
+
loadedSyncRules: string | undefined
|
|
353
|
+
) {
|
|
354
|
+
if (loadedSyncRules == null) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const processingConfig = replicationStream.syncConfigContent.find(
|
|
359
|
+
(syncConfig) => syncConfig.syncConfigState == storage.SyncRuleState.PROCESSING
|
|
360
|
+
);
|
|
361
|
+
return processingConfig == null || processingConfig.sync_rules_content == loadedSyncRules;
|
|
283
362
|
}
|
|
284
363
|
|
|
285
364
|
protected createJobId(syncRuleId: number) {
|
|
@@ -241,7 +241,8 @@ export const validate = routeDefinition({
|
|
|
241
241
|
replicationStreamName: '',
|
|
242
242
|
storageVersion: storage.LEGACY_STORAGE_VERSION,
|
|
243
243
|
sync_rules_content: content,
|
|
244
|
-
compiled_plan: null
|
|
244
|
+
compiled_plan: null,
|
|
245
|
+
syncConfigState: storage.SyncRuleState.PROCESSING
|
|
245
246
|
});
|
|
246
247
|
|
|
247
248
|
const connectionStatus = await apiHandler.getConnectionStatus();
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '@powersync/service-sync-rules';
|
|
18
18
|
import * as sqlite from 'node:sqlite';
|
|
19
19
|
import { Logger } from 'winston';
|
|
20
|
+
import { SyncRuleState } from './BucketStorage.js';
|
|
20
21
|
import { SerializedSyncPlan, UpdateSyncRulesOptions } from './BucketStorageFactory.js';
|
|
21
22
|
import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
|
|
22
23
|
import { PersistedSyncConfigStatus } from './PersistedSyncConfigStatus.js';
|
|
@@ -91,6 +92,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
|
|
|
91
92
|
readonly storageVersion: number;
|
|
92
93
|
readonly logger: Logger;
|
|
93
94
|
readonly syncConfigId: PersistedSyncConfigId | null;
|
|
95
|
+
readonly syncConfigState: SyncRuleState;
|
|
94
96
|
|
|
95
97
|
constructor(data: PersistedSyncConfigContentData) {
|
|
96
98
|
this.replicationStreamId = data.replicationStreamId;
|
|
@@ -99,6 +101,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
|
|
|
99
101
|
this.replicationStreamName = data.replicationStreamName;
|
|
100
102
|
this.storageVersion = data.storageVersion;
|
|
101
103
|
this.syncConfigId = data.syncConfigId ?? null;
|
|
104
|
+
this.syncConfigState = data.syncConfigState;
|
|
102
105
|
this.logger = defaultLogger.child({ prefix: `[${this.replicationStreamName}] ` });
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -181,6 +184,7 @@ export interface PersistedSyncConfigContentData {
|
|
|
181
184
|
readonly storageVersion: number;
|
|
182
185
|
|
|
183
186
|
readonly syncConfigId?: PersistedSyncConfigId | null;
|
|
187
|
+
readonly syncConfigState: SyncRuleState;
|
|
184
188
|
}
|
|
185
189
|
export type PersistedSyncConfigId = string;
|
|
186
190
|
export interface ParseSyncConfigOptions {
|
|
@@ -18,6 +18,17 @@ function makeSyncRulesContent(overrides?: {
|
|
|
18
18
|
status?: storage.PersistedSyncConfigStatus;
|
|
19
19
|
}): storage.PersistedSyncConfigContent {
|
|
20
20
|
// We don't implement the entire interface correctly here - just enough to test the diagnostics logic.
|
|
21
|
+
const status = overrides?.status ?? {
|
|
22
|
+
id: '1',
|
|
23
|
+
replicationStreamId: 1,
|
|
24
|
+
state: storage.SyncRuleState.ACTIVE,
|
|
25
|
+
last_checkpoint_lsn: 'some_lsn',
|
|
26
|
+
last_fatal_error: null,
|
|
27
|
+
last_fatal_error_ts: null,
|
|
28
|
+
last_keepalive_ts: new Date(),
|
|
29
|
+
last_checkpoint_ts: new Date()
|
|
30
|
+
};
|
|
31
|
+
|
|
21
32
|
return {
|
|
22
33
|
replicationStreamId: 1,
|
|
23
34
|
syncConfigId: null,
|
|
@@ -25,6 +36,7 @@ function makeSyncRulesContent(overrides?: {
|
|
|
25
36
|
sync_rules_content: MINIMAL_SYNC_RULES,
|
|
26
37
|
compiled_plan: null,
|
|
27
38
|
storageVersion: 1,
|
|
39
|
+
syncConfigState: status.state,
|
|
28
40
|
parsed(options?: any) {
|
|
29
41
|
const syncRules = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
|
|
30
42
|
...options,
|
|
@@ -38,18 +50,7 @@ function makeSyncRulesContent(overrides?: {
|
|
|
38
50
|
asUpdateOptions: null as any,
|
|
39
51
|
getStorageConfig: null as any,
|
|
40
52
|
async getSyncConfigStatus() {
|
|
41
|
-
return
|
|
42
|
-
overrides?.status ?? {
|
|
43
|
-
id: '1',
|
|
44
|
-
replicationStreamId: 1,
|
|
45
|
-
state: storage.SyncRuleState.ACTIVE,
|
|
46
|
-
last_checkpoint_lsn: 'some_lsn',
|
|
47
|
-
last_fatal_error: null,
|
|
48
|
-
last_fatal_error_ts: null,
|
|
49
|
-
last_keepalive_ts: new Date(),
|
|
50
|
-
last_checkpoint_ts: new Date()
|
|
51
|
-
}
|
|
52
|
-
);
|
|
53
|
+
return status;
|
|
53
54
|
}
|
|
54
55
|
} as storage.PersistedSyncConfigContent;
|
|
55
56
|
}
|
|
@@ -41,6 +41,16 @@ describe('admin routes', () => {
|
|
|
41
41
|
const state = active ? storage.SyncRuleState.ACTIVE : storage.SyncRuleState.PROCESSING;
|
|
42
42
|
const lastKeepaliveTs = new Date('2026-01-01T00:00:00.000Z');
|
|
43
43
|
const lastCheckpointTs = new Date('2026-01-01T00:00:00.000Z');
|
|
44
|
+
const syncConfigStatus = {
|
|
45
|
+
id: syncConfigId,
|
|
46
|
+
replicationStreamId: id,
|
|
47
|
+
state,
|
|
48
|
+
last_checkpoint_lsn: null,
|
|
49
|
+
last_fatal_error: null,
|
|
50
|
+
last_fatal_error_ts: null,
|
|
51
|
+
last_keepalive_ts: lastKeepaliveTs,
|
|
52
|
+
last_checkpoint_ts: lastCheckpointTs
|
|
53
|
+
};
|
|
44
54
|
const content = {
|
|
45
55
|
replicationStreamId: id,
|
|
46
56
|
syncConfigId,
|
|
@@ -55,6 +65,7 @@ bucket_definitions:
|
|
|
55
65
|
`,
|
|
56
66
|
compiled_plan: null,
|
|
57
67
|
storageVersion: storage.LEGACY_STORAGE_VERSION,
|
|
68
|
+
syncConfigState: syncConfigStatus.state,
|
|
58
69
|
parsed(options?: any) {
|
|
59
70
|
const syncRules = SqlSyncRules.fromYaml(content.sync_rules_content, {
|
|
60
71
|
...options,
|
|
@@ -67,16 +78,7 @@ bucket_definitions:
|
|
|
67
78
|
asUpdateOptions: vi.fn(),
|
|
68
79
|
getStorageConfig: vi.fn(),
|
|
69
80
|
async getSyncConfigStatus() {
|
|
70
|
-
return
|
|
71
|
-
id: syncConfigId,
|
|
72
|
-
replicationStreamId: id,
|
|
73
|
-
state,
|
|
74
|
-
last_checkpoint_lsn: null,
|
|
75
|
-
last_fatal_error: null,
|
|
76
|
-
last_fatal_error_ts: null,
|
|
77
|
-
last_keepalive_ts: lastKeepaliveTs,
|
|
78
|
-
last_checkpoint_ts: lastCheckpointTs
|
|
79
|
-
};
|
|
81
|
+
return syncConfigStatus;
|
|
80
82
|
}
|
|
81
83
|
};
|
|
82
84
|
return content as unknown as storage.PersistedSyncConfigContent;
|