@powersync/service-core 1.27.2 → 1.28.0

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +201 -67
  3. package/dist/api/RouteAPI.d.ts +6 -0
  4. package/dist/api/api-metrics.js +4 -0
  5. package/dist/api/api-metrics.js.map +1 -1
  6. package/dist/api/diagnostics.d.ts +5 -0
  7. package/dist/api/diagnostics.js +17 -2
  8. package/dist/api/diagnostics.js.map +1 -1
  9. package/dist/metrics/connection-metrics.d.ts +28 -0
  10. package/dist/metrics/connection-metrics.js +55 -0
  11. package/dist/metrics/connection-metrics.js.map +1 -0
  12. package/dist/metrics/metrics-index.d.ts +1 -0
  13. package/dist/metrics/metrics-index.js +1 -0
  14. package/dist/metrics/metrics-index.js.map +1 -1
  15. package/dist/metrics/metrics-interfaces.d.ts +7 -1
  16. package/dist/metrics/metrics-interfaces.js.map +1 -1
  17. package/dist/modules/loader.js +8 -2
  18. package/dist/modules/loader.js.map +1 -1
  19. package/dist/replication/AbstractReplicationJob.d.ts +2 -0
  20. package/dist/replication/AbstractReplicationJob.js +17 -1
  21. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  22. package/dist/replication/AbstractReplicator.d.ts +8 -18
  23. package/dist/replication/AbstractReplicator.js +63 -64
  24. package/dist/replication/AbstractReplicator.js.map +1 -1
  25. package/dist/replication/ReplicationModule.js +0 -6
  26. package/dist/replication/ReplicationModule.js.map +1 -1
  27. package/dist/routes/configure-fastify.js +5 -1
  28. package/dist/routes/configure-fastify.js.map +1 -1
  29. package/dist/routes/endpoints/admin.js +24 -22
  30. package/dist/routes/endpoints/admin.js.map +1 -1
  31. package/dist/routes/endpoints/socket-route.js +19 -27
  32. package/dist/routes/endpoints/socket-route.js.map +1 -1
  33. package/dist/routes/endpoints/sync-rules.js +17 -40
  34. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  35. package/dist/routes/endpoints/sync-stream.js +25 -23
  36. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  37. package/dist/routes/hooks.d.ts +1 -1
  38. package/dist/routes/hooks.js +2 -1
  39. package/dist/routes/hooks.js.map +1 -1
  40. package/dist/routes/sync-connection.d.ts +7 -0
  41. package/dist/routes/sync-connection.js +34 -0
  42. package/dist/routes/sync-connection.js.map +1 -0
  43. package/dist/storage/BucketStorageFactory.d.ts +30 -22
  44. package/dist/storage/BucketStorageFactory.js +14 -14
  45. package/dist/storage/BucketStorageFactory.js.map +1 -1
  46. package/dist/storage/PersistedSyncConfigContent.js +2 -2
  47. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  48. package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
  49. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  50. package/dist/streams/BroadcastIterable.js +8 -7
  51. package/dist/streams/BroadcastIterable.js.map +1 -1
  52. package/dist/streams/LastValueSink.d.ts +2 -2
  53. package/dist/streams/LastValueSink.js +8 -13
  54. package/dist/streams/LastValueSink.js.map +1 -1
  55. package/dist/streams/merge.d.ts +15 -31
  56. package/dist/streams/merge.js +136 -152
  57. package/dist/streams/merge.js.map +1 -1
  58. package/dist/sync/BucketChecksumState.d.ts +3 -2
  59. package/dist/sync/BucketChecksumState.js +12 -5
  60. package/dist/sync/BucketChecksumState.js.map +1 -1
  61. package/dist/sync/sync.js +48 -38
  62. package/dist/sync/sync.js.map +1 -1
  63. package/dist/sync/util.d.ts +1 -2
  64. package/dist/sync/util.js +2 -5
  65. package/dist/sync/util.js.map +1 -1
  66. package/dist/system/ServiceContext.d.ts +7 -0
  67. package/dist/system/ServiceContext.js +3 -3
  68. package/dist/system/ServiceContext.js.map +1 -1
  69. package/dist/system/SyncConfigEngine.d.ts +135 -0
  70. package/dist/system/SyncConfigEngine.js +186 -0
  71. package/dist/system/SyncConfigEngine.js.map +1 -0
  72. package/dist/system/system-index.d.ts +1 -0
  73. package/dist/system/system-index.js +1 -0
  74. package/dist/system/system-index.js.map +1 -1
  75. package/dist/util/config/types.d.ts +4 -0
  76. package/package.json +8 -9
  77. package/src/api/RouteAPI.ts +6 -0
  78. package/src/api/api-metrics.ts +5 -0
  79. package/src/api/diagnostics.ts +22 -2
  80. package/src/metrics/connection-metrics.ts +75 -0
  81. package/src/metrics/metrics-index.ts +1 -0
  82. package/src/metrics/metrics-interfaces.ts +8 -1
  83. package/src/modules/loader.ts +16 -2
  84. package/src/replication/AbstractReplicationJob.ts +17 -1
  85. package/src/replication/AbstractReplicator.ts +83 -81
  86. package/src/replication/ReplicationModule.ts +0 -5
  87. package/src/routes/configure-fastify.ts +7 -1
  88. package/src/routes/endpoints/admin.ts +27 -20
  89. package/src/routes/endpoints/socket-route.ts +26 -38
  90. package/src/routes/endpoints/sync-rules.ts +22 -46
  91. package/src/routes/endpoints/sync-stream.ts +31 -27
  92. package/src/routes/hooks.ts +5 -1
  93. package/src/routes/sync-connection.ts +39 -0
  94. package/src/storage/BucketStorageFactory.ts +45 -29
  95. package/src/storage/PersistedSyncConfigContent.ts +2 -2
  96. package/src/storage/SyncRulesBucketStorage.ts +23 -2
  97. package/src/streams/BroadcastIterable.ts +8 -7
  98. package/src/streams/LastValueSink.ts +10 -16
  99. package/src/streams/merge.ts +149 -169
  100. package/src/sync/BucketChecksumState.ts +17 -5
  101. package/src/sync/sync.ts +71 -57
  102. package/src/sync/util.ts +2 -7
  103. package/src/system/ServiceContext.ts +8 -3
  104. package/src/system/SyncConfigEngine.ts +247 -0
  105. package/src/system/system-index.ts +1 -0
  106. package/src/util/config/types.ts +4 -0
  107. package/test/src/AbstractReplicator.test.ts +375 -45
  108. package/test/src/PersistedSyncConfigContent.test.ts +5 -5
  109. package/test/src/SyncConfigEngine.test.ts +208 -0
  110. package/test/src/broadcast_iterable.test.ts +59 -31
  111. package/test/src/config.test.ts +65 -3
  112. package/test/src/connection-metrics-export.test.ts +76 -0
  113. package/test/src/connection-metrics.test.ts +184 -0
  114. package/test/src/diagnostics.test.ts +3 -3
  115. package/test/src/last_value_sink.test.ts +34 -0
  116. package/test/src/merge_iterable.test.ts +304 -55
  117. package/test/src/module-loader.test.ts +14 -1
  118. package/test/src/recording-metrics.ts +36 -0
  119. package/test/src/routes/admin.test.ts +2 -2
  120. package/test/src/routes/concurrency-limit.test.ts +108 -0
  121. package/test/src/routes/connection-metrics.test.ts +269 -0
  122. package/test/src/routes/invalid-config.test.ts +184 -0
  123. package/test/src/routes/mocks.ts +26 -13
  124. package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
  125. package/test/src/routes/stream.test.ts +200 -107
  126. package/test/src/routes/sync-rules.test.ts +19 -1
  127. package/test/src/source_table_config.test.ts +7 -7
  128. package/test/src/sync/BucketChecksumState.test.ts +10 -10
  129. package/test/src/sync/stream_response.test.ts +389 -0
  130. package/test/src/sync/util.test.ts +14 -3
  131. 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 { SyncRulesProvider } from '../util/config/sync-rules/sync-rules-provider.js';
