@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,6 +1,7 @@
1
1
  import * as framework from '@powersync/lib-services-framework';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
+ import { pathToFileURL } from 'url';
4
5
  import * as system from '../system/system-index.js';
5
6
 
6
7
  /**
@@ -28,7 +29,7 @@ export abstract class AbstractPowerSyncMigrationAgent extends framework.Abstract
28
29
 
29
30
  return await Promise.all(
30
31
  migrations.map(async (migration) => {
31
- const module = await import(path.resolve(migrationsDir, migration));
32
+ const module = await import(pathToFileURL(path.resolve(migrationsDir, migration)).href);
32
33
  return {
33
34
  name: path.basename(migration).replace(path.extname(migration), ''),
34
35
  up: module.up,
@@ -165,10 +165,14 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
165
165
  // versions before that check was added, so we keep the lock for now - for where te service version and sync config is updated at
166
166
  // the same time.
167
167
 
168
+ const parsed = this.options.storageEngine.activeBucketStorage.syncConfigParser.parseContent(loadedSyncConfig, {
169
+ schema: undefined,
170
+ defaultSchema: 'not_applicable',
171
+ throwOnError: this.syncRuleProvider.exitOnError
172
+ });
168
173
  const { lock } = await this.storage.configureSyncRules(
169
- storage.updateSyncRulesFromYaml(loadedSyncConfig, {
174
+ storage.updateSyncRulesFromConfig(parsed, {
170
175
  lock: true,
171
- validate: this.syncRuleProvider.exitOnError,
172
176
  version_label: versionLabel
173
177
  })
174
178
  );
@@ -182,7 +186,7 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
182
186
  throw e;
183
187
  }
184
188
  } else {
185
- this.logger.info('No sync streams or rules configured - configure via API');
189
+ this.logger.info('No sync config configured - configure via API');
186
190
  }
187
191
  let useFastRefresh = true;
188
192
  const fastRefreshDeadline = Date.now() + FAST_REFRESH_TIMEOUT_MS;
@@ -290,7 +294,7 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
290
294
  } else {
291
295
  lock = await replicationStream.lock();
292
296
  }
293
- const syncRuleStorage = this.storage.getInstance(replicationStream);
297
+ const syncRuleStorage = this.storage.getInstance(replicationStream, { replicationLock: lock });
294
298
  const newJob = this.createJob({
295
299
  lock: lock,
296
300
  storage: syncRuleStorage
@@ -63,14 +63,6 @@ export abstract class ReplicationModule<TConfig extends types.configFile.DataSou
63
63
  */
64
64
  protected abstract createReplicator(context: system.ServiceContext): AbstractReplicator;
65
65
 
66
- /**
67
- * Any additional initialization specific to the module should be added here. Will be called if necessary after the
68
- * main initialization has been completed
69
- * @param context
70
- * @protected
71
- */
72
- protected abstract onInitialized(context: system.ServiceContext): Promise<void>;
73
-
74
66
  public abstract testConnection(config: TConfig): Promise<ConnectionTestResult>;
75
67
 
