@powersync/service-core 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716

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 (92) hide show
  1. package/CHANGELOG.md +45 -5
  2. package/dist/api/diagnostics.js +5 -2
  3. package/dist/api/diagnostics.js.map +1 -1
  4. package/dist/entry/commands/compact-action.js +1 -1
  5. package/dist/entry/commands/compact-action.js.map +1 -1
  6. package/dist/entry/commands/config-command.js +1 -1
  7. package/dist/entry/commands/config-command.js.map +1 -1
  8. package/dist/migrations/PowerSyncMigrationManager.js +2 -1
  9. package/dist/migrations/PowerSyncMigrationManager.js.map +1 -1
  10. package/dist/replication/AbstractReplicator.js +8 -4
  11. package/dist/replication/AbstractReplicator.js.map +1 -1
  12. package/dist/replication/ReplicationModule.d.ts +0 -7
  13. package/dist/replication/ReplicationModule.js +0 -1
  14. package/dist/replication/ReplicationModule.js.map +1 -1
  15. package/dist/routes/configure-fastify.d.ts +3 -3
  16. package/dist/routes/endpoints/admin.d.ts +6 -6
  17. package/dist/routes/endpoints/admin.js +15 -11
  18. package/dist/routes/endpoints/admin.js.map +1 -1
  19. package/dist/routes/endpoints/socket-route.js +7 -5
  20. package/dist/routes/endpoints/socket-route.js.map +1 -1
  21. package/dist/routes/endpoints/sync-rules.js +16 -10
  22. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  23. package/dist/storage/BucketStorageFactory.d.ts +21 -2
  24. package/dist/storage/BucketStorageFactory.js +17 -4
  25. package/dist/storage/BucketStorageFactory.js.map +1 -1
  26. package/dist/storage/PersistedReplicationStream.d.ts +2 -1
  27. package/dist/storage/PersistedReplicationStream.js.map +1 -1
  28. package/dist/storage/PersistedSyncConfigContent.d.ts +4 -4
  29. package/dist/storage/PersistedSyncConfigContent.js +12 -5
  30. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  31. package/dist/storage/StorageEngine.d.ts +4 -0
  32. package/dist/storage/StorageEngine.js +7 -1
  33. package/dist/storage/StorageEngine.js.map +1 -1
  34. package/dist/storage/StorageProvider.d.ts +8 -0
  35. package/dist/storage/SyncConfigParser.d.ts +44 -0
  36. package/dist/storage/SyncConfigParser.js +70 -0
  37. package/dist/storage/SyncConfigParser.js.map +1 -0
  38. package/dist/storage/SyncRulesBucketStorage.d.ts +3 -1
  39. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  40. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +9 -5
  41. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
  42. package/dist/storage/storage-index.d.ts +1 -0
  43. package/dist/storage/storage-index.js +1 -0
  44. package/dist/storage/storage-index.js.map +1 -1
  45. package/dist/sync/BucketChecksumState.d.ts +1 -1
  46. package/dist/sync/BucketChecksumState.js +5 -4
  47. package/dist/sync/BucketChecksumState.js.map +1 -1
  48. package/dist/sync/util.d.ts +1 -1
  49. package/dist/sync/util.js +3 -0
  50. package/dist/sync/util.js.map +1 -1
  51. package/dist/system/ServiceContext.d.ts +20 -0
  52. package/dist/system/ServiceContext.js +29 -1
  53. package/dist/system/ServiceContext.js.map +1 -1
  54. package/dist/util/param-logging.d.ts +5 -2
  55. package/dist/util/param-logging.js +17 -9
  56. package/dist/util/param-logging.js.map +1 -1
  57. package/dist/util/protocol-types.d.ts +7 -7
  58. package/dist/util/protocol-types.js +3 -3
  59. package/package.json +4 -4
  60. package/src/api/diagnostics.ts +6 -2
  61. package/src/entry/commands/compact-action.ts +1 -1
  62. package/src/entry/commands/config-command.ts +1 -1
  63. package/src/migrations/PowerSyncMigrationManager.ts +2 -1
  64. package/src/replication/AbstractReplicator.ts +8 -4
  65. package/src/replication/ReplicationModule.ts +0 -10
  66. package/src/routes/endpoints/admin.ts +15 -17
  67. package/src/routes/endpoints/socket-route.ts +9 -6
  68. package/src/routes/endpoints/sync-rules.ts +19 -12
  69. package/src/storage/BucketStorageFactory.ts +33 -6
  70. package/src/storage/PersistedReplicationStream.ts +2 -1
  71. package/src/storage/PersistedSyncConfigContent.ts +16 -8
  72. package/src/storage/StorageEngine.ts +10 -1
  73. package/src/storage/StorageProvider.ts +8 -0
  74. package/src/storage/SyncConfigParser.ts +121 -0
  75. package/src/storage/SyncRulesBucketStorage.ts +3 -1
  76. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +14 -5
  77. package/src/storage/storage-index.ts +1 -0
  78. package/src/sync/BucketChecksumState.ts +5 -4
  79. package/src/sync/util.ts +4 -1
  80. package/src/system/ServiceContext.ts +47 -1
  81. package/src/util/param-logging.ts +21 -14
  82. package/src/util/protocol-types.ts +5 -5
  83. package/test/src/PersistedSyncConfigContent.test.ts +3 -1
  84. package/test/src/SyncConfigParser.test.ts +330 -0
  85. package/test/src/broadcast_iterable.test.ts +26 -8
  86. package/test/src/diagnostics.test.ts +23 -0
  87. package/test/src/routes/admin.test.ts +22 -0
  88. package/test/src/routes/mocks.ts +2 -0
  89. package/test/src/sync/BucketChecksumState.test.ts +195 -6
  90. package/test/src/sync/util.test.ts +19 -0
  91. package/test/src/util/param-logging.test.ts +59 -0
  92. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import { CompatibilityContext } from '@powersync/service-sync-rules';
