@powersync/service-core 1.27.2 → 1.28.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 +36 -0
- package/LICENSE +201 -67
- package/dist/api/RouteAPI.d.ts +6 -0
- package/dist/api/api-metrics.js +4 -0
- package/dist/api/api-metrics.js.map +1 -1
- package/dist/api/diagnostics.d.ts +5 -0
- package/dist/api/diagnostics.js +17 -2
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/metrics/connection-metrics.d.ts +28 -0
- package/dist/metrics/connection-metrics.js +55 -0
- package/dist/metrics/connection-metrics.js.map +1 -0
- package/dist/metrics/metrics-index.d.ts +1 -0
- package/dist/metrics/metrics-index.js +1 -0
- package/dist/metrics/metrics-index.js.map +1 -1
- package/dist/metrics/metrics-interfaces.d.ts +7 -1
- package/dist/metrics/metrics-interfaces.js.map +1 -1
- package/dist/modules/loader.js +8 -2
- package/dist/modules/loader.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +2 -0
- package/dist/replication/AbstractReplicationJob.js +17 -1
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +8 -18
- package/dist/replication/AbstractReplicator.js +63 -64
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.js +0 -6
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.js +5 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.js +24 -22
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +19 -27
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +17 -40
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +25 -23
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/hooks.d.ts +1 -1
- package/dist/routes/hooks.js +2 -1
- package/dist/routes/hooks.js.map +1 -1
- package/dist/routes/sync-connection.d.ts +7 -0
- package/dist/routes/sync-connection.js +34 -0
- package/dist/routes/sync-connection.js.map +1 -0
- package/dist/storage/BucketStorageFactory.d.ts +30 -22
- package/dist/storage/BucketStorageFactory.js +14 -14
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.js +2 -2
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/storage-metrics.js +2 -1
- package/dist/storage/storage-metrics.js.map +1 -1
- package/dist/streams/BroadcastIterable.js +8 -7
- package/dist/streams/BroadcastIterable.js.map +1 -1
- package/dist/streams/LastValueSink.d.ts +2 -2
- package/dist/streams/LastValueSink.js +8 -13
- package/dist/streams/LastValueSink.js.map +1 -1
- package/dist/streams/merge.d.ts +15 -31
- package/dist/streams/merge.js +136 -152
- package/dist/streams/merge.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +3 -2
- package/dist/sync/BucketChecksumState.js +12 -5
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +48 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +1 -2
- package/dist/sync/util.js +2 -5
- package/dist/sync/util.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +7 -0
- package/dist/system/ServiceContext.js +3 -3
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/system/SyncConfigEngine.d.ts +135 -0
- package/dist/system/SyncConfigEngine.js +186 -0
- package/dist/system/SyncConfigEngine.js.map +1 -0
- package/dist/system/system-index.d.ts +1 -0
- package/dist/system/system-index.js +1 -0
- package/dist/system/system-index.js.map +1 -1
- package/dist/util/config/types.d.ts +4 -0
- package/package.json +8 -9
- package/src/api/RouteAPI.ts +6 -0
- package/src/api/api-metrics.ts +5 -0
- package/src/api/diagnostics.ts +22 -2
- package/src/metrics/connection-metrics.ts +75 -0
- package/src/metrics/metrics-index.ts +1 -0
- package/src/metrics/metrics-interfaces.ts +8 -1
- package/src/modules/loader.ts +16 -2
- package/src/replication/AbstractReplicationJob.ts +17 -1
- package/src/replication/AbstractReplicator.ts +83 -81
- package/src/replication/ReplicationModule.ts +0 -5
- package/src/routes/configure-fastify.ts +7 -1
- package/src/routes/endpoints/admin.ts +27 -20
- package/src/routes/endpoints/socket-route.ts +26 -38
- package/src/routes/endpoints/sync-rules.ts +22 -46
- package/src/routes/endpoints/sync-stream.ts +31 -27
- package/src/routes/hooks.ts +5 -1
- package/src/routes/sync-connection.ts +39 -0
- package/src/storage/BucketStorageFactory.ts +45 -29
- package/src/storage/PersistedSyncConfigContent.ts +2 -2
- package/src/storage/SyncRulesBucketStorage.ts +23 -2
- package/src/storage/storage-metrics.ts +2 -1
- package/src/streams/BroadcastIterable.ts +8 -7
- package/src/streams/LastValueSink.ts +10 -16
- package/src/streams/merge.ts +149 -169
- package/src/sync/BucketChecksumState.ts +17 -5
- package/src/sync/sync.ts +71 -57
- package/src/sync/util.ts +2 -7
- package/src/system/ServiceContext.ts +8 -3
- package/src/system/SyncConfigEngine.ts +247 -0
- package/src/system/system-index.ts +1 -0
- package/src/util/config/types.ts +4 -0
- package/test/src/AbstractReplicator.test.ts +375 -45
- package/test/src/PersistedSyncConfigContent.test.ts +5 -5
- package/test/src/SyncConfigEngine.test.ts +208 -0
- package/test/src/broadcast_iterable.test.ts +59 -31
- package/test/src/config.test.ts +65 -3
- package/test/src/connection-metrics-export.test.ts +76 -0
- package/test/src/connection-metrics.test.ts +184 -0
- package/test/src/diagnostics.test.ts +3 -3
- package/test/src/last_value_sink.test.ts +34 -0
- package/test/src/merge_iterable.test.ts +304 -55
- package/test/src/module-loader.test.ts +14 -1
- package/test/src/recording-metrics.ts +36 -0
- package/test/src/routes/admin.test.ts +2 -2
- package/test/src/routes/concurrency-limit.test.ts +108 -0
- package/test/src/routes/connection-metrics.test.ts +269 -0
- package/test/src/routes/invalid-config.test.ts +184 -0
- package/test/src/routes/mocks.ts +26 -13
- package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
- package/test/src/routes/stream.test.ts +200 -107
- package/test/src/routes/sync-rules.test.ts +19 -1
- package/test/src/source_table_config.test.ts +7 -7
- package/test/src/sync/BucketChecksumState.test.ts +10 -10
- package/test/src/sync/stream_response.test.ts +389 -0
- package/test/src/sync/util.test.ts +14 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { container, ErrorCode, logger, ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
2
|
-
import { DEFAULT_TAG, SqlSyncRules, type SyncConfig } from '@powersync/service-sync-rules';
|
|
3
2
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
4
3
|
import { hrtime } from 'node:process';
|
|
5
4
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
@@ -7,8 +6,8 @@ import winston from 'winston';
|
|
|
7
6
|
import { MetricsEngine } from '../metrics/MetricsEngine.js';
|
|
8
7
|
import * as storage from '../storage/storage-index.js';
|
|
9
8
|
import { StorageEngine } from '../storage/storage-index.js';
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
9
|
+
import { assertAcceptedSyncConfig, SyncConfigEngine, SyncConfigResultStatus } from '../system/SyncConfigEngine.js';
|
|
10
|
+
import { ResolvedPowerSyncConfig } from '../util/config/types.js';
|
|
12
11
|
import { AbstractReplicationJob } from './AbstractReplicationJob.js';
|
|
13
12
|
import { ErrorRateLimiter } from './ErrorRateLimiter.js';
|
|
14
13
|
import { ConnectionTestResult } from './ReplicationModule.js';
|
|
@@ -31,11 +30,11 @@ export interface AbstractReplicatorOptions {
|
|
|
31
30
|
id: string;
|
|
32
31
|
storageEngine: StorageEngine;
|
|
33
32
|
metricsEngine: MetricsEngine;
|
|
34
|
-
|
|
33
|
+
configuration: Pick<ResolvedPowerSyncConfig, 'sync_rules' | 'connections'>;
|
|
35
34
|
/**
|
|
36
|
-
*
|
|
35
|
+
* Context-owned admission engine shared with diagnostics; jobs use storage-owned parsed configs.
|
|
37
36
|
*/
|
|
38
|
-
|
|
37
|
+
syncConfigEngine: SyncConfigEngine;
|
|
39
38
|
/**
|
|
40
39
|
* This limits the effect of retries when there is a persistent issue.
|
|
41
40
|
*/
|
|
@@ -79,8 +78,6 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
79
78
|
|
|
80
79
|
private abortController: AbortController | undefined;
|
|
81
80
|
|
|
82
|
-
private assertSourceCapabilities: ((config: SyncConfig) => Promise<void>) | undefined;
|
|
83
|
-
|
|
84
81
|
protected constructor(private options: AbstractReplicatorOptions) {
|
|
85
82
|
this.logger = logger.child({ name: `Replicator:${options.id}` });
|
|
86
83
|
const heartbeatIntervalSeconds = options.heartbeatIntervalSeconds ?? DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
|
|
@@ -109,17 +106,6 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
109
106
|
return this.options.storageEngine.activeBucketStorage;
|
|
110
107
|
}
|
|
111
108
|
|
|
112
|
-
protected get syncRuleProvider() {
|
|
113
|
-
return this.options.syncRuleProvider;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Default schema for startup sync config validation. Source modules with a known default should override this.
|
|
118
|
-
*/
|
|
119
|
-
protected get defaultSchema(): string {
|
|
120
|
-
return 'not_applicable';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
109
|
protected get rateLimiter() {
|
|
124
110
|
return this.options.rateLimiter;
|
|
125
111
|
}
|
|
@@ -132,13 +118,6 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
132
118
|
return this.abortController?.signal.aborted;
|
|
133
119
|
}
|
|
134
120
|
|
|
135
|
-
/**
|
|
136
|
-
* Register an assertion that blocks unsupported file-loaded sync configs before replication.
|
|
137
|
-
*/
|
|
138
|
-
public registerSourceCapabilitiesAssertion(assertion: (config: SyncConfig) => Promise<void>): void {
|
|
139
|
-
this.assertSourceCapabilities = assertion;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
121
|
public async start(): Promise<void> {
|
|
143
122
|
this.abortController = new AbortController();
|
|
144
123
|
this.runLoop().catch((e) => {
|
|
@@ -174,56 +153,42 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
174
153
|
}
|
|
175
154
|
|
|
176
155
|
private async runLoop() {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
connections:
|
|
194
|
-
this.options.configuration?.connections &&
|
|
195
|
-
Object.fromEntries(
|
|
196
|
-
this.options.configuration.connections.map((connection) => [
|
|
197
|
-
connection.tag ?? DEFAULT_TAG,
|
|
198
|
-
{ type: connection.type }
|
|
199
|
-
])
|
|
200
|
-
),
|
|
201
|
-
throwOnError: this.syncRuleProvider.exitOnError
|
|
202
|
-
});
|
|
203
|
-
storage.logSyncConfigErrors(parsed, this.logger);
|
|
204
|
-
try {
|
|
205
|
-
await this.assertSourceCapabilities?.(parsed.config);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
if (this.syncRuleProvider.exitOnError) throw error;
|
|
208
|
-
// Persist the config for diagnostics; the job-start check will prevent unsafe replication.
|
|
209
|
-
this.logger.error('Source capability validation failed for the sync config', error);
|
|
156
|
+
const settings = this.options.configuration.sync_rules;
|
|
157
|
+
const result = await this.options.syncConfigEngine.getConfigured();
|
|
158
|
+
if (result.status === SyncConfigResultStatus.UNAVAILABLE) {
|
|
159
|
+
throw result.error;
|
|
160
|
+
}
|
|
161
|
+
const loadedSyncConfig =
|
|
162
|
+
result.status === SyncConfigResultStatus.ACCEPTED || result.status === SyncConfigResultStatus.REJECTED
|
|
163
|
+
? result.content
|
|
164
|
+
: undefined;
|
|
165
|
+
const loadedSyncConfigInvalid = result.status === SyncConfigResultStatus.REJECTED;
|
|
166
|
+
let configuredLock: storage.ReplicationLock | undefined;
|
|
167
|
+
if (result.status === SyncConfigResultStatus.ACCEPTED || result.status === SyncConfigResultStatus.REJECTED) {
|
|
168
|
+
storage.logSyncConfigErrors(result.diagnostics, this.logger);
|
|
169
|
+
if (result.status === SyncConfigResultStatus.REJECTED) {
|
|
170
|
+
if (settings.exit_on_error) {
|
|
171
|
+
assertAcceptedSyncConfig(result);
|
|
210
172
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
);
|
|
217
|
-
if (
|
|
218
|
-
|
|
173
|
+
// A startup submission supersedes pending work even when its replacement is rejected.
|
|
174
|
+
// Do not repeat this on refresh: a later process startup is a new deployment.
|
|
175
|
+
await this.storage.stopProcessingSyncConfigs();
|
|
176
|
+
// Supersession may also have stopped recovery of an already-errored active config.
|
|
177
|
+
// Reuse the backend recovery path, which rechecks current active/pending work.
|
|
178
|
+
const active = await this.storage.getActiveSyncConfig();
|
|
179
|
+
if (active?.replicationStream.state === storage.SyncRuleState.ERRORED) {
|
|
180
|
+
await this.storage.restartReplication(active.replicationStream.replicationStreamId);
|
|
219
181
|
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
182
|
+
} else {
|
|
183
|
+
const { lock } = await this.storage.configureSyncRules({
|
|
184
|
+
config: result.compiled,
|
|
185
|
+
lock: true,
|
|
186
|
+
version_label: settings.version_label
|
|
187
|
+
});
|
|
188
|
+
configuredLock = lock;
|
|
224
189
|
}
|
|
225
190
|
} else {
|
|
226
|
-
this.logger.info('No sync config
|
|
191
|
+
this.logger.info('No sync config loaded - using stored deployments');
|
|
227
192
|
}
|
|
228
193
|
let useFastRefresh = true;
|
|
229
194
|
const fastRefreshDeadline = Date.now() + FAST_REFRESH_TIMEOUT_MS;
|
|
@@ -233,7 +198,8 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
233
198
|
const refreshResult = await this.refresh({
|
|
234
199
|
configuredLock,
|
|
235
200
|
loadedSyncConfig,
|
|
236
|
-
|
|
201
|
+
loadedSyncConfigInvalid,
|
|
202
|
+
loadedVersionLabel: settings.version_label
|
|
237
203
|
});
|
|
238
204
|
if (refreshResult.replicationJobStarted || Date.now() >= fastRefreshDeadline) {
|
|
239
205
|
useFastRefresh = false;
|
|
@@ -266,6 +232,7 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
266
232
|
private async refresh(options?: {
|
|
267
233
|
configuredLock?: storage.ReplicationLock;
|
|
268
234
|
loadedSyncConfig?: string;
|
|
235
|
+
loadedSyncConfigInvalid?: boolean;
|
|
269
236
|
loadedVersionLabel?: string;
|
|
270
237
|
}): Promise<{ replicationJobStarted: boolean }> {
|
|
271
238
|
if (this.stopped) {
|
|
@@ -285,7 +252,12 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
285
252
|
const existingJob = existingJobs.get(jobId);
|
|
286
253
|
const syncConfigMismatchMessage = 'Ignoring replication stream for sync config not loaded by this process';
|
|
287
254
|
if (
|
|
288
|
-
!this.shouldHandleReplicationStream(
|
|
255
|
+
!(await this.shouldHandleReplicationStream(
|
|
256
|
+
replicationStream,
|
|
257
|
+
options?.loadedSyncConfig,
|
|
258
|
+
options?.loadedVersionLabel,
|
|
259
|
+
options?.loadedSyncConfigInvalid
|
|
260
|
+
))
|
|
289
261
|
) {
|
|
290
262
|
this.logReplicationStreamInfoOnce(replicationStream, 'sync-config-mismatch', () => {
|
|
291
263
|
replicationStream.logger.info(syncConfigMismatchMessage);
|
|
@@ -415,29 +387,59 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
415
387
|
}
|
|
416
388
|
|
|
417
389
|
/**
|
|
418
|
-
* When we load sync config from the
|
|
390
|
+
* When we load sync config from the service configuration (file, Base64, or inline content), we ignore any config loaded by other processes.
|
|
419
391
|
* The idea is that if a different process loads updated config, that process should process it, not this one.
|
|
420
392
|
*
|
|
421
393
|
* This specifically helps for cases with rolling deploys.
|
|
422
394
|
*
|
|
395
|
+
* A rejected configured sync config may still run recovery processing matching the active config.
|
|
423
396
|
* This does not apply when sync config is loaded from the database/API, instead of in the config.
|
|
424
397
|
*/
|
|
425
|
-
|
|
398
|
+
protected async shouldHandleReplicationStream(
|
|
426
399
|
replicationStream: storage.PersistedReplicationStream,
|
|
427
400
|
loadedSyncRules: string | undefined,
|
|
428
|
-
loadedVersionLabel: string | undefined
|
|
401
|
+
loadedVersionLabel: string | undefined,
|
|
402
|
+
loadedSyncConfigInvalid = false
|
|
429
403
|
) {
|
|
430
404
|
if (loadedSyncRules == null) {
|
|
405
|
+
// Without loaded content, handle stored deployments regardless of their source.
|
|
431
406
|
return true;
|
|
432
407
|
}
|
|
433
408
|
|
|
409
|
+
// A local config was loaded; check pending deployment ownership below.
|
|
434
410
|
const processingConfig = replicationStream.syncConfigContent.find(
|
|
435
411
|
(syncConfig) => syncConfig.syncConfigState == storage.SyncRuleState.PROCESSING
|
|
436
412
|
);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
413
|
+
if (processingConfig == null) {
|
|
414
|
+
// There aren't any processing configs here, there may be active configs, so this deployment can handle the stream.
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Preserve ownership of matching deployments already stored by an older service, even
|
|
419
|
+
// if this version rejects their YAML. In incremental storage they may share a stream
|
|
420
|
+
// with the active config. We block new invalid deployments, not existing processing work.
|
|
421
|
+
if (
|
|
422
|
+
processingConfig.sync_rules_content == loadedSyncRules &&
|
|
423
|
+
processingConfig.version_label == loadedVersionLabel
|
|
424
|
+
) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (loadedSyncConfigInvalid) {
|
|
429
|
+
// Recovery and manual reprocessing may rebuild the active config while this process has
|
|
430
|
+
// rejected its configured sync config. Allow that rebuild, but not another instance's unrelated
|
|
431
|
+
// pending deployment. An ERRORED active config still identifies the content being recovered.
|
|
432
|
+
// Manual reprocessing can select a newer storage version, so match config identity only.
|
|
433
|
+
const active = await this.storage.getActiveSyncConfig();
|
|
434
|
+
return (
|
|
435
|
+
active != null &&
|
|
436
|
+
processingConfig.sync_rules_content == active.content.sync_rules_content &&
|
|
437
|
+
processingConfig.version_label == active.content.version_label
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Leave unrelated pending deployments to a worker that loaded them.
|
|
442
|
+
return false;
|
|
441
443
|
}
|
|
442
444
|
|
|
443
445
|
protected createJobId(syncRuleId: number) {
|
|
@@ -95,11 +95,6 @@ export abstract class ReplicationModule<TConfig extends types.configFile.DataSou
|
|
|
95
95
|
context.routerEngine.registerAPI(sourceAPI);
|
|
96
96
|
if (context.replicationEngine) {
|
|
97
97
|
const replicator = this.createReplicator(context);
|
|
98
|
-
if (sourceAPI.validateSourceCapabilities != null) {
|
|
99
|
-
// KLUDGE: Reuse the adapter validation for filesystem-loaded configs.
|
|
100
|
-
// A shared validation interface could replace this registration through the route adapter in the future.
|
|
101
|
-
replicator.registerSourceCapabilitiesAssertion((config) => api.assertSourceCapabilities(sourceAPI, config));
|
|
102
|
-
}
|
|
103
98
|
context.replicationEngine.register(replicator);
|
|
104
99
|
}
|
|
105
100
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
registerFastifyRoutes
|
|
7
7
|
} from './route-register.js';
|
|
8
8
|
|
|
9
|
+
import { recordSyncConnection, SyncCloseReason, SyncTransport } from '../metrics/connection-metrics.js';
|
|
9
10
|
import * as system from '../system/system-index.js';
|
|
10
11
|
|
|
11
12
|
import { ADMIN_ROUTES } from './endpoints/admin.js';
|
|
@@ -123,7 +124,12 @@ export function configureFastifyServer(server: fastify.FastifyInstance, options:
|
|
|
123
124
|
// Limit the active concurrent requests
|
|
124
125
|
childContext.addHook(
|
|
125
126
|
'onRequest',
|
|
126
|
-
createRequestQueueHook(routes.sync_stream?.queue_options ?? DEFAULT_ROUTE_OPTIONS.sync_stream.queue_options)
|
|
127
|
+
createRequestQueueHook(routes.sync_stream?.queue_options ?? DEFAULT_ROUTE_OPTIONS.sync_stream.queue_options, () =>
|
|
128
|
+
recordSyncConnection(service_context.metricsEngine, {
|
|
129
|
+
transport: SyncTransport.HttpStream,
|
|
130
|
+
closeReason: SyncCloseReason.ConcurrencyLimit
|
|
131
|
+
})
|
|
132
|
+
)
|
|
127
133
|
);
|
|
128
134
|
});
|
|
129
135
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as sqlite from 'node:sqlite';
|
|
2
|
+
import { assertAcceptedSyncConfig, assertConfiguredSyncConfigValid } from '../../system/SyncConfigEngine.js';
|
|
2
3
|
|
|
3
4
|
import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
|
|
4
5
|
import {
|
|
5
6
|
HydratedSyncConfig,
|
|
6
7
|
nodeSqlite,
|
|
8
|
+
parseSyncConfig,
|
|
7
9
|
SourceSchema,
|
|
8
|
-
SqlSyncRules,
|
|
9
10
|
StaticSchema
|
|
10
11
|
} from '@powersync/service-sync-rules';
|
|
11
12
|
import { internal_routes, ServiceInfo } from '@powersync/service-types';
|
|
@@ -67,11 +68,17 @@ export const diagnostics = routeDefinition({
|
|
|
67
68
|
|
|
68
69
|
const apiHandler = service_context.routerEngine.getAPI();
|
|
69
70
|
|
|
71
|
+
const rejectedConfig = api.getInvalidLocalSyncConfigStatus(
|
|
72
|
+
await service_context.syncConfigEngine.getConfigured(),
|
|
73
|
+
include_content,
|
|
74
|
+
service_context.configuration.sync_rules.version_label
|
|
75
|
+
);
|
|
70
76
|
const status = await apiHandler.getConnectionStatus();
|
|
71
77
|
if (!status) {
|
|
72
78
|
return internal_routes.DiagnosticsResponse.encode({
|
|
73
79
|
...serviceResponse,
|
|
74
|
-
connections: []
|
|
80
|
+
connections: [],
|
|
81
|
+
deploying_sync_rules: rejectedConfig
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -96,8 +103,12 @@ export const diagnostics = routeDefinition({
|
|
|
96
103
|
active?.storage
|
|
97
104
|
);
|
|
98
105
|
|
|
106
|
+
// Prefer validation errors from this instance's configured content: rejected configs are
|
|
107
|
+
// never persisted, so stored pending work may belong to an earlier deployment. Fall back
|
|
108
|
+
// to stored deployment status when there is no rejected config to report.
|
|
99
109
|
const deploying_status =
|
|
100
|
-
|
|
110
|
+
rejectedConfig ??
|
|
111
|
+
(deploying == null
|
|
101
112
|
? undefined
|
|
102
113
|
: await api.getSyncRulesStatus(
|
|
103
114
|
apiHandler,
|
|
@@ -109,7 +120,7 @@ export const diagnostics = routeDefinition({
|
|
|
109
120
|
active: false
|
|
110
121
|
},
|
|
111
122
|
deploying.storage
|
|
112
|
-
);
|
|
123
|
+
));
|
|
113
124
|
|
|
114
125
|
return internal_routes.DiagnosticsResponse.encode({
|
|
115
126
|
...serviceResponse,
|
|
@@ -161,6 +172,8 @@ export const reprocess = routeDefinition({
|
|
|
161
172
|
});
|
|
162
173
|
}
|
|
163
174
|
|
|
175
|
+
await assertConfiguredSyncConfigValid(service_context.syncConfigEngine);
|
|
176
|
+
|
|
164
177
|
const active = await activeBucketStorage.getActiveSyncConfig();
|
|
165
178
|
if (active == null) {
|
|
166
179
|
throw new errors.ServiceError({
|
|
@@ -173,23 +186,17 @@ export const reprocess = routeDefinition({
|
|
|
173
186
|
// 1. This always re-parses the source YAML. If there are changes to the Sync Streams compiler, that can affect the sync plan.
|
|
174
187
|
// 2. If the source does not set the storage version, this will update it do the current version.
|
|
175
188
|
// We can consider tweaking this behavior in the future.
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
schema: undefined,
|
|
179
|
-
// This sync config already passed validation. But if the config is not valid anymore due
|
|
180
|
-
// to a service change, we do want to report the error here.
|
|
181
|
-
throwOnError: true
|
|
182
|
-
});
|
|
183
|
-
storage.logSyncConfigErrors(parsed);
|
|
184
|
-
await api.assertSourceCapabilities(apiHandler, parsed.config);
|
|
185
|
-
|
|
186
|
-
const new_rules = await activeBucketStorage.updateSyncRules(
|
|
187
|
-
storage.updateSyncRulesFromConfig(parsed, {
|
|
188
|
-
version_label: active.content.version_label,
|
|
189
|
-
forceNewReplicationStream: true
|
|
190
|
-
})
|
|
189
|
+
const compiled = assertAcceptedSyncConfig(
|
|
190
|
+
await service_context.syncConfigEngine.validate(active.content.sync_rules_content)
|
|
191
191
|
);
|
|
192
192
|
|
|
193
|
+
storage.logSyncConfigErrors(compiled.parsed);
|
|
194
|
+
const new_rules = await activeBucketStorage.updateSyncRules({
|
|
195
|
+
config: compiled,
|
|
196
|
+
version_label: active.content.version_label,
|
|
197
|
+
forceNewReplicationStream: true
|
|
198
|
+
});
|
|
199
|
+
|
|
193
200
|
const baseConfig = await apiHandler.getSourceConfig();
|
|
194
201
|
|
|
195
202
|
return internal_routes.ReprocessResponse.encode({
|
|
@@ -214,7 +221,7 @@ class FakeSyncRulesContentForValidation extends storage.PersistedSyncConfigConte
|
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
parsed(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
|
|
217
|
-
const syncConfig =
|
|
224
|
+
const syncConfig = parseSyncConfig(this.sync_rules_content, {
|
|
218
225
|
...options,
|
|
219
226
|
schema: this.schema
|
|
220
227
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { errors, schema } from '@powersync/lib-services-framework';
|
|
2
2
|
|
|
3
3
|
import * as sync from '../../sync/sync-index.js';
|
|
4
4
|
import * as util from '../../util/util-index.js';
|
|
@@ -6,7 +6,14 @@ import { SocketRouteGenerator } from '../router-socket.js';
|
|
|
6
6
|
import { SyncRoutes } from './sync-stream.js';
|
|
7
7
|
|
|
8
8
|
import { APIMetric, event_types } from '@powersync/service-types';
|
|
9
|
+
import {
|
|
10
|
+
recordSyncConnection,
|
|
11
|
+
SyncCloseReason,
|
|
12
|
+
syncConnectionCloseReasonLogText,
|
|
13
|
+
SyncTransport
|
|
14
|
+
} from '../../metrics/connection-metrics.js';
|
|
9
15
|
import { limitParamsForLogging } from '../../util/param-logging.js';
|
|
16
|
+
import { resolveSyncConnectionSetup } from '../sync-connection.js';
|
|
10
17
|
|
|
11
18
|
export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
12
19
|
router.reactiveStream<util.StreamingSyncRequest, any>(SyncRoutes.STREAM, {
|
|
@@ -36,18 +43,22 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
36
43
|
connected_at: new Date(streamStart)
|
|
37
44
|
};
|
|
38
45
|
|
|
46
|
+
const { bucketStorage, syncRules } = await resolveSyncConnectionSetup(service_context, SyncTransport.RSocket);
|
|
47
|
+
|
|
39
48
|
// Best effort guess on why the stream was closed.
|
|
40
49
|
// We use the `??=` operator everywhere, so that we catch the first relevant
|
|
41
50
|
// event, which is usually the most specific.
|
|
42
|
-
let closeReason:
|
|
51
|
+
let closeReason: SyncCloseReason | undefined = undefined;
|
|
52
|
+
let connectionError: unknown;
|
|
43
53
|
|
|
44
54
|
// Create our own controller that we can abort directly
|
|
45
55
|
const controller = new AbortController();
|
|
46
56
|
upstreamSignal.addEventListener('abort', () => {
|
|
47
|
-
closeReason ??=
|
|
57
|
+
closeReason ??= SyncCloseReason.ClientClosed;
|
|
48
58
|
controller.abort();
|
|
49
59
|
});
|
|
50
60
|
if (upstreamSignal.aborted) {
|
|
61
|
+
closeReason ??= SyncCloseReason.ClientClosed;
|
|
51
62
|
controller.abort();
|
|
52
63
|
}
|
|
53
64
|
const signal = controller.signal;
|
|
@@ -59,39 +70,8 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
59
70
|
}
|
|
60
71
|
});
|
|
61
72
|
|
|
62
|
-
if (routerEngine.closed) {
|
|
63
|
-
responder.onError(
|
|
64
|
-
new errors.ServiceError({
|
|
65
|
-
status: 503,
|
|
66
|
-
code: ErrorCode.PSYNC_S2003,
|
|
67
|
-
description: 'Service temporarily unavailable'
|
|
68
|
-
})
|
|
69
|
-
);
|
|
70
|
-
responder.onComplete();
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const {
|
|
75
|
-
storageEngine: { activeBucketStorage }
|
|
76
|
-
} = service_context;
|
|
77
|
-
|
|
78
|
-
const bucketStorage = (await activeBucketStorage.getActiveSyncConfig())?.storage;
|
|
79
|
-
if (bucketStorage == null) {
|
|
80
|
-
responder.onError(
|
|
81
|
-
new errors.ServiceError({
|
|
82
|
-
status: 500,
|
|
83
|
-
code: ErrorCode.PSYNC_S2302,
|
|
84
|
-
description: 'No sync config available'
|
|
85
|
-
})
|
|
86
|
-
);
|
|
87
|
-
responder.onComplete();
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
|
|
92
|
-
|
|
93
73
|
const removeStopHandler = routerEngine.addStopHandler(() => {
|
|
94
|
-
closeReason ??=
|
|
74
|
+
closeReason ??= SyncCloseReason.ProcessShutdown;
|
|
95
75
|
controller.abort();
|
|
96
76
|
});
|
|
97
77
|
|
|
@@ -162,14 +142,17 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
162
142
|
});
|
|
163
143
|
}
|
|
164
144
|
}
|
|
165
|
-
closeReason ??=
|
|
145
|
+
closeReason ??= SyncCloseReason.ServiceClosed;
|
|
166
146
|
} catch (ex) {
|
|
167
147
|
// Convert to our standard form before responding.
|
|
168
148
|
// This ensures the error can be serialized.
|
|
169
149
|
// However, use the original error for the logs, so that we have the stack trace.
|
|
170
150
|
const error = new errors.InternalServerError(ex);
|
|
171
151
|
logger.error('Sync stream error', ex);
|
|
172
|
-
closeReason
|
|
152
|
+
if (closeReason == null) {
|
|
153
|
+
closeReason = SyncCloseReason.StreamError;
|
|
154
|
+
connectionError = ex;
|
|
155
|
+
}
|
|
173
156
|
responder.onError(error);
|
|
174
157
|
} finally {
|
|
175
158
|
responder.onComplete();
|
|
@@ -190,9 +173,14 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
190
173
|
...tracker.getLogMeta(),
|
|
191
174
|
app_metadata: formattedAppMetadata,
|
|
192
175
|
stream_ms: Date.now() - streamStart,
|
|
193
|
-
close_reason: closeReason
|
|
176
|
+
close_reason: syncConnectionCloseReasonLogText(closeReason)
|
|
194
177
|
});
|
|
195
178
|
metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
|
|
179
|
+
recordSyncConnection(metricsEngine, {
|
|
180
|
+
transport: SyncTransport.RSocket,
|
|
181
|
+
closeReason: closeReason ?? SyncCloseReason.Unknown,
|
|
182
|
+
error: connectionError
|
|
183
|
+
});
|
|
196
184
|
service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
|
|
197
185
|
...sdkData,
|
|
198
186
|
disconnected_at: new Date()
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
|
|
2
|
-
import {
|
|
2
|
+
import { parseSyncConfig, SyncConfigErrors } from '@powersync/service-sync-rules';
|
|
3
3
|
import type { FastifyPluginAsync } from 'fastify';
|
|
4
4
|
import * as t from 'ts-codec';
|
|
5
|
-
import {
|
|
5
|
+
import { assertAcceptedSyncConfig, assertConfiguredSyncConfigValid } from '../../system/SyncConfigEngine.js';
|
|
6
6
|
|
|
7
7
|
import { PatternResult, RouteAPI } from '../../api/RouteAPI.js';
|
|
8
|
-
import { logSyncConfigErrors
|
|
8
|
+
import { logSyncConfigErrors } from '../../storage/BucketStorageFactory.js';
|
|
9
9
|
import { authApi } from '../auth.js';
|
|
10
10
|
import { routeDefinition } from '../router.js';
|
|
11
11
|
|
|
@@ -53,30 +53,9 @@ export const deploySyncRules = routeDefinition({
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
const content = payload.params.content;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// First ensure the SyncConfig parses correctly
|
|
60
|
-
const apiHandler = service_context.routerEngine.getAPI();
|
|
61
|
-
syncConfig = SqlSyncRules.fromYaml(content, {
|
|
62
|
-
...apiHandler.getParseSyncRulesOptions(),
|
|
63
|
-
// We don't do any schema-level validation at this point
|
|
64
|
-
schema: undefined
|
|
65
|
-
});
|
|
66
|
-
} catch (e) {
|
|
67
|
-
throw new errors.ServiceError({
|
|
68
|
-
status: 422,
|
|
69
|
-
code: ErrorCode.PSYNC_R0001,
|
|
70
|
-
description: 'Sync config parsing failed',
|
|
71
|
-
details: e.message
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
logSyncConfigErrors(syncConfig);
|
|
76
|
-
// Validate if the current sources support the configuration specified in the sync config
|
|
77
|
-
await assertSourceCapabilities(service_context.routerEngine.getAPI(), syncConfig.config);
|
|
78
|
-
|
|
79
|
-
const sync_rules = await storageEngine.activeBucketStorage.updateSyncRules(updateSyncRulesFromConfig(syncConfig));
|
|
56
|
+
const compiled = assertAcceptedSyncConfig(await service_context.syncConfigEngine.validate(content));
|
|
57
|
+
logSyncConfigErrors(compiled.parsed);
|
|
58
|
+
const sync_rules = await storageEngine.activeBucketStorage.updateSyncRules({ config: compiled });
|
|
80
59
|
|
|
81
60
|
return {
|
|
82
61
|
slot_name: sync_rules.replicationStreamName
|
|
@@ -160,9 +139,10 @@ export const reprocessSyncRules = routeDefinition({
|
|
|
160
139
|
authorize: authApi,
|
|
161
140
|
validator: schema.createTsCodecValidator(ReprocessSyncRulesRequest),
|
|
162
141
|
handler: async (payload) => {
|
|
142
|
+
const service_context = payload.context.service_context;
|
|
163
143
|
const {
|
|
164
144
|
storageEngine: { activeBucketStorage }
|
|
165
|
-
} =
|
|
145
|
+
} = service_context;
|
|
166
146
|
const next = await activeBucketStorage.getDeployingSyncConfig();
|
|
167
147
|
if (next != null) {
|
|
168
148
|
throw new errors.ServiceError({
|
|
@@ -172,6 +152,8 @@ export const reprocessSyncRules = routeDefinition({
|
|
|
172
152
|
});
|
|
173
153
|
}
|
|
174
154
|
|
|
155
|
+
await assertConfiguredSyncConfigValid(service_context.syncConfigEngine);
|
|
156
|
+
|
|
175
157
|
const active = await activeBucketStorage.getActiveSyncConfig();
|
|
176
158
|
if (active == null) {
|
|
177
159
|
throw new errors.ServiceError({
|
|
@@ -182,22 +164,16 @@ export const reprocessSyncRules = routeDefinition({
|
|
|
182
164
|
}
|
|
183
165
|
|
|
184
166
|
const sync_rules = active.content;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
schema: undefined,
|
|
188
|
-
// This sync config already passed validation. But if the rules are not valid anymore due
|
|
189
|
-
// to a service change, we do want to report the error here.
|
|
190
|
-
throwOnError: true
|
|
191
|
-
});
|
|
192
|
-
logSyncConfigErrors(parsed);
|
|
193
|
-
await assertSourceCapabilities(payload.context.service_context.routerEngine.getAPI(), parsed.config);
|
|
194
|
-
|
|
195
|
-
const new_rules = await activeBucketStorage.updateSyncRules(
|
|
196
|
-
updateSyncRulesFromConfig(parsed, {
|
|
197
|
-
version_label: sync_rules.version_label,
|
|
198
|
-
forceNewReplicationStream: true
|
|
199
|
-
})
|
|
167
|
+
const compiled = assertAcceptedSyncConfig(
|
|
168
|
+
await service_context.syncConfigEngine.validate(sync_rules.sync_rules_content)
|
|
200
169
|
);
|
|
170
|
+
|
|
171
|
+
logSyncConfigErrors(compiled.parsed);
|
|
172
|
+
const new_rules = await activeBucketStorage.updateSyncRules({
|
|
173
|
+
config: compiled,
|
|
174
|
+
version_label: sync_rules.version_label,
|
|
175
|
+
forceNewReplicationStream: true
|
|
176
|
+
});
|
|
201
177
|
return {
|
|
202
178
|
slot_name: new_rules.replicationStreamName
|
|
203
179
|
};
|
|
@@ -216,7 +192,7 @@ function replyPrettyJson(payload: any) {
|
|
|
216
192
|
|
|
217
193
|
async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
|
|
218
194
|
try {
|
|
219
|
-
const rules =
|
|
195
|
+
const rules = parseSyncConfig(sync_rules, {
|
|
220
196
|
...apiHandler.getParseSyncRulesOptions(),
|
|
221
197
|
// No schema-based validation at this point
|
|
222
198
|
schema: undefined
|
|
@@ -224,7 +200,7 @@ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
|
|
|
224
200
|
const errors: string[] = [];
|
|
225
201
|
const warnings = rules.errors.filter((error) => error.type === 'warning').map((error) => error.message);
|
|
226
202
|
const recordError = (error: any) => {
|
|
227
|
-
errors.push(...(error instanceof
|
|
203
|
+
errors.push(...(error instanceof SyncConfigErrors ? error.errors.map((e) => e.message) : [error.message]));
|
|
228
204
|
};
|
|
229
205
|
try {
|
|
230
206
|
const diagnostics = (await apiHandler.validateSourceCapabilities?.(rules.config)) ?? [];
|
|
@@ -251,7 +227,7 @@ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
|
|
|
251
227
|
data_tables: rules.config.debugGetOutputTables()
|
|
252
228
|
};
|
|
253
229
|
} catch (e) {
|
|
254
|
-
if (e instanceof
|
|
230
|
+
if (e instanceof SyncConfigErrors) {
|
|
255
231
|
return {
|
|
256
232
|
valid: false,
|
|
257
233
|
errors: e.errors.map((e) => e.message)
|