76
68
  /**
@@ -100,8 +92,6 @@ export abstract class ReplicationModule<TConfig extends types.configFile.DataSou
100
92
 
101
93
  context.replicationEngine?.register(this.createReplicator(context));
102
94
  context.routerEngine?.registerAPI(this.createRouteAPIAdapter());
103
-
104
- await this.onInitialized(context);
105
95
  }
106
96
 
107
97
  protected decodeConfig(config: TConfig): void {
@@ -1,13 +1,7 @@
1
1
  import * as sqlite from 'node:sqlite';
2
2
 
3
3
  import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
4
- import {
5
- HydratedSyncConfig,
6
- nodeSqlite,
7
- SourceSchema,
8
- SqlSyncRules,
9
- StaticSchema
10
- } from '@powersync/service-sync-rules';
4
+ import { HydratedSyncConfig, nodeSqlite, SourceSchema, StaticSchema } from '@powersync/service-sync-rules';
11
5
  import { internal_routes } from '@powersync/service-types';
12
6
 
13
7
  import { DEFAULT_HYDRATION_STATE } from '@powersync/service-sync-rules';
@@ -158,14 +152,18 @@ export const reprocess = routeDefinition({
158
152
  });
159
153
  }
160
154
  // There are some differences between this and using asUpdateOptions():
161
- // 1. This always re-parses the source YAML. If there are changes to the sync stream compiler, that can affect the sync plan.
155
+ // 1. This always re-parses the source YAML. If there are changes to the Sync Streams compiler, that can affect the sync plan.
162
156
  // 2. If the source does not set the storage version, this will update it do the current version.
163
157
  // We can consider tweaking this behavior in the future.
158
+ const parsed = service_context.syncConfigParser.parseContent(active.content.sync_rules_content, {
159
+ ...apiHandler.getParseSyncRulesOptions(),
160
+ schema: undefined,
161
+ // This sync config already passed validation. But if the config is not valid anymore due
162
+ // to a service change, we do want to report the error here.
163
+ throwOnError: true
164
+ });
164
165
  const new_rules = await activeBucketStorage.updateSyncRules(
165
- storage.updateSyncRulesFromYaml(active.content.sync_rules_content, {
166
- // This sync config already passed validation. But if the config is not valid anymore due
167
- // to a service change, we do want to report the error here.
168
- validate: true,
166
+ storage.updateSyncRulesFromConfig(parsed, {
169
167
  version_label: active.content.version_label,
170
168
  forceNewReplicationStream: true
171
169
  })
@@ -188,16 +186,16 @@ export const reprocess = routeDefinition({
188
186
 
189
187
  class FakeSyncRulesContentForValidation extends storage.PersistedSyncConfigContent {
190
188
  constructor(
191
- private readonly apiHandler: api.RouteAPI,
189
+ private readonly validationParser: storage.SyncConfigParser,
192
190
  private readonly schema: SourceSchema,
193
191
  data: storage.PersistedSyncConfigContentData
194
192
  ) {
195
- super(data);
193
+ super(data, validationParser);
196
194
  }
197
195
 
198
196
  parsed(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
199
- const syncConfig = SqlSyncRules.fromYaml(this.sync_rules_content, {
200
- ...this.apiHandler.getParseSyncRulesOptions(),
197
+ const syncConfig = this.validationParser.parseContent(this.sync_rules_content, {
198
+ ...options,
201
199
  schema: this.schema
202
200
  });
203
201
 
@@ -237,7 +235,7 @@ export const validate = routeDefinition({
237
235
  const schemaData = await api.getConnectionsSchema(apiHandler);
238
236
  const schema = new StaticSchema(schemaData.connections);
239
237
 
240
- const sync_rules = new FakeSyncRulesContentForValidation(apiHandler, schema, {
238
+ const sync_rules = new FakeSyncRulesContentForValidation(service_context.syncConfigParser, schema, {
241
239
  // Dummy values
242
240
  replicationStreamId: 0,
243
241
  replicationStreamName: '',
@@ -16,6 +16,10 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
16
16
  const { routerEngine, metricsEngine, syncContext } = service_context;
17
17
  const streamStart = Date.now();
18
18
 
19
+ // Format client-controlled values before registering connection resources.
20
+ const formattedAppMetadata = params.app_metadata ? limitParamsForLogging(params.app_metadata) : undefined;
21
+ const formattedClientParams = params.parameters ? limitParamsForLogging(params.parameters) : undefined;
22
+
19
23
  logger.defaultMeta = {
20
24
  ...logger.defaultMeta,
21
25
  user_id: context.token_payload!.userIdJson,
@@ -99,13 +103,12 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
99
103
  tracker.setCompressed(connection.tracker.encoding);
100
104
  }
101
105
 
102
- const formattedAppMetadata = params.app_metadata ? limitParamsForLogging(params.app_metadata) : undefined;
103
- logger.info('Sync stream started', {
104
- app_metadata: formattedAppMetadata,
105
- client_params: params.parameters ? limitParamsForLogging(params.parameters) : undefined
106
- });
107
-
108
106
  try {
107
+ logger.info('Sync stream started', {
108
+ app_metadata: formattedAppMetadata,
109
+ client_params: formattedClientParams
110
+ });
111
+
109
112
  for await (const data of sync.streamResponse({
110
113
  syncContext: syncContext,
111
114
  bucketStorage: bucketStorage,
@@ -1,10 +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 { SyncConfigWithErrors, SyncRulesErrors } from '@powersync/service-sync-rules';
3
3
  import type { FastifyPluginAsync } from 'fastify';
4
4
  import * as t from 'ts-codec';
5
5
 
6
6
  import { RouteAPI } from '../../api/RouteAPI.js';
7
- import { updateSyncRulesFromConfig, updateSyncRulesFromYaml } from '../../storage/BucketStorageFactory.js';
7
+ import { updateSyncRulesFromConfig } from '../../storage/BucketStorageFactory.js';
8
+ import { SyncConfigParser } from '../../storage/SyncConfigParser.js';
8
9
  import { authApi } from '../auth.js';
9
10
  import { routeDefinition } from '../router.js';
10
11
 
@@ -56,7 +57,7 @@ export const deploySyncRules = routeDefinition({
56
57
 
57
58
  try {
58
59
  const apiHandler = service_context.routerEngine.getAPI();
59
- syncConfig = SqlSyncRules.fromYaml(content, {
60
+ syncConfig = service_context.syncConfigParser.parseContent(content, {
60
61
  ...apiHandler.getParseSyncRulesOptions(),
61
62
  // We don't do any schema-level validation at this point
62
63
  schema: undefined
@@ -94,7 +95,7 @@ export const validateSyncRules = routeDefinition({
94
95
  const { service_context } = payload.context;
95
96
  const apiHandler = service_context.routerEngine.getAPI();
96
97
 
97
- const info = await debugSyncRules(apiHandler, content);
98
+ const info = await debugSyncRules(apiHandler, content, service_context.syncConfigParser);
98
99
 
99
100
  return replyPrettyJson(info);
100
101
  }
@@ -121,10 +122,12 @@ export const currentSyncRules = routeDefinition({
121
122
 
122
123
  const sync_rules = active.content;
123
124
  const apiHandler = service_context.routerEngine.getAPI();
124
- const info = await debugSyncRules(apiHandler, sync_rules.sync_rules_content);
125
+ const info = await debugSyncRules(apiHandler, sync_rules.sync_rules_content, service_context.syncConfigParser);
125
126
  const next = await activeBucketStorage.getDeployingSyncConfig();
126
127
 
127
- const next_info = next ? await debugSyncRules(apiHandler, next.content.sync_rules_content) : null;
128
+ const next_info = next
129
+ ? await debugSyncRules(apiHandler, next.content.sync_rules_content, service_context.syncConfigParser)
130
+ : null;
128
131
 
129
132
  const response = {
130
133
  current: {
@@ -176,11 +179,15 @@ export const reprocessSyncRules = routeDefinition({
176
179
  }
177
180
 
178
181
  const sync_rules = active.content;
182
+ const parsed = payload.context.service_context.syncConfigParser.parseContent(sync_rules.sync_rules_content, {
183
+ ...payload.context.service_context.routerEngine.getAPI().getParseSyncRulesOptions(),
184
+ schema: undefined,
185
+ // This sync config already passed validation. But if the rules are not valid anymore due
186
+ // to a service change, we do want to report the error here.
187
+ throwOnError: true
188
+ });
179
189
  const new_rules = await activeBucketStorage.updateSyncRules(
180
- updateSyncRulesFromYaml(sync_rules.sync_rules_content, {
181
- // This sync config already passed validation. But if the rules are not valid anymore due
182
- // to a service change, we do want to report the error here.
183
- validate: true,
190
+ updateSyncRulesFromConfig(parsed, {
184
191
  version_label: sync_rules.version_label,
185
192
  forceNewReplicationStream: true
186
193
  })
@@ -201,9 +208,9 @@ function replyPrettyJson(payload: any) {
201
208
  });
202
209
  }
203
210
 
204
- async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
211
+ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string, syncConfigParser: SyncConfigParser) {
205
212
  try {
206
- const rules = SqlSyncRules.fromYaml(sync_rules, {
213
+ const rules = syncConfigParser.parseContent(sync_rules, {
207
214
  ...apiHandler.getParseSyncRulesOptions(),
208
215
  // No schema-based validation at this point
209
216
  schema: undefined
@@ -4,7 +4,6 @@ import {
4
4
  SerializedSyncPlan as RawSerializedSyncPlan,
5
5
  SerializedCompatibilityContext,
6
6
  serializeSyncPlan,
7
- SqlSyncRules,
8
7
  SyncConfigWithErrors
9
8
  } from '@powersync/service-sync-rules';
10
9
  import { ReplicationError } from '@powersync/service-types';
@@ -14,8 +13,16 @@ import { PersistedSyncConfigContent } from './PersistedSyncConfigContent.js';
14
13
  import { ReplicationEventPayload } from './ReplicationEventPayload.js';
15
14
  import { ReplicationLock } from './ReplicationLock.js';
16
15
  import { ReportStorage } from './ReportStorage.js';
16
+ import { SqlSyncConfigParser, SyncConfigParser } from './SyncConfigParser.js';
17
17
  import { SyncRulesBucketStorage } from './SyncRulesBucketStorage.js';
18
18
 
19
+ export interface BucketStorageFactoryOptions {
20
+ /**
21
+ * Service-wide parser assembled before storage startup.
22
+ */
23
+ syncConfigParser: SyncConfigParser;
24
+ }
25
+
19
26
  /**
20
27
  * Represents a configured storage provider.
21
28
  *
@@ -28,6 +35,16 @@ export abstract class BucketStorageFactory
28
35
  extends BaseObserver<BucketStorageFactoryListener>
29
36
  implements AsyncDisposable
30
37
  {
38
+ readonly syncConfigParser: SyncConfigParser;
39
+
40
+ /**
41
+ * Creates a storage factory using the service-wide parser assembled before storage startup.
42
+ */
43
+ constructor(options: BucketStorageFactoryOptions) {
44
+ super();
45
+ this.syncConfigParser = options.syncConfigParser;
46
+ }
47
+
31
48
  /**
32
49
  * Update sync config from configuration, if changed.
33
50
  */