1
+ import { CompatibilityContext, connectionConfigsEqual } from '@powersync/service-sync-rules';
2
2
  import { Logger } from 'winston';
3
3
  import { SerializedSyncPlan, UpdateSyncRulesOptions } from '../BucketStorageFactory.js';
4
4
 
@@ -11,8 +11,8 @@ export function isCompatible(
11
11
  logger: Logger
12
12
  ): boolean {
13
13
  if (updateConfig.plan == null) {
14
- // Only support sync streams with serialized plans
15
- logger.info(`Not using current sync streams - incremental reprocessing not supported`);
14
+ // Only support Sync Streams with serialized plans
15
+ logger.info(`Not using current Sync Streams - incremental reprocessing not supported`);
16
16
  return false;
17
17
  }
18
18
 
@@ -23,8 +23,8 @@ export function isCompatible(
23
23
  }
24
24
 
25
25
  if (existingPlans.some((plan) => plan == null)) {
26
- // Only support sync streams with serialized plans
27
- logger.info(`Existing replication stream not using current sync streams - incremental reprocessing not supported`);
26
+ // Only support Sync Streams with serialized plans
27
+ logger.info(`Existing replication stream not using current Sync Streams - incremental reprocessing not supported`);
28
28
  return false;
29
29
  }
30
30
 
@@ -37,5 +37,14 @@ export function isCompatible(
37
37
  return false;
38
38
  }
39
39
 
40
+ if (
41
+ existingPlans.some(
42
+ (existing) =>
43
+ !connectionConfigsEqual(existing!.plan.connectionConfig ?? {}, updateConfig.plan!.plan.connectionConfig ?? {})
44
+ )
45
+ ) {
46
+ logger.info('Connection configuration changed - incremental reprocessing not supported');
47
+ return false;
48
+ }
40
49
  return true;
41
50
  }
@@ -25,3 +25,4 @@ export * from './implementation/BucketDefinitionMapping.js';
25
25
  export * from './implementation/IncrementalReprocessingCompatibility.js';
26
26
  export * from './implementation/IncrementalReprocessingSyncConfigLog.js';
27
27
  export * from './implementation/ReplicationStreamStorageIds.js';
28
+ export * from './SyncConfigParser.js';
@@ -89,7 +89,7 @@ export class BucketChecksumState {
89
89
  * subscribed to, we keep an index of the stream names to their index in that array.
90
90
  *
91
91
  * This is used to compress the representation of buckets in `checkpoint` and `checkpoint_diff` lines: For buckets
92
- * that are part of sync rules or default streams, we need to include the name of the defining sync rule or definition
92
+ * that are part of legacy Sync Rules or default streams, we need to include the name of the defining bucket definition or stream
93
93
  * yielding that bucket (so that clients can track progress for default streams).
94
94
  * But instead of sending the name for each bucket, we use the fact that it's part of the streams array and only send
95
95
  * their index, reducing the size of those messages.
@@ -747,7 +747,8 @@ export class BucketParameterState {
747
747
  }
748
748
  }
749
749
  }
750
- const allBuckets = [...staticBuckets, ...mergeBuckets(dynamicBuckets)];
750
+ // A stream can reach the same bucket through both static and dynamic parameters.
751
+ const allBuckets = mergeBuckets([...staticBuckets, ...dynamicBuckets]);
751
752
 
