@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
@@ -9,6 +9,7 @@ import * as routes from '../routes/routes-index.js';
9
9
  import * as storage from '../storage/storage-index.js';
10
10
  import { SyncContext } from '../sync/SyncContext.js';
11
11
  import * as utils from '../util/util-index.js';
12
+ import { SyncConfigEngine } from './SyncConfigEngine.js';
12
13
 
13
14
  export interface ServiceContext {
14
15
  configuration: utils.ResolvedPowerSyncConfig;
@@ -19,6 +20,11 @@ export interface ServiceContext {
19
20
  storageEngine: storage.StorageEngine;
20
21
  migrations: PowerSyncMigrationManager;
21
22
  syncContext: SyncContext;
23
+ /**
24
+ * Shared parsing and admission. The configured snapshot covers filesystem, Base64, or inline YAML
25
+ * supplied through service configuration; API deployments use separate submission validation.
26
+ */
27
+ syncConfigEngine: SyncConfigEngine;
22
28
  writeCheckpointBatcher: utils.WriteCheckpointBatcher;
23
29
  serviceMode: ServiceContextMode;
24
30
  eventsEngine: EventsEngine;
@@ -50,6 +56,7 @@ export class ServiceContextContainer implements ServiceContext {
50
56
  storageEngine: storage.StorageEngine;
51
57
  eventsEngine: EventsEngine;
52
58
  syncContext: SyncContext;
59
+ syncConfigEngine: SyncConfigEngine;
53
60
  routerEngine: routes.RouterEngine;
54
61
  writeCheckpointBatcher: utils.WriteCheckpointBatcher;
55
62
  serviceMode: ServiceContextMode;
@@ -82,9 +89,7 @@ export class ServiceContextContainer implements ServiceContext {
82
89
  });
83
90
 
84
91
  this.routerEngine = new routes.RouterEngine();
85
- this.lifeCycleEngine.withLifecycle(this.routerEngine, {
86
- stop: (routerEngine) => routerEngine.shutDown()
87
- });
92
+ this.syncConfigEngine = new SyncConfigEngine(configuration.sync_rules, () => this.routerEngine.getAPI());
88
93
 
89
94
  this.writeCheckpointBatcher = new utils.WriteCheckpointBatcher(
90
95
  () => this.routerEngine.getAPI(),
@@ -0,0 +1,247 @@
1
+ import { ErrorCode, logger, ServiceError } from '@powersync/lib-services-framework';
2
+ import { parseSyncConfig, SyncConfigErrors, SyncConfigWithErrors } from '@powersync/service-sync-rules';
3
+ import { ValidationDiagnostic } from '@powersync/service-types';
4
+ import type { RouteAPI } from '../api/RouteAPI.js';
5
+ import { CompiledSyncConfig, updateSyncRulesFromConfig } from '../storage/BucketStorageFactory.js';
6
+ import { ConfigurationFileSyncRulesProvider } from '../util/config/sync-rules/sync-rules-provider.js';
7
+ import type { SyncRulesConfig } from '../util/config/types.js';
8
+ import { syncConfigYamlErrorToReplicationError } from '../util/errors.js';
9
+
10
+ /**
11
+ * Outcome of loading or validating sync config, independent of persisted replication state.
12
+ */
13
+ export enum SyncConfigResultStatus {
14
+ /**
15
+ * No sync config is supplied through service configuration; use API-managed deployments.
16
+ */
17
+ ABSENT = 'absent',
18
+ /**
19
+ * Configured input could not be loaded. This is distinct from invalid YAML and API-managed mode.
20
+ */
21
+ UNAVAILABLE = 'unavailable',
22
+ /**
23
+ * Parsing and source-capability checks passed without fatal diagnostics; warnings may remain.
24
+ */
25
+ ACCEPTED = 'accepted',
26
+ /**
27
+ * Parsing, compilation, or source-capability checks failed; the input cannot be deployed.
28
+ */
29
+ REJECTED = 'rejected'
30
+ }
31
+
32
+ /**
33
+ * Validation stage responsible for rejecting a sync config.
34
+ */
35
+ export enum SyncConfigRejectionReason {
36
+ /**
37
+ * YAML parsing, query validation, or compilation failed.
38
+ */
39
+ PARSE = 'parse',
40
+ /**
41
+ * The source adapter rejected required execution capabilities or its capability check failed.
42
+ */
43
+ CAPABILITY = 'capability'
44
+ }
45
+
46
+ /**
47
+ * Source-aware admission result for supplied YAML. Only accepted input exposes a deployable
48
+ * compiled config. Rejected input retains diagnostics and any usable partial parse for inspection.
49
+ * Startup policies such as `exit_on_error` are applied by callers after validation.
50
+ */
51
+ export type SyncConfigValidationResult =
52
+ | {
53
+ status: SyncConfigResultStatus.ACCEPTED;
54
+ /**
55
+ * Original YAML, retained for deployment identity and optional diagnostics content.
56
+ */
57
+ content: string;
58
+ /**
59
+ * Parsed config and prepared plan for submission to storage, before storage-owned hydration.
60
+ */
61
+ compiled: CompiledSyncConfig;
62
+ /**
63
+ * Advisory diagnostics collected during parsing and source-capability validation.
64
+ */
65
+ diagnostics: ValidationDiagnostic[];
66
+ }
67
+ | {
68
+ status: SyncConfigResultStatus.REJECTED;
69
+ /**
70
+ * First failing validation stage, used to format caller-facing errors. Parser failures take
71
+ * precedence when source-capability validation also reports errors.
72
+ */
73
+ reason: SyncConfigRejectionReason;
74
+ /**
75
+ * Rejected YAML, retained for deployment identity and optional diagnostics content.
76
+ */
77
+ content: string;
78
+ /**
79
+ * Partial parse when available; malformed YAML may fail before producing one.
80
+ */
81
+ parsed?: SyncConfigWithErrors;
82
+ /**
83
+ * Fatal and advisory diagnostics, preserving available source locations and timestamps.
84
+ */
85
+ diagnostics: ValidationDiagnostic[];
86
+ };
87
+
88
+ /**
89
+ * Cached outcome for input supplied through service configuration (filesystem, Base64, or inline YAML),
90
+ * excluding YAML submitted through the deployment API.
91
+ * Absent input selects API-managed mode; unavailable input preserves its load failure rather than
92
+ * treating it as an absent or rejected config.
93
+ */
94
+ export type ConfiguredSyncConfigResult =
95
+ | { status: SyncConfigResultStatus.ABSENT }
96
+ | { status: SyncConfigResultStatus.UNAVAILABLE; error: unknown }
97
+ | SyncConfigValidationResult;
98
+
99
+ /**
100
+ * Owns admission of configured and submitted YAML. "Configured" means supplied through service
101
+ * configuration from a filesystem path, Base64 input, or inline YAML, excluding deployment API submissions.
102
+ * Each process caches its configured result;
103
+ * replication jobs retain their storage-owned parsed configs and hydration identity.
104
+ */
105
+ export class SyncConfigEngine {
106
+ private configured?: Promise<ConfiguredSyncConfigResult>;
107
+
108
+ /**
109
+ * @param settings Input source and startup policy from service configuration. Raw YAML is loaded
110
+ * on the first call to `getConfigured()`.
111
+ * @param getRouteAPI Resolves the registered source adapter only when YAML validation is needed.
112
+ * Adapter shutdown remains owned by the router lifecycle.
113
+ */
114
+ constructor(
115
+ private readonly settings: SyncRulesConfig,
116
+ private readonly getRouteAPI: () => RouteAPI
117
+ ) {}
118
+
119
+ /**
120
+ * Evaluate YAML supplied through service configuration (filesystem, Base64, or inline content),
121
+ * excluding deployment API submissions. Concurrent consumers share the same promise
122
+ * and result until restart. Reads configured input once on first use, then retains the loaded,
123
+ * parsed, or unavailable result without rereading files or mutating storage.
124
+ * Input load failures return `UNAVAILABLE`; absent configured input selects API-managed mode.
125
+ */
126
+ getConfigured(): Promise<ConfiguredSyncConfigResult> {
127
+ return (this.configured ??= this.evaluateConfigured());
128
+ }
129
+
130
+ private async evaluateConfigured(): Promise<ConfiguredSyncConfigResult> {
131
+ if (!this.settings.present) {
132
+ return { status: SyncConfigResultStatus.ABSENT };
133
+ }
134
+ let content: string | undefined;
135
+ try {
136
+ content = await new ConfigurationFileSyncRulesProvider(this.settings).get();
137
+ if (content == null) {
138
+ throw new Error('Sync config is configured but no content or file path is available');
139
+ }
140
+ } catch (error) {
141
+ logger.warn('Unable to read local sync config', error);
142
+ return { status: SyncConfigResultStatus.UNAVAILABLE, error };
143
+ }
144
+ return this.validate(content);
145
+ }
146
+
147
+ /**
148
+ * Parse supplied YAML, validate source capabilities even when the returned parse has fatal diagnostics,
149
+ * and prepare accepted input for storage. A parser exception with no config skips capability checks.
150
+ * Does not replace the configured snapshot or mutate storage. Validation failures become rejected
151
+ * results; source lookup or failures obtaining adapter parse options reject the promise.
152
+ */
153
+ async validate(content: string): Promise<SyncConfigValidationResult> {
154
+ const routeAPI = this.getRouteAPI();
155
+ const parseOptions = routeAPI.getParseSyncRulesOptions();
156
+ const now = new Date().toISOString();
157
+ const diagnostics: ValidationDiagnostic[] = [];
158
+ let parsed: SyncConfigWithErrors | undefined;
159
+ try {
160
+ parsed = parseSyncConfig(content, { ...parseOptions, schema: undefined, throwOnError: false });
161
+ diagnostics.push(...parsed.errors.map((error) => syncConfigYamlErrorToReplicationError(error, now)));
162
+ const hasFatalParseErrors = diagnostics.some((error) => error.level === 'fatal');
163
+ try {
164
+ diagnostics.push(
165
+ ...((await routeAPI.validateSourceCapabilities?.(parsed.config)) ?? []).map((diagnostic) => ({
166
+ ...diagnostic,
167
+ ts: diagnostic.ts ?? now
168
+ }))
169
+ );
170
+ } catch (error) {
171
+ diagnostics.push(...validationFailureDiagnostics(error, now));
172
+ }
173
+ if (diagnostics.some((error) => error.level === 'fatal')) {
174
+ return {
175
+ status: SyncConfigResultStatus.REJECTED,
176
+ reason: hasFatalParseErrors ? SyncConfigRejectionReason.PARSE : SyncConfigRejectionReason.CAPABILITY,
177
+ content,
178
+ parsed,
179
+ diagnostics
180
+ };
181
+ }
182
+ return {
183
+ status: SyncConfigResultStatus.ACCEPTED,
184
+ content,
185
+ compiled: updateSyncRulesFromConfig(parsed).config,
186
+ diagnostics
187
+ };
188
+ } catch (error) {
189
+ diagnostics.push(...validationFailureDiagnostics(error, now));
190
+ return {
191
+ status: SyncConfigResultStatus.REJECTED,
192
+ reason: SyncConfigRejectionReason.PARSE,
193
+ content,
194
+ parsed,
195
+ diagnostics
196
+ };
197
+ }
198
+ }
199
+ }
200
+
201
+ function validationFailureDiagnostics(error: unknown, ts: string): ValidationDiagnostic[] {
202
+ return error instanceof SyncConfigErrors
203
+ ? error.errors.map((error) => syncConfigYamlErrorToReplicationError(error, ts))
204
+ : [{ level: 'fatal', message: error instanceof Error ? error.message : String(error), ts }];
205
+ }
206
+
207
+ /**
208
+ * Return deployable input from an accepted result.
209
+ * @throws A 422 `PSYNC_R0001` service error containing fatal diagnostics when the input was rejected.
210
+ */
211
+ export function assertAcceptedSyncConfig(result: SyncConfigValidationResult): CompiledSyncConfig {
212
+ if (result.status === SyncConfigResultStatus.ACCEPTED) {
213
+ return result.compiled;
214
+ }
215
+ throw new ServiceError({
216
+ status: 422,
217
+ code: ErrorCode.PSYNC_R0001,
218
+ description:
219
+ result.reason === SyncConfigRejectionReason.PARSE
220
+ ? 'Sync config parsing failed'
221
+ : 'Source capability validation failed',
222
+ details: result.diagnostics
223
+ .filter((error) => error.level === 'fatal')
224
+ .map((error) => error.message)
225
+ .join('\n')
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Check local admission before manual reprocessing; callers separately check stored processing work.
231
+ * Both manual reprocess endpoints use this guard; automatic recovery does not.
232
+ * @throws A 409 `PSYNC_S4106` service error for rejected input, or the original input load failure.
233
+ */
234
+ export async function assertConfiguredSyncConfigValid(engine: SyncConfigEngine): Promise<void> {
235
+ const result = await engine.getConfigured();
236
+ if (result.status === SyncConfigResultStatus.REJECTED) {
237
+ throw new ServiceError({
238
+ status: 409,
239
+ code: ErrorCode.PSYNC_S4106,
240
+ description: 'Cannot reprocess while the configured sync config is invalid',
241
+ details: 'Correct the sync config and restart the affected service processes'
242
+ });
243
+ }
244
+ if (result.status === SyncConfigResultStatus.UNAVAILABLE) {
245
+ throw result.error;
246
+ }
247
+ }
@@ -1 +1,2 @@
1
1
  export * from './ServiceContext.js';
2
+ export * from './SyncConfigEngine.js';
@@ -20,6 +20,10 @@ export type MigrationContext = {
20
20
 
21
21
  export type Runner = (config: RunnerConfig) => Promise<void>;
22
22
 
23
+ /**
24
+ * Sync config supplied through service configuration: a filesystem path, Base64 input, or inline YAML.
25
+ * This excludes configs submitted through the deployment API; `present: false` selects API-managed mode.
26
+ */
23
27
  export type SyncRulesConfig = {
24
28
  present: boolean;
25
29
  version_label?: string;