@@ -117,10 +134,14 @@ export abstract class BucketStorageFactory
117
134
  */
118
135
  abstract getSystemIdentifier(): Promise<BucketStorageSystemIdentifier>;
119
136
 
120
- abstract [Symbol.asyncDispose](): PromiseLike<void>;
137
+ async [Symbol.asyncDispose](): Promise<void> {
138
+ await this.iterateAsyncListeners(async (listener) => listener.beforeDispose?.());
139
+ }
121
140
  }
122
141
 
123
142
  export interface BucketStorageFactoryListener {
143
+ /** Release owned resources before the factory closes its database connections. */
144
+ beforeDispose: () => Promise<void>;
124
145
  syncStorageCreated: (storage: SyncRulesBucketStorage) => void;
125
146
  replicationEvent: (event: ReplicationEventPayload) => void;
126
147
  }
@@ -177,7 +198,7 @@ export interface UpdateSyncRulesOptions {
177
198
  config: {
178
199
  yaml: string;
179
200
  /**
180
- * The serialized sync plan for the sync configuration, or `null` for configurations not using the sync stream
201
+ * The serialized sync plan for the sync configuration, or `null` for configurations not using the Sync Streams
181
202
  * compiler.
182
203
  */
183
204
  plan: SerializedSyncPlan | null;
@@ -223,14 +244,15 @@ export function updateSyncRulesFromYaml(
223
244
  content: string,
224
245
  options?: Omit<UpdateSyncRulesOptions, 'config'> & { validate?: boolean }
225
246
  ): UpdateSyncRulesOptions {
226
- const config = SqlSyncRules.fromYaml(content, {
247
+ const { validate, ...updateOptions } = options ?? {};
248
+ const config = new SqlSyncConfigParser().parseContent(content, {
227
249
  // No schema-based validation at this point
228
250
  schema: undefined,
229
251
  defaultSchema: options?.defaultSchema ?? 'not_applicable', // Not needed for validation
230
- throwOnError: options?.validate ?? false
252
+ throwOnError: validate ?? false
231
253
  });
232
254
 
233
- return updateSyncRulesFromConfig(config, options);
255
+ return updateSyncRulesFromConfig(config, updateOptions);
234
256
  }
235
257
 
236
258
  export function updateSyncRulesFromConfig(
@@ -258,6 +280,11 @@ export function updateSyncRulesFromConfig(
258
280
  }
259
281
 
260
282
  export interface GetIntanceOptions {
283
+ /**
284
+ * The job lease, including a lease acquired during initial configuration.
285
+ * Required for writing; optional for reading.
286
+ */
287
+ replicationLock?: ReplicationLock;
261
288
  /**
262
289
  * Set to true to skip trigger any events for creating the instance.
263
290
  *
@@ -2,9 +2,10 @@ import { logger as defaultLogger, ErrorCode, ServiceError } from '@powersync/lib
2
2
  import { Logger } from 'winston';
3
3
  import { SyncRuleState } from './BucketStorage.js';
4
4
  import type { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
5
- import type { ParseSyncConfigOptions, PersistedSyncConfigContent } from './PersistedSyncConfigContent.js';
5
+ import type { PersistedSyncConfigContent } from './PersistedSyncConfigContent.js';
6
6
  import { ReplicationLock } from './ReplicationLock.js';
7
7
  import { STORAGE_VERSION_CONFIG, StorageVersionConfig } from './StorageVersionConfig.js';
8
+ import type { ParseSyncConfigOptions } from './SyncConfigParser.js';
8
9
 
9
10
  export abstract class PersistedReplicationStream implements PersistedReplicationStreamData {
10
11
  readonly replicationStreamId: number;
@@ -10,7 +10,6 @@ import {
10
10
  HydrationState,
11
11
  nodeSqlite,
12
12
  PrecompiledSyncConfig,
13
- SqlSyncRules,
14
13
  SyncConfigWithErrors,
15
14
  versionedHydrationState,
16
15
  YamlError
@@ -22,20 +21,22 @@ import { SerializedSyncPlan, UpdateSyncRulesOptions } from './BucketStorageFacto
22
21
  import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
23
22
  import { PersistedSyncConfigStatus } from './PersistedSyncConfigStatus.js';
24
23
  import { STORAGE_VERSION_CONFIG, StorageVersionConfig } from './StorageVersionConfig.js';
24
+ import { ParseSyncConfigOptions, SyncConfigParser } from './SyncConfigParser.js';
25
25
 
26
26
  export interface ParsePersistedSyncConfigContentOptions {
27
27
  content: string;
28
28
  compiledPlan: SerializedSyncPlan | null;
29
29
  storageVersion: number;
30
30
  parseOptions: ParseSyncConfigOptions;
31
+ syncConfigParser: SyncConfigParser;
31
32
  }
32
33
 
33
34
  export function parsePersistedSyncConfigContent(options: ParsePersistedSyncConfigContentOptions): SyncConfigWithErrors {
34
- const { content, compiledPlan, storageVersion, parseOptions } = options;
35
+ const { content, compiledPlan, storageVersion, parseOptions, syncConfigParser } = options;
35
36
 
36
37
  if (compiledPlan == null) {
37
38
  // Fallback: Only parse from YAML if no compiled plan is available.
38
- return SqlSyncRules.fromYaml(content, parseOptions);
39
+ return syncConfigParser.parseContent(content, parseOptions);
39
40
  }
40
41
 
41
42
  const plan = deserializeSyncPlan(compiledPlan.plan);
@@ -69,6 +70,12 @@ export function parsePersistedSyncConfigContent(options: ParsePersistedSyncConfi
69
70
  // Note: If the original content did not define a storage version, this will still set the storage version.
70
71
  // This means asUpdateOptions will not change the storage version, even if the default changes.
71
72
  precompiled.storageVersion = storageVersion;
73
+ errors.push(
74
+ ...syncConfigParser.validatePersisted({
75
+ config: precompiled,
76
+ context: { defaultSchema: parseOptions.defaultSchema }
77
+ })
78
+ );
72
79
 
73
80
  if (compiledPlan.errors) {
74
81
  for (const error of compiledPlan.errors) {
@@ -105,7 +112,10 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
105
112
  readonly syncConfigState: SyncRuleState;
106
113
  readonly version_label: string | undefined;
107
114
 
108
- constructor(data: PersistedSyncConfigContentData) {
115
+ constructor(
116
+ data: PersistedSyncConfigContentData,
117
+ private readonly syncConfigParser: SyncConfigParser
118
+ ) {
109
119
  this.replicationStreamId = data.replicationStreamId;
110
120
  this.sync_rules_content = data.sync_rules_content;
111
121
  this.compiled_plan = data.compiled_plan;
@@ -144,7 +154,8 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
144
154
  content: this.sync_rules_content,
145
155
  compiledPlan: this.compiled_plan,
146
156
  storageVersion: this.storageVersion,
147
- parseOptions: options
157
+ parseOptions: options,
158
+ syncConfigParser: this.syncConfigParser
148
159
  });
149
160
  }
150
161
 
@@ -202,6 +213,3 @@ export interface PersistedSyncConfigContentData {
202
213
  readonly version_label?: string;
203
214
  }
204
215
  export type PersistedSyncConfigId = string;
205
- export interface ParseSyncConfigOptions {
206
- defaultSchema: string;
207
- }
@@ -2,9 +2,11 @@ import { BaseObserver, logger, ServiceError } from '@powersync/lib-services-fram
2
2
  import { ResolvedPowerSyncConfig } from '../util/util-index.js';
3
3
  import { BucketStorageFactory } from './BucketStorageFactory.js';
4
4
  import { ActiveStorage, StorageProvider } from './StorageProvider.js';
5
+ import { SyncConfigParser } from './SyncConfigParser.js';
5
6
 
6
7
  export type StorageEngineOptions = {
7
8
  configuration: ResolvedPowerSyncConfig;
9
+ syncConfigParser: SyncConfigParser;
8
10
  };
9
11
 
10
12
  export interface StorageEngineListener {
@@ -15,6 +17,7 @@ export interface StorageEngineListener {
15
17
  export class StorageEngine extends BaseObserver<StorageEngineListener> {
16
18
  // TODO: This will need to revisited when we actually support multiple storage providers.
17
19
  private storageProviders: Map<string, StorageProvider> = new Map();
20
+ private startupStarted = false;
18
21
  private currentActiveStorage: ActiveStorage | null = null;
19
22
 
20
23
  constructor(private options: StorageEngineOptions) {
@@ -33,6 +36,10 @@ export class StorageEngine extends BaseObserver<StorageEngineListener> {
33
36
  return this.currentActiveStorage;
34
37
  }
35
38
 
39
+ get started(): boolean {
40
+ return this.startupStarted;
41
+ }
42
+
36
43
  /**
37
44
  * Register a provider which generates a {@link BucketStorageFactory}
38
45
  * given the matching config specified in the loaded {@link ResolvedPowerSyncConfig}
@@ -42,10 +49,12 @@ export class StorageEngine extends BaseObserver<StorageEngineListener> {
42
49
  }
43
50
 
44
51
  public async start(): Promise<void> {
52
+ this.startupStarted = true;
45
53
  logger.info('Starting Storage Engine...');
46
54
  const { configuration } = this.options;
47
55
  this.currentActiveStorage = await this.storageProviders.get(configuration.storage.type)!.getStorage({
48
- resolvedConfig: configuration
56
+ resolvedConfig: configuration,
57
+ syncConfigParser: this.options.syncConfigParser
49
58
  });
50
59
  this.iterateListeners((cb) => cb.storageActivated?.(this.activeBucketStorage));
51
60
  this.currentActiveStorage.onFatalError?.((error) => {
@@ -2,6 +2,7 @@ import { ServiceError } from '@powersync/lib-services-framework';
2
2
  import * as util from '../util/util-index.js';
3
3
  import { BucketStorageFactory } from './BucketStorageFactory.js';
4
4
  import { ReportStorage } from './ReportStorage.js';
5
+ import { SyncConfigParser } from './SyncConfigParser.js';
5
6
 
6
7
  export interface ActiveStorage {
7
8
  storage: BucketStorageFactory;
@@ -19,6 +20,10 @@ export interface ActiveStorage {
19
20
  export interface GetStorageOptions {
20
21
  // TODO: This should just be the storage config. Update once the slot name prefix coupling has been removed from the storage
21
22
  resolvedConfig: util.ResolvedPowerSyncConfig;
23
+ /**
24
+ * Service-wide parser containing every extension registered during module initialization.
25
+ */
26
+ syncConfigParser: SyncConfigParser;
22
27
  }
23
28
 
24
29
  /**
@@ -31,5 +36,8 @@ export interface StorageProvider {
31
36
  */
32
37
  type: string;
33
38
 
39
+ /**
40
+ * Creates active storage during storage-engine startup using the fully assembled service parser.
41
+ */
34
42
  getStorage(options: GetStorageOptions): Promise<ActiveStorage>;
35
43
  }
@@ -0,0 +1,121 @@
1
+ import {
2
+ AdditionalSyncConfigParser,
3
+ compileSyncRulesSchemaValidator,
4
+ createSyncRulesSchema,
5
+ JsonObject,
6
+ PrecompiledSyncConfig,
7
+ SqlSyncRules,
8
+ SyncConfig,
9
+ SyncConfigDiagnostic,
10
+ SyncConfigWithErrors,
11
+ SyncRulesErrors,
12
+ SyncRulesOptions,
13
+ SyncRulesSchemaValidator,
14
+ validateAdditionalSyncConfigParsers,
15
+ YamlError
16
+ } from '@powersync/service-sync-rules';
17
+
18
+ export type ParseSyncConfigOptions = Omit<SyncRulesOptions, 'parsers' | 'schemaValidator'>;
19
+
20
+ /**
21
+ * Shared by service routes, deployment, replication and persisted storage; only initialization can register hooks.
22
+ */
23
+ export interface SyncConfigParser {
24
+ /**
25
+ * A JSON schema which can be used to validate the format of a supplied sync config.
26
+ */
27
+ readonly jsonSchema: JsonObject;
28
+
29
+ /**
30
+ * Parses string content.
31
+ *
32
+ * @returns The parsed {@link SyncConfigWithErrors}
33
+ */
34
+ parseContent(content: string, options: ParseSyncConfigOptions): SyncConfigWithErrors;
35
+
36
+ /**
37
+ * Validates a persisted/parsed sync config.
38
+ *
39
+ * @returns Any errors detected.
40
+ */
41
+ validatePersisted(options: { config: SyncConfig; context: { defaultSchema: string } }): YamlError[];
42
+ }
43
+
44
+ export class SqlSyncConfigParser implements SyncConfigParser {
45
+ readonly #parsers: AdditionalSyncConfigParser[];
46
+ #jsonSchema: JsonObject;
47
+ #schemaValidator: SyncRulesSchemaValidator;
48
+
49
+ constructor(parsers: readonly AdditionalSyncConfigParser[] = []) {
50
+ validateAdditionalSyncConfigParsers(parsers);
51
+ this.#parsers = [...parsers];
52
+ this.#jsonSchema = createSyncRulesSchema(parsers);
53
+ this.#schemaValidator = compileSyncRulesSchemaValidator(this.#jsonSchema);
54
+ }
55
+
56
+ get jsonSchema(): JsonObject {
57
+ // Tooling can customize its copy without changing validation for the running service.
58
+ return structuredClone(this.#jsonSchema);
59
+ }
60
+
61
+ /**
62
+ * Validate the candidate composition before mutating the registry.
63
+ */
64
+ registerParser(parser: AdditionalSyncConfigParser): void {
65
+ const parsers = [...this.#parsers, parser];
66
+ validateAdditionalSyncConfigParsers(parsers);
67
+ const schema = createSyncRulesSchema(parsers);
68
+ const validator = compileSyncRulesSchemaValidator(schema);
69
+ this.#parsers.push(parser);
70
+ this.#jsonSchema = schema;
71
+ this.#schemaValidator = validator;
72
+ }
73
+
74
+ parseContent(content: string, options: ParseSyncConfigOptions): SyncConfigWithErrors {
75
+ return SqlSyncRules.fromYaml(content, {
76
+ ...options,
77
+ parsers: this.#parsers,
78
+ schemaValidator: this.#schemaValidator
79
+ });
80
+ }
81
+
82
+ validatePersisted({ config, context }: { config: SyncConfig; context: { defaultSchema: string } }): YamlError[] {
83
+ /**
84
+ * Ensure that all modules which created a persisted SyncConfig
85
+ * are currently loaded.
86
+ * This prevents a case for a SyncConfig with missing external modules to be loaded.
87
+ */
88
+ const requiredIds = new Set(
89
+ config instanceof PrecompiledSyncConfig ? Object.keys(config.plan.moduleData ?? {}) : []
90
+ );
91
+ const registeredIds = new Set(this.#parsers.map((parser) => parser.id));
92
+ const missingIds = [...requiredIds].filter((id) => !registeredIds.has(id));
93
+ if (missingIds.length != 0) {
94
+ throw new Error(`Missing required sync config parsers: ${missingIds.join(', ')}`);
95
+ }
96
+
97
+ const errors: YamlError[] = [];
98
+ const reportDiagnostic = (diagnostic: SyncConfigDiagnostic) => {
99
+ const location = diagnostic.location;
100
+ const error = new YamlError(
101
+ new Error(diagnostic.message),
102
+ location && {
103
+ start: location.start_offset,
104
+ end: location.end_offset
105
+ }
106
+ );
107
+ error.type = diagnostic.level;
108
+ errors.push(error);
109
+ };
110
+
111
+ for (const parser of this.#parsers) {
112
+ if (!requiredIds.has(parser.id)) continue;
113
+ /**
114
+ * This allows additional validation of module specific semantics
115
+ */
116
+ parser.validatePersisted?.({ config, context: { ...context, reportDiagnostic } });
117
+ }
118
+ if (errors.some((error) => error.type == 'fatal')) throw new SyncRulesErrors(errors);
119
+ return errors;
120
+ }
121
+ }
@@ -11,11 +11,11 @@ import * as util from '../util/util-index.js';
11
11
  import { BucketStorageBatch, FlushedResult, SaveUpdate } from './BucketStorageBatch.js';
12
12
  import { BucketStorageFactory } from './BucketStorageFactory.js';
13
13
  import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
14
- import { ParseSyncConfigOptions } from './PersistedSyncConfigContent.js';
15
14
  import { SourceEntityDescriptor } from './SourceEntity.js';
16
15
  import { SourceTable } from './SourceTable.js';
17
16
  import { SourceTableCandidateReconciler } from './SourceTableReconciler.js';
18
17
  import { StorageVersionConfig } from './StorageVersionConfig.js';
18
+ import { ParseSyncConfigOptions } from './SyncConfigParser.js';
19
19
  import { SyncStorageWriteCheckpointAPI } from './WriteCheckpointAPI.js';
20
20
 
21
21
  /**
@@ -402,6 +402,8 @@ export interface CompactOptions {
402
402
  }
403
403
 
404
404
  export interface CompactInitialReplicationOptions {
405
+ /** Internal/testing use: bypass the new-chunk threshold in MongoDB V3/V4 storage. */
406
+ forceChunkCompaction?: boolean;
405
407
  /**
406
408
  * Compact data up to this op id.
407
409
  *