752
753
  if (invalidateDataBuckets) {
753
754
  return {
@@ -816,7 +817,7 @@ function logCheckpoint(
816
817
  }
817
818
 
818
819
  /**
819
- * Format a breakdown of dynamic buckets by sync stream or legacy bucket definition.
820
+ * Format a breakdown of dynamic buckets by Sync Stream or legacy bucket definition.
820
821
  *
821
822
  * Sorts definitions by count (descending), includes the top 100, and returns both the
822
823
  * formatted message string and the counts record suitable for structured log data.
@@ -834,7 +835,7 @@ function formatBucketDefinitionBreakdown(
834
835
  const allSorted = Array.from(bucketsByDefinition.entries()).sort((a, b) => b[1] - a[1]);
835
836
  const sortedDefinitions = allSorted.slice(0, maxLoggedDefinitions);
836
837
 
837
- const sourceLabel = bucketSourceType == BucketSourceType.SYNC_STREAM ? 'sync stream' : 'bucket definition';
838
+ const sourceLabel = bucketSourceType == BucketSourceType.SYNC_STREAM ? 'Sync Stream' : 'bucket definition';
838
839
  let message = `\nBuckets by ${sourceLabel}:`;
839
840
  const countsByDefinition: Record<string, number> = {};
840
841
  for (const [definition, count] of sortedDefinitions) {
package/src/sync/util.ts CHANGED
@@ -144,8 +144,11 @@ export async function* transformToBytesTracked(
144
144
 
145
145
  export function acquireSemaphoreAbortable(
146
146
  semaphone: SemaphoreInterface,
147
- abort: AbortSignal
147
+ abort?: AbortSignal
148
148
  ): Promise<[number, SemaphoreInterface.Releaser] | 'aborted'> {
149
+ if (abort == null) {
150
+ return semaphone.acquire();
151
+ }
149
152
  return new Promise((resolve, reject) => {
150
153
  // An already-aborted signal never fires its listener, and we would wait for the semaphore
151
154
  // indefinitely.
@@ -1,4 +1,5 @@
1
1
  import { container, LifeCycledSystem, MigrationManager, ServiceIdentifier } from '@powersync/lib-services-framework';
2
+ import type { AdditionalSyncConfigParser } from '@powersync/service-sync-rules';
2
3
 
3
4
  import { EventsEngine } from '../events/EventsEngine.js';
4
5
  import { framework } from '../index.js';
@@ -10,6 +11,16 @@ import * as storage from '../storage/storage-index.js';
10
11
  import { SyncContext } from '../sync/SyncContext.js';
11
12
  import * as utils from '../util/util-index.js';
12
13
 
14
+ /**
15
+ * Registration-capable sync-config parser exposed to modules through the service context.
16
+ */
17
+ export interface ServiceContextSyncConfigParser extends storage.SyncConfigParser {
18
+ /**
19
+ * Registers a deterministic parser extension during module initialization, before storage starts.
20
+ */
21
+ registerParser(extension: AdditionalSyncConfigParser): void;
22
+ }
23
+
13
24
  export interface ServiceContext {
14
25
  configuration: utils.ResolvedPowerSyncConfig;
15
26
  lifeCycleEngine: LifeCycledSystem;
@@ -22,6 +33,11 @@ export interface ServiceContext {
22
33
  writeCheckpointBatcher: utils.WriteCheckpointBatcher;
23
34
  serviceMode: ServiceContextMode;
24
35
  eventsEngine: EventsEngine;
36
+ /**
37
+ * Service-wide parser shared by routes, replication, and storage. Source modules register parser extensions on this
38
+ * instance during module initialization.
39
+ */
40
+ readonly syncConfigParser: ServiceContextSyncConfigParser;
25
41
  }
26
42
 
27
43
  export enum ServiceContextMode {
@@ -53,16 +69,20 @@ export class ServiceContextContainer implements ServiceContext {
53
69
  routerEngine: routes.RouterEngine;
54
70
  writeCheckpointBatcher: utils.WriteCheckpointBatcher;
55
71
  serviceMode: ServiceContextMode;
72
+ readonly #syncConfigParser: ServiceContextSyncConfigParser;
56
73
 
57
74
  constructor(options: ServiceContextOptions) {
58
75
  this.serviceMode = options.serviceMode;
59
76
  const { configuration } = options;
60
77
  this.configuration = configuration;
61
78
 
79
+ this.#syncConfigParser = new ServiceContextSyncConfigParserImpl(() => this.storageEngine.started);
80
+
62
81
  this.lifeCycleEngine = new LifeCycledSystem();
63
82
 
64
83
  this.storageEngine = new storage.StorageEngine({
65
- configuration
84
+ configuration,
85
+ syncConfigParser: this.#syncConfigParser
66
86
  });
67
87
  this.storageEngine.registerListener({
68
88
  storageFatalError: (error) => {
@@ -114,6 +134,13 @@ export class ServiceContextContainer implements ServiceContext {
114
134
  return container.getOptional(replication.ReplicationEngine);
115
135
  }
116
136
 
137
+ /**
138
+ * Returns the service-wide parser shared by routes, replication, and storage.
139
+ */
140
+ get syncConfigParser(): ServiceContextSyncConfigParser {
141
+ return this.#syncConfigParser;
142
+ }
143
+
117
144
  get metricsEngine(): metrics.MetricsEngine {
118
145
  return container.getImplementation(metrics.MetricsEngine);
119
146
  }
@@ -137,3 +164,22 @@ export class ServiceContextContainer implements ServiceContext {
137
164
  return container.getImplementation(identifier);
138
165
  }
139
166
  }
167
+
168
+ /**
169
+ * Registration-capable parser exposed by the service context during module initialization.
170
+ */
171
+ class ServiceContextSyncConfigParserImpl extends storage.SqlSyncConfigParser implements ServiceContextSyncConfigParser {
172
+ constructor(private readonly isStorageStarted: () => boolean) {
173
+ super();
174
+ }
175
+
176
+ /**
177
+ * Registers an extension while preserving one parser definition for the service lifetime.
178
+ */
179
+ override registerParser(extension: AdditionalSyncConfigParser): void {
180
+ if (this.isStorageStarted()) {
181
+ throw new Error('A sync config parser extension cannot be registered after the storage engine has started.');
182
+ }
183
+ super.registerParser(extension);
184
+ }
185
+ }
@@ -18,8 +18,9 @@ export const DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS: ParamLoggingFormatOptions = {
18
18
  * Formats potentially arbitrary parameters for logging.
19
19
  * This limits the number of keys and strings to a maximum length.
20
20
  * A warning key-value is added if the number of keys exceeds the maximum.
21
- * String values exceeding the maximum length are truncated.
21
+ * Keys and string values exceeding the maximum length are truncated.
22
22
  * Non-String values are stringified, the maximum length is then applied.
23
+ * Values that cannot be serialized are replaced with a placeholder.
23
24
  * @param params - The parameters to format.
24
25
  * @param options - The options to use.
25
26
  * @default DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS
@@ -41,20 +42,26 @@ export function limitParamsForLogging(
41
42
  return value;
42
43
  }
43
44
 
44
- return Object.fromEntries(
45
- Object.entries(params).map(([key, value], index) => {
46
- if (index == maxKeyCount) {
47
- return ['⚠️', 'Additional parameters omitted'];
48
- }
45
+ function formatValue(value: unknown): string {
46
+ if (typeof value == 'string') {
47
+ return trimString(value);
48
+ }
49
+ try {
50
+ return trimString(JSON.stringify(value) ?? '[undefined]');
51
+ } catch {
52
+ return trimString('[unserializable]');
53
+ }
54
+ }
49
55
 
50
- if (index > maxKeyCount) {
51
- return [];
52
- }
56
+ return Object.fromEntries(
57
+ Object.entries(params)
58
+ .slice(0, maxKeyCount + 1)
59
+ .map(([key, value], index) => {
60
+ if (index == maxKeyCount) {
61
+ return ['⚠️', 'Additional parameters omitted'];
62
+ }
53
63
 
54
- if (typeof value == 'string') {
55
- return [key, trimString(value)];
56
- }
57
- return [key, trimString(JSON.stringify(value))];
58
- })
64
+ return [trimString(key), formatValue(value)];
65
+ })
59
66
  );
60
67
  }
@@ -19,7 +19,7 @@ export type BucketRequest = t.Decoded<typeof BucketRequest>;
19
19
  */
20
20
  export const RequestedStreamSubscription = t.object({
21
21
  /**
22
- * The defined name of the stream as it appears in sync stream definitions.
22
+ * The defined name of the stream as it appears in Sync Stream definitions.
23
23
  */
24
24
  stream: t.string,
25
25
  /**
@@ -29,7 +29,7 @@ export const RequestedStreamSubscription = t.object({
29
29
  /**
30
30
  * Set when the client wishes to re-assign a different priority to this stream.
31
31
  *
32
- * Streams and sync rules can also assign a default priority, but clients are allowed to override those. This can be
32
+ * Sync Streams and legacy bucket definitions can also assign a default priority, but clients are allowed to override those. This can be
33
33
  * useful when the priority for partial syncs depends on e.g. the current page opened in a client.
34
34
  */
35
35
  override_priority: orNull(t.number)
@@ -49,7 +49,7 @@ export const StreamSubscriptionRequest = t.object({
49
49
  include_defaults: t.boolean.optional(),
50
50
 
51
51
  /**
52
- * An array of sync streams the client has opened explicitly.
52
+ * An array of Sync Streams the client has opened explicitly.
53
53
  */
54
54
  subscriptions: t.array(RequestedStreamSubscription)
55
55
  });
@@ -253,8 +253,8 @@ export interface BucketChecksum {
253
253
  /**
254
254
  * The reason a particular bucket is included in a checkpoint.
255
255
  *
256
- * This information allows clients to associate individual buckets with sync streams they're subscribed to. Having that
257
- * association is useful because it enables clients to track progress for individual sync streams.
256
+ * This information allows clients to associate individual buckets with Sync Streams they're subscribed to. Having that
257
+ * association is useful because it enables clients to track progress for individual Sync Streams.
258
258
  */
259
259
  export type BucketSubscriptionReason = BucketDerivedFromDefaultStream | BucketDerivedFromExplicitSubscription;
260
260
 
@@ -11,6 +11,7 @@ import { describe, expect, test } from 'vitest';
11
11
  import {
12
12
  parsePersistedSyncConfigContent,
13
13
  SerializedSyncPlan,
14
+ SqlSyncConfigParser,
14
15
  updateSyncRulesFromConfig
15
16
  } from '../../src/storage/storage-index.js';
16
17
 
@@ -147,6 +148,7 @@ function restore(compiledPlan: SerializedSyncPlan) {
147
148
  content: yamlWithEvents,
148
149
  compiledPlan,
149
150
  storageVersion: 1,
150
- parseOptions: { defaultSchema: 'test_schema' }
151
+ parseOptions: { defaultSchema: 'test_schema' },
152
+ syncConfigParser: new SqlSyncConfigParser()
151
153
  });
152
154
  }
@@ -0,0 +1,330 @@
1
+ import { SqlSyncConfigParser } from '@/storage/SyncConfigParser.js';
2
+ import { isCompatible, parsePersistedSyncConfigContent, updateSyncRulesFromConfig } from '@/storage/storage-index.js';
3
+ import { ServiceContextContainer, ServiceContextMode } from '@/system/ServiceContext.js';
4
+ import { logger } from '@powersync/lib-services-framework';
5
+ import * as syncRules from '@powersync/service-sync-rules';
6
+ import {
7
+ AdditionalSyncConfigParser,
8
+ DEFAULT_HYDRATION_STATE,
9
+ HydratedSyncConfig,
10
+ nodeSqlite,
11
+ normalizeConnectionConfig,
12
+ PrecompiledSyncConfig,
13
+ SyncConfig
14
+ } from '@powersync/service-sync-rules';
15
+ import * as sqlite from 'node:sqlite';
16
+ import { describe, expect, test, vi } from 'vitest';
17
+
18
+ const STREAMS = /* yaml */ `
19
+ config: { edition: 3 }
20
+ streams:
21
+ orders:
22
+ query: SELECT * FROM orders
23
+ `;
24
+ const CONFIGURED = /* yaml */ `
25
+ config:
26
+ edition: 3
27
+ connections:
28
+ default:
29
+ type: example
30
+ tables:
31
+ orders: { sample: 10 }
32
+ streams:
33
+ orders:
34
+ query: SELECT * FROM orders
35
+ `;
36
+
37
+ // This fixture represents an external module. Core only knows the connection/table maps; sample is module-owned.
38
+ function additionalParser(): AdditionalSyncConfigParser {
39
+ const check = (config: SyncConfig) => {
40
+ for (const connection of Object.values(config.connectionConfig)) {
41
+ if (connection?.type != 'example') continue;
42
+ for (const table of Object.values(connection.tables ?? {})) {
43
+ if (typeof (table as { sample?: number })?.sample != 'number') throw new Error('Invalid persisted sample.');
44
+ if ((table as { sample?: number })?.sample === 13) throw new Error('Sample 13 is unsupported.');
45
+ }
46
+ }
47
+ };
48
+ return {
49
+ id: 'example.tables',
50
+ extendJsonSchema({ schema }) {
51
+ const map = (schema.properties as any).config.properties.connections;
52
+ map.additionalProperties = {
53
+ if: { type: 'object', required: ['type'], properties: { type: { const: 'example' } } },
54
+ then: {
55
+ type: 'object',
56
+ additionalProperties: false,
57
+ required: ['type'],
58
+ properties: {
59
+ type: { const: 'example' },
60
+ tables: {
61
+ type: 'object',
62
+ propertyNames: { minLength: 1 },
63
+ additionalProperties: {
64
+ type: 'object',
65
+ additionalProperties: false,
66
+ properties: { sample: { type: 'number' } }
67
+ }
68
+ }
69
+ }
70
+ },
71
+ else: map.additionalProperties
72
+ };
73
+ },
74
+ parse({ config, context }) {
75
+ const connections = normalizeConnectionConfig(
76
+ (config as { config?: { connections?: unknown } }).config?.connections
77
+ );
78
+ for (const [tag, connection] of Object.entries(connections)) {
79
+ if (connection?.type != 'example') continue;
80
+ context.parsedConfig.connectionConfig = { ...context.parsedConfig.connectionConfig, [tag]: connection };
81
+ const { plan } = context.parsedConfig as PrecompiledSyncConfig;
82
+ plan.moduleData = { ...plan.moduleData, ['example.tables']: null };
83
+ }
84
+ check(context.parsedConfig);
85
+ },
86
+ validatePersisted({ config }) {
87
+ check(config);
88
+ }
89
+ };
90
+ }
91
+
92
+ function deployOptions(yaml = CONFIGURED, parser = new SqlSyncConfigParser([additionalParser()])) {
93
+ return updateSyncRulesFromConfig(parser.parseContent(yaml, { defaultSchema: 'app' }));
94
+ }
95
+
96
+ describe('service sync config parser', () => {
97
+ test('reuses compiled validation until a parser is registered', () => {
98
+ const compile = vi.spyOn(syncRules, 'compileSyncRulesSchemaValidator');
99
+ try {
100
+ const parser = new SqlSyncConfigParser();
101
+ expect(compile).toHaveBeenCalledTimes(1);
102
+ for (let i = 0; i < 2; i++) {
103
+ expect(parser.parseContent(STREAMS, { defaultSchema: 'app' }).errors).toEqual([]);
104
+ expect(() => parser.parseContent(CONFIGURED, { defaultSchema: 'app' })).toThrow();
105
+ }
106
+ expect(compile).toHaveBeenCalledTimes(1);
107
+
108
+ parser.registerParser(additionalParser());
109
+ expect(compile).toHaveBeenCalledTimes(2);
110
+ for (let i = 0; i < 2; i++) {
111
+ expect(parser.parseContent(CONFIGURED, { defaultSchema: 'app' }).errors).toEqual([]);
112
+ expect(() =>
113
+ parser.parseContent(CONFIGURED.replace('sample: 10', 'sample: wrong'), { defaultSchema: 'app' })
114
+ ).toThrow();
115
+ }
116
+ expect(compile).toHaveBeenCalledTimes(2);
117
+ } finally {
118
+ compile.mockRestore();
119
+ }
120
+ });
121
+
122
+ test('composes generic registrations atomically and keeps returned schema copies isolated', () => {
123
+ const parser = new SqlSyncConfigParser();
124
+ const baseSchema = parser.jsonSchema;
125
+ expect(() => parser.parseContent(CONFIGURED, { defaultSchema: 'app' })).toThrow();
126
+ parser.registerParser(additionalParser());
127
+ expect(parser.parseContent(CONFIGURED, { defaultSchema: 'app' }).errors).toEqual([]);
128
+ expect(baseSchema).toEqual(new SqlSyncConfigParser().jsonSchema);
129
+ const composed = parser.jsonSchema;
130
+ delete (composed.properties as any).config.properties.connections;
131
+ expect(parser.parseContent(CONFIGURED, { defaultSchema: 'app' }).errors).toEqual([]);
132
+
133
+ expect(() => parser.registerParser(additionalParser())).toThrow('already registered');
134
+ expect(() => parser.registerParser({ id: '', parse() {} })).toThrow('must not be empty');
135
+ expect(() =>
136
+ parser.registerParser({
137
+ id: 'bad-schema',
138
+ parse() {},
139
+ extendJsonSchema({ schema }) {
140
+ schema.misspelledKeyword = true;
141
+ }
142
+ })
143
+ ).toThrow();
144
+ // The failed schema is not retained and its ID can be used by a corrected registration.
145
+ parser.registerParser({ id: 'bad-schema', parse() {} });
146
+ expect(parser.parseContent(CONFIGURED, { defaultSchema: 'app' }).errors).toEqual([]);
147
+ });
148
+
149
+ test('supplies one parser to storage and closes registration before asynchronous startup completes', async () => {
150
+ const context = new ServiceContextContainer({
151
+ serviceMode: ServiceContextMode.TEST_CONNECTION,
152
+ configuration: {
153
+ storage: { type: 'example' },
154
+ api_parameters: {
155
+ max_data_fetch_concurrency: 1,
156
+ max_buckets_per_connection: 1,
157
+ max_parameter_query_results: 1
158
+ }
159
+ } as never
160
+ });
161
+ const parser = context.syncConfigParser;
162
+ parser.registerParser(additionalParser());
163
+ let finish!: () => void;
164
+ const ready = new Promise<void>((resolve) => {
165
+ finish = resolve;
166
+ });
167
+ const getStorage = vi.fn(async () => {
168
+ await ready;
169
+ return { storage: {} as never, reportStorage: {} as never, shutDown: async () => {}, tearDown: async () => true };
170
+ });
171
+ context.storageEngine.registerProvider({ type: 'example', getStorage });
172
+ const startup = context.storageEngine.start();
173
+ try {
174
+ expect(() => parser.registerParser({ id: 'too-late', parse() {} })).toThrow(
175
+ 'after the storage engine has started'
176
+ );
177
+ finish();
178
+ await startup;
179
+ expect(getStorage).toHaveBeenCalledWith({ resolvedConfig: context.configuration, syncConfigParser: parser });
180
+ expect(context.syncConfigParser).toBe(parser);
181
+ } finally {
182
+ finish();
183
+ await startup;
184
+ await context.storageEngine.shutDown();
185
+ }
186
+ });
187
+
188
+ test('requires recorded modules and semantic validation when restoring a saved plan', () => {
189
+ const parser = new SqlSyncConfigParser([additionalParser()]);
190
+ const compiled = deployOptions();
191
+ const restore = (syncConfigParser = parser, compiledPlan = compiled.config.plan) =>
192
+ parsePersistedSyncConfigContent({
193
+ content: CONFIGURED,
194
+ compiledPlan,
195
+ storageVersion: 2,
196
+ parseOptions: { defaultSchema: 'app' },
197
+ syncConfigParser
198
+ });
199
+ const restored = restore();
200
+ expect(restored.config.connectionConfig).toEqual(compiled.config.parsed.config.connectionConfig);
201
+ expect(
202
+ restored.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) }).connectionConfig
203
+ ).toEqual(restored.config.connectionConfig);
204
+ expect(() => restore(new SqlSyncConfigParser())).toThrow('Missing required sync config parsers: example.tables');
205
+ const malformed = structuredClone(compiled.config.plan!);
206
+ (malformed.plan.connectionConfig!.default!.tables!.orders as any).sample = 'wrong';
207
+ expect(() => restore(parser, malformed)).toThrow();
208
+ (malformed.plan.connectionConfig!.default!.tables!.orders as any).sample = 13;
209
+ expect(() => restore(parser, malformed)).toThrow('Sample 13');
210
+ // A compiled plan is authoritative; its failure must not be hidden by reparsing valid source YAML.
211
+ expect(() => restore(new SqlSyncConfigParser(), null)).toThrow();
212
+ expect(restore(parser, null).config.connectionConfig).toEqual(restored.config.connectionConfig);
213
+ });
214
+
215
+ test('checks all dependencies before hooks and ignores unused registered parsers', () => {
216
+ const validatePersisted = vi.fn();
217
+ const parser = new SqlSyncConfigParser([{ id: 'installed', parse() {}, validatePersisted }]);
218
+ const { config } = parser.parseContent(STREAMS, { defaultSchema: 'app' });
219
+ expect(parser.validatePersisted({ config, context: { defaultSchema: 'app' } })).toEqual([]);
220
+ expect(validatePersisted).not.toHaveBeenCalled();
221
+ (config as PrecompiledSyncConfig).plan.moduleData = { installed: null, missing: null, 'also-missing': null };
222
+ expect(() => parser.validatePersisted({ config, context: { defaultSchema: 'app' } })).toThrow(
223
+ 'Missing required sync config parsers: missing, also-missing'
224
+ );
225
+ expect(validatePersisted).not.toHaveBeenCalled();
226
+ });
227
+
228
+ test('restores transformed module state without applying the authoring schema', () => {
229
+ const parser = new SqlSyncConfigParser([
230
+ {
231
+ id: 'transformed',
232
+ parse({ context }) {
233
+ const { plan } = context.parsedConfig as PrecompiledSyncConfig;
234
+ plan.moduleData = { ...plan.moduleData, ['transformed']: null };
235
+ context.parsedConfig.connectionConfig = {
236
+ default: { type: 'transformed', tables: { orders: { compiled: true } } }
237
+ };
238
+ }
239
+ }
240
+ ]);
241
+ const compiled = deployOptions(STREAMS, parser);
242
+ const restore = (syncConfigParser: SqlSyncConfigParser) =>
243
+ parsePersistedSyncConfigContent({
244
+ content: STREAMS,
245
+ compiledPlan: compiled.config.plan,
246
+ storageVersion: 2,
247
+ parseOptions: { defaultSchema: 'app' },
248
+ syncConfigParser
249
+ });
250
+ expect((restore(parser).config as PrecompiledSyncConfig).plan.moduleData).toEqual({ transformed: null });
251
+ expect(restore(parser).config.connectionConfig).toEqual(compiled.config.parsed.config.connectionConfig);
252
+ expect(() => restore(new SqlSyncConfigParser())).toThrow('Missing required sync config parsers: transformed');
253
+ });
254
+
255
+ test('persists module dependencies without connection options and accepts legacy plans without module data', () => {
256
+ const parser = new SqlSyncConfigParser([
257
+ {
258
+ id: 'required',
259
+ parse({ context }) {
260
+ const { plan } = context.parsedConfig as PrecompiledSyncConfig;
261
+ plan.moduleData = { ...plan.moduleData, ['required']: null };
262
+ }
263
+ }
264
+ ]);
265
+ const compiled = deployOptions(STREAMS, parser);
266
+ expect(compiled.config.plan!.plan.version).toBe(3);
267
+ expect(compiled.config.plan!.plan.moduleData).toEqual({ required: null });
268
+ const restore = (compiledPlan = compiled.config.plan) =>
269
+ parsePersistedSyncConfigContent({
270
+ content: STREAMS,
271
+ compiledPlan,
272
+ storageVersion: 2,
273
+ parseOptions: { defaultSchema: 'app' },
274
+ syncConfigParser: new SqlSyncConfigParser()
275
+ });
276
+ expect(() => restore()).toThrow('Missing required sync config parsers: required');
277
+ const malformed = structuredClone(compiled.config.plan!);
278
+ (malformed.plan as any).moduleData = 'required';
279
+ expect(() => restore(malformed)).toThrow('Invalid sync config module data');
280
+ const legacy = deployOptions(STREAMS, new SqlSyncConfigParser());
281
+ expect((restore(legacy.config.plan).config as PrecompiledSyncConfig).plan.moduleData).toBeUndefined();
282
+ });
283
+
284
+ test('runs generic persisted validators with context and retains their warnings', () => {
285
+ const hook: AdditionalSyncConfigParser = {
286
+ id: 'core-check',
287
+ parse({ context }) {
288
+ const { plan } = context.parsedConfig as PrecompiledSyncConfig;
289
+ plan.moduleData = { ...plan.moduleData, ['core-check']: null };
290
+ },
291
+ validatePersisted({ config, context }) {
292
+ expect(config.getSourceTables()[0].name).toBe('orders');
293
+ expect(context.defaultSchema).toBe('app');
294
+ context.reportDiagnostic({ level: 'warning', message: 'Review this configuration.' });
295
+ }
296
+ };
297
+ const parser = new SqlSyncConfigParser([hook]);
298
+ const parsed = parser.parseContent(STREAMS, { defaultSchema: 'app' });
299
+ expect(parser.validatePersisted({ config: parsed.config, context: { defaultSchema: 'app' } })).toEqual([
300
+ expect.objectContaining({ type: 'warning', message: 'Review this configuration.' })
301
+ ]);
302
+ hook.validatePersisted = ({ context }) => context.reportDiagnostic({ level: 'fatal', message: 'Rejected.' });
303
+ expect(() => parser.validatePersisted({ config: parsed.config, context: { defaultSchema: 'app' } })).toThrow(
304
+ 'Rejected.'
305
+ );
306
+ });
307
+
308
+ test('reuses equal connection options but requires replacement for additions, changes and removals', () => {
309
+ const first = deployOptions();
310
+ const same = deployOptions(CONFIGURED.replace('SELECT * FROM orders', 'SELECT id FROM orders'));
311
+ const changed = deployOptions(CONFIGURED.replace('sample: 10', 'sample: 20'));
312
+ const absent = deployOptions(STREAMS);
313
+ expect(isCompatible([first.config.plan], same.config, logger)).toBe(true);
314
+ expect(isCompatible([first.config.plan], changed.config, logger)).toBe(false);
315
+ expect(isCompatible([first.config.plan], absent.config, logger)).toBe(false);
316
+ expect(isCompatible([absent.config.plan], first.config, logger)).toBe(false);
317
+ });
318
+
319
+ test('merges equal connection config once', () => {
320
+ const first = deployOptions().config.parsed.config;
321
+ const same = deployOptions(CONFIGURED.replace('SELECT * FROM orders', 'SELECT id FROM orders')).config.parsed
322
+ .config;
323
+ const hydrate = (definitions: SyncConfig[]) =>
324
+ new HydratedSyncConfig({
325
+ definitions,
326
+ createParams: { hydrationState: DEFAULT_HYDRATION_STATE, sqlite: nodeSqlite(sqlite) }
327
+ });
328
+ expect(hydrate([first, same]).connectionConfig).toEqual(first.connectionConfig);
329
+ });
330
+ });