11
- import type { ResolvedPowerSyncConfig } from '../util/config/types.js';
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
- syncRuleProvider: SyncRulesProvider;
33
+ configuration: Pick<ResolvedPowerSyncConfig, 'sync_rules' | 'connections'>;
35
34
  /**
36
- * Source connection metadata from the service config, used when parsing filesystem-loaded sync configs.
35
+ * Context-owned admission engine shared with diagnostics; jobs use storage-owned parsed configs.
37
36
  */
38
- configuration?: Pick<ResolvedPowerSyncConfig, 'connections'>;
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 loadedSyncConfig = await this.syncRuleProvider.get();
178
-
179
- let configuredLock: storage.ReplicationLock | undefined = undefined;
180
- if (loadedSyncConfig != null) {
181
- const versionLabel = this.syncRuleProvider.versionLabel;
182
- this.logger.info(`Loaded sync config${versionLabel == null ? '' : ` ${versionLabel}`}`);
183
- try {
184
- // Configure new sync config, if they have changed.
185
- // In that case, also immediately take out a lock, so that another process doesn't start replication on it.
186
- // This upfront lock is mostly superseded by the replicationStreamLoadedSyncConfigMatch() check. However, that doesn't cover old
187
- // versions before that check was added, so we keep the lock for now - for where te service version and sync config is updated at
188
- // the same time.
189
-
190
- const parsed = SqlSyncRules.fromYaml(loadedSyncConfig, {
191
- schema: undefined,
192
- defaultSchema: this.defaultSchema,
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
- const { lock } = await this.storage.configureSyncRules(
212
- storage.updateSyncRulesFromConfig(parsed, {
213
- lock: true,
214
- version_label: versionLabel
215
- })
216
- );
217
- if (lock) {
218
- configuredLock = lock;
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
- } catch (e) {
221
- // Log and re-raise to exit.
222
- this.logger.error(`Failed to update sync config`, e);
223
- throw e;
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 configured - configure via API');
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
- loadedVersionLabel: this.syncRuleProvider.versionLabel
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(replicationStream, options?.loadedSyncConfig, options?.loadedVersionLabel)
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 filesystem/config source, we ignore any config loaded by other processes.
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
- private shouldHandleReplicationStream(
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
- return (
438
- processingConfig == null ||
439
- (processingConfig.sync_rules_content == loadedSyncRules && processingConfig.version_label == loadedVersionLabel)
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
- deploying == null
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 parsed = SqlSyncRules.fromYaml(active.content.sync_rules_content, {
177
- ...apiHandler.getParseSyncRulesOptions(),
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 = SqlSyncRules.fromYaml(this.sync_rules_content, {
224
+ const syncConfig = parseSyncConfig(this.sync_rules_content, {
218
225
  ...options,
219
226
  schema: this.schema
220
227
  });
@@ -1,4 +1,4 @@
1
- import { ErrorCode, errors, schema } from '@powersync/lib-services-framework';
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: string | undefined = undefined;
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 ??= 'client closing stream';
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 ??= 'process shutdown';
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 ??= 'service closing stream';
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 ??= 'stream error';
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 ?? 'unknown'
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 { SqlSyncRules, SyncConfigWithErrors, SyncRulesErrors } from '@powersync/service-sync-rules';
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 { assertSourceCapabilities } from '../../api/source-capabilities.js';
5
+ import { assertAcceptedSyncConfig, assertConfiguredSyncConfigValid } from '../../system/SyncConfigEngine.js';
6
6
 
7
7
  import { PatternResult, RouteAPI } from '../../api/RouteAPI.js';
8
- import { logSyncConfigErrors, updateSyncRulesFromConfig } from '../../storage/BucketStorageFactory.js';
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
- let syncConfig: SyncConfigWithErrors;
57
-
58
- try {
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
- } = payload.context.service_context;
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 parsed = SqlSyncRules.fromYaml(sync_rules.sync_rules_content, {
186
- ...payload.context.service_context.routerEngine.getAPI().getParseSyncRulesOptions(),
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 = SqlSyncRules.fromYaml(sync_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 SyncRulesErrors ? error.errors.map((e) => e.message) : [error.message]));
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 SyncRulesErrors) {
230
+ if (e instanceof SyncConfigErrors) {
255
231
  return {
256
232
  valid: false,
257
233
  errors: e.errors.map((e) => e.message)