@powersync/service-core 1.24.0 → 1.26.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 (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/api/diagnostics.js +2 -0
  3. package/dist/api/diagnostics.js.map +1 -1
  4. package/dist/entry/commands/compact-action.js +28 -24
  5. package/dist/entry/commands/compact-action.js.map +1 -1
  6. package/dist/metrics/metrics-interfaces.d.ts +5 -1
  7. package/dist/metrics/metrics-interfaces.js.map +1 -1
  8. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.d.ts +36 -0
  9. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js +70 -0
  10. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js.map +1 -0
  11. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js +6 -1
  12. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js.map +1 -1
  13. package/dist/metrics/open-telemetry/util.js +7 -3
  14. package/dist/metrics/open-telemetry/util.js.map +1 -1
  15. package/dist/replication/AbstractReplicator.js +16 -6
  16. package/dist/replication/AbstractReplicator.js.map +1 -1
  17. package/dist/routes/configure-fastify.d.ts +9 -0
  18. package/dist/routes/endpoints/admin.d.ts +18 -0
  19. package/dist/routes/endpoints/admin.js +3 -1
  20. package/dist/routes/endpoints/admin.js.map +1 -1
  21. package/dist/routes/endpoints/sync-rules.js +3 -1
  22. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  23. package/dist/storage/BucketStorageBatch.d.ts +2 -1
  24. package/dist/storage/BucketStorageBatch.js.map +1 -1
  25. package/dist/storage/BucketStorageFactory.d.ts +28 -4
  26. package/dist/storage/BucketStorageFactory.js +8 -4
  27. package/dist/storage/BucketStorageFactory.js.map +1 -1
  28. package/dist/storage/PersistedSyncConfigContent.d.ts +2 -0
  29. package/dist/storage/PersistedSyncConfigContent.js +23 -10
  30. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  31. package/dist/storage/ReplicationEventPayload.d.ts +6 -1
  32. package/dist/storage/SourceEntity.d.ts +7 -2
  33. package/dist/storage/SourceTable.d.ts +37 -6
  34. package/dist/storage/SourceTable.js +42 -10
  35. package/dist/storage/SourceTable.js.map +1 -1
  36. package/dist/storage/SourceTableReconciler.d.ts +83 -0
  37. package/dist/storage/SourceTableReconciler.js +95 -0
  38. package/dist/storage/SourceTableReconciler.js.map +1 -0
  39. package/dist/storage/StorageVersionConfig.d.ts +4 -0
  40. package/dist/storage/StorageVersionConfig.js +10 -1
  41. package/dist/storage/StorageVersionConfig.js.map +1 -1
  42. package/dist/storage/SyncRulesBucketStorage.d.ts +45 -7
  43. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  44. package/dist/storage/WriteCheckpointAPI.d.ts +25 -3
  45. package/dist/storage/WriteCheckpointAPI.js.map +1 -1
  46. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +23 -7
  47. package/dist/storage/implementation/BucketDefinitionMapping.js +88 -14
  48. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
  49. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js +9 -1
  50. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js.map +1 -1
  51. package/dist/storage/storage-index.d.ts +1 -0
  52. package/dist/storage/storage-index.js +1 -0
  53. package/dist/storage/storage-index.js.map +1 -1
  54. package/dist/storage/storage-metrics.js +120 -4
  55. package/dist/storage/storage-metrics.js.map +1 -1
  56. package/dist/sync/BucketChecksumState.d.ts +1 -1
  57. package/dist/sync/BucketChecksumState.js.map +1 -1
  58. package/dist/sync/sync.js +37 -9
  59. package/dist/sync/sync.js.map +1 -1
  60. package/dist/sync/util.js +10 -1
  61. package/dist/sync/util.js.map +1 -1
  62. package/dist/util/config/compound-config-collector.js +5 -0
  63. package/dist/util/config/compound-config-collector.js.map +1 -1
  64. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js +1 -0
  65. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js.map +1 -1
  66. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -0
  67. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  68. package/dist/util/config/sync-rules/sync-rules-provider.d.ts +2 -0
  69. package/dist/util/config/sync-rules/sync-rules-provider.js +3 -0
  70. package/dist/util/config/sync-rules/sync-rules-provider.js.map +1 -1
  71. package/dist/util/config/types.d.ts +1 -0
  72. package/dist/util/protocol-types.d.ts +4 -4
  73. package/dist/util/protocol-types.js +3 -2
  74. package/dist/util/protocol-types.js.map +1 -1
  75. package/dist/util/utils.d.ts +2 -1
  76. package/dist/util/utils.js +16 -1
  77. package/dist/util/utils.js.map +1 -1
  78. package/package.json +6 -5
  79. package/src/api/diagnostics.ts +2 -0
  80. package/src/entry/commands/compact-action.ts +32 -24
  81. package/src/metrics/metrics-interfaces.ts +6 -1
  82. package/src/metrics/open-telemetry/MetricAttributeFilteringExporter.ts +85 -0
  83. package/src/metrics/open-telemetry/OpenTelemetryMetricsFactory.ts +7 -2
  84. package/src/metrics/open-telemetry/util.ts +7 -3
  85. package/src/replication/AbstractReplicator.ts +22 -6
  86. package/src/routes/endpoints/admin.ts +3 -1
  87. package/src/routes/endpoints/sync-rules.ts +3 -1
  88. package/src/storage/BucketStorageBatch.ts +2 -1
  89. package/src/storage/BucketStorageFactory.ts +44 -8
  90. package/src/storage/PersistedSyncConfigContent.ts +26 -11
  91. package/src/storage/ReplicationEventPayload.ts +6 -1
  92. package/src/storage/SourceEntity.ts +6 -2
  93. package/src/storage/SourceTable.ts +68 -11
  94. package/src/storage/SourceTableReconciler.ts +188 -0
  95. package/src/storage/StorageVersionConfig.ts +11 -1
  96. package/src/storage/SyncRulesBucketStorage.ts +53 -7
  97. package/src/storage/WriteCheckpointAPI.ts +32 -3
  98. package/src/storage/implementation/BucketDefinitionMapping.ts +120 -17
  99. package/src/storage/implementation/IncrementalReprocessingSyncConfigLog.ts +22 -5
  100. package/src/storage/storage-index.ts +1 -0
  101. package/src/storage/storage-metrics.ts +154 -8
  102. package/src/sync/BucketChecksumState.ts +3 -1
  103. package/src/sync/sync.ts +40 -11
  104. package/src/sync/util.ts +21 -8
  105. package/src/util/config/compound-config-collector.ts +6 -0
  106. package/src/util/config/sync-rules/impl/base64-sync-rules-collector.ts +1 -0
  107. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -0
  108. package/src/util/config/sync-rules/sync-rules-provider.ts +5 -0
  109. package/src/util/config/types.ts +1 -0
  110. package/src/util/protocol-types.ts +3 -2
  111. package/src/util/utils.ts +15 -1
  112. package/test/src/AbstractReplicator.test.ts +30 -0
  113. package/test/src/MetricAttributeFilteringExporter.test.ts +96 -0
  114. package/test/src/PersistedSyncConfigContent.test.ts +152 -0
  115. package/test/src/config.test.ts +40 -0
  116. package/test/src/diagnostics.test.ts +25 -0
  117. package/test/src/routes/admin.test.ts +8 -2
  118. package/test/src/routes/stream.test.ts +123 -2
  119. package/test/src/source-table-reconciler.test.ts +244 -0
  120. package/test/src/storage/SourceTable.test.ts +4 -1
  121. package/test/src/storage-metrics.test.ts +148 -0
  122. package/test/src/sync/util.test.ts +15 -0
  123. package/test/src/util/utils.test.ts +23 -0
  124. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -4,13 +4,14 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "1.24.0",
7
+ "version": "1.26.0",
8
8
  "license": "FSL-1.1-ALv2",
9
9
  "type": "module",
10
10
  "exports": "./dist/index.js",
11
11
  "dependencies": {
12
12
  "@js-sdsl/ordered-set": "^4.4.2",
13
13
  "@opentelemetry/api": "^1.9.0",
14
+ "@opentelemetry/core": "^2.0.1",
14
15
  "@opentelemetry/exporter-metrics-otlp-http": "^0.203.0",
15
16
  "@opentelemetry/exporter-prometheus": "^0.203.0",
16
17
  "@opentelemetry/resources": "^2.0.1",
@@ -32,11 +33,11 @@
32
33
  "uuid": "^14.0.0",
33
34
  "winston": "^3.13.0",
34
35
  "yaml": "^2.8.3",
35
- "@powersync/lib-services-framework": "0.10.0",
36
36
  "@powersync/service-jsonbig": "0.17.13",
37
- "@powersync/service-rsocket-router": "0.2.25",
38
- "@powersync/service-sync-rules": "0.40.0",
39
- "@powersync/service-types": "0.17.0"
37
+ "@powersync/service-rsocket-router": "0.2.26",
38
+ "@powersync/service-sync-rules": "0.41.0",
39
+ "@powersync/lib-services-framework": "0.10.1",
40
+ "@powersync/service-types": "0.18.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/async": "^3.2.24",
@@ -65,6 +65,7 @@ export async function getSyncRulesStatus(
65
65
  parsed = singleConfig;
66
66
  } catch (e) {
67
67
  return {
68
+ version_label: syncConfig.version_label,
68
69
  content: include_content ? syncConfig.sync_rules_content : undefined,
69
70
  connections: [],
70
71
  errors: [{ level: 'fatal', message: e.message, ts: now }]
@@ -221,6 +222,7 @@ export async function getSyncRulesStatus(
221
222
  }
222
223
 
223
224
  return {
225
+ version_label: syncConfig.version_label,
224
226
  content: include_content ? syncConfig.sync_rules_content : undefined,
225
227
  connections: [
226
228
  {
@@ -28,7 +28,11 @@ export function registerCompactAction(program: Command) {
28
28
  .command(COMMAND_NAME)
29
29
  .option(`-b, --buckets [buckets]`, 'Full bucket names, comma-separated (e.g., "global[],mybucket[\\"user1\\"]")')
30
30
  .option('-p, --parameter-indexes', 'Compacting parameter indexes. Defaults to set unless --buckets is provided.')
31
- .option('--no-parameter-indexes', 'Disabling compacting parameter indexes.');
31
+ .option('--no-parameter-indexes', 'Disabling compacting parameter indexes.')
32
+ .option(
33
+ '--incremental-only',
34
+ '[EXPERIMENTAL] Perform incremental compacting only. Parameter compaction runs on supported storage versions.'
35
+ );
32
36
 
33
37
  wrapConfigCommand(compactCommand);
34
38
 
@@ -48,8 +52,9 @@ export function registerCompactAction(program: Command) {
48
52
  }
49
53
  }
50
54
 
51
- let compactParameters: boolean | null = options.parameterIndexes;
55
+ const incremental: boolean = options.incrementalOnly ?? false;
52
56
 
57
+ let compactParameters: boolean | null = options.parameterIndexes;
53
58
  if (buckets == null) {
54
59
  logger.info('Compacting storage for all buckets...');
55
60
  } else if (buckets.length == 0) {
@@ -87,29 +92,32 @@ export function registerCompactAction(program: Command) {
87
92
  Date.now() - config.api_parameters.checkpoint_request_retention_minutes * MINUTE_MS
88
93
  );
89
94
 
90
- const active = (await bucketStorage.getActiveSyncConfig())?.storage;
91
- if (active == null) {
92
- logger.info('No active instance to compact');
93
- return;
94
- }
95
- if (buckets != null) {
96
- logger.info('Performing compaction...');
97
- await active.compact({
98
- memoryLimitMB: COMPACT_MEMORY_LIMIT_MB,
99
- compactBuckets: buckets,
100
- compactParameterData: compactParameters ?? false,
101
- deleteCheckpointRequestsBefore,
102
- signal: abortController.signal
103
- });
104
- } else {
105
- await active.compact({
106
- memoryLimitMB: COMPACT_MEMORY_LIMIT_MB,
107
- compactParameterData: compactParameters ?? true,
108
- deleteCheckpointRequestsBefore,
109
- signal: abortController.signal
110
- });
95
+ const streams = await bucketStorage.getReplicatingReplicationStreams();
96
+ for (let stream of streams) {
97
+ const storage = bucketStorage.getInstance(stream);
98
+ const compactParameterData =
99
+ (compactParameters ?? buckets == null) && (!incremental || storage.supportsIncrementalParameterCompaction());
100
+ logger.info(`[${stream.replicationStreamName}] Performing compaction...`);
101
+ if (buckets != null) {
102
+ await storage.compact({
103
+ memoryLimitMB: COMPACT_MEMORY_LIMIT_MB,
104
+ compactBuckets: buckets,
105
+ compactParameterData,
106
+ incrementalOnly: incremental,
107
+ deleteCheckpointRequestsBefore,
108
+ signal: abortController.signal
109
+ });
110
+ } else {
111
+ await storage.compact({
112
+ memoryLimitMB: COMPACT_MEMORY_LIMIT_MB,
113
+ compactParameterData,
114
+ incrementalOnly: incremental,
115
+ deleteCheckpointRequestsBefore,
116
+ signal: abortController.signal
117
+ });
118
+ }
119
+ logger.info(`[${stream.replicationStreamName}] Successfully compacted storage.`);
111
120
  }
112
- logger.info('Successfully compacted storage.');
113
121
  } catch (e) {
114
122
  logger.error(`Failed to compact:`, e);
115
123
  // Indirectly triggers lifeCycleEngine.stop
@@ -19,7 +19,12 @@ export interface ObservableGauge {
19
19
  * Set a value provider that provides the value for the gauge at the time of observation.
20
20
  * @param valueProvider
21
21
  */
22
- setValueProvider(valueProvider: () => Promise<number | undefined>): void;
22
+ setValueProvider(valueProvider: () => Promise<number | ObservableGaugeObservation[] | undefined>): void;
23
+ }
24
+
25
+ export interface ObservableGaugeObservation {
26
+ value: number;
27
+ attributes?: Record<string, string>;
23
28
  }
24
29
 
25
30
  export enum Precision {
@@ -0,0 +1,85 @@
1
+ import { Attributes } from '@opentelemetry/api';
2
+ import { ExportResult } from '@opentelemetry/core';
3
+ import {
4
+ AggregationSelector,
5
+ AggregationTemporalitySelector,
6
+ DataPoint,
7
+ DataPointType,
8
+ MetricData,
9
+ PushMetricExporter,
10
+ ResourceMetrics
11
+ } from '@opentelemetry/sdk-metrics';
12
+
13
+ /**
14
+ * Decorates a push exporter and removes selected attributes from every metric data point before
15
+ * delegating the export. The input metrics are not mutated, so another reader can export the
16
+ * original attributes.
17
+ *
18
+ * This exporter deliberately does not merge data points when filtering makes their attribute sets
19
+ * identical. Both points are forwarded with the same attributes. There is no generally correct
20
+ * merge operation at this layer: gauges, counters, and histograms have different aggregation
21
+ * semantics. Callers must therefore ensure that an excluded attribute does not distinguish data
22
+ * points for the same metric, resource, and instrumentation scope. Otherwise, the resulting
23
+ * duplicate series identity may be rejected or interpreted unpredictably downstream.
24
+ *
25
+ * Lifecycle methods and aggregation selection are delegated unchanged to the wrapped exporter.
26
+ */
27
+ export class MetricAttributeFilteringExporter implements PushMetricExporter {
28
+ readonly selectAggregation?: AggregationSelector;
29
+ readonly selectAggregationTemporality?: AggregationTemporalitySelector;
30
+
31
+ constructor(
32
+ /** The exporter that receives the filtered copy of the metrics. */
33
+ private readonly delegate: PushMetricExporter,
34
+ /** Attribute names to remove from every exported data point. */
35
+ private readonly excludedAttributes: ReadonlySet<string>
36
+ ) {
37
+ this.selectAggregation = delegate.selectAggregation?.bind(delegate);
38
+ this.selectAggregationTemporality = delegate.selectAggregationTemporality?.bind(delegate);
39
+ }
40
+
41
+ export(metrics: ResourceMetrics, resultCallback: (result: ExportResult) => void): void {
42
+ this.delegate.export(this.filterAttributes(metrics), resultCallback);
43
+ }
44
+
45
+ forceFlush(): Promise<void> {
46
+ return this.delegate.forceFlush();
47
+ }
48
+
49
+ shutdown(): Promise<void> {
50
+ return this.delegate.shutdown();
51
+ }
52
+
53
+ private filterAttributes(metrics: ResourceMetrics): ResourceMetrics {
54
+ return {
55
+ ...metrics,
56
+ scopeMetrics: metrics.scopeMetrics.map((scopeMetrics) => ({
57
+ ...scopeMetrics,
58
+ metrics: scopeMetrics.metrics.map((metric) => this.filterMetricAttributes(metric))
59
+ }))
60
+ };
61
+ }
62
+
63
+ private filterMetricAttributes(metric: MetricData): MetricData {
64
+ switch (metric.dataPointType) {
65
+ case DataPointType.SUM:
66
+ case DataPointType.GAUGE:
67
+ return { ...metric, dataPoints: metric.dataPoints.map((dataPoint) => this.filterDataPoint(dataPoint)) };
68
+ case DataPointType.HISTOGRAM:
69
+ return { ...metric, dataPoints: metric.dataPoints.map((dataPoint) => this.filterDataPoint(dataPoint)) };
70
+ case DataPointType.EXPONENTIAL_HISTOGRAM:
71
+ return { ...metric, dataPoints: metric.dataPoints.map((dataPoint) => this.filterDataPoint(dataPoint)) };
72
+ }
73
+ }
74
+
75
+ private filterDataPoint<T>(dataPoint: DataPoint<T>): DataPoint<T> {
76
+ return {
77
+ ...dataPoint,
78
+ attributes: this.filterDataPointAttributes(dataPoint.attributes)
79
+ };
80
+ }
81
+
82
+ private filterDataPointAttributes(attributes: Attributes): Attributes {
83
+ return Object.fromEntries(Object.entries(attributes).filter(([name]) => !this.excludedAttributes.has(name)));
84
+ }
85
+ }
@@ -4,6 +4,7 @@ import {
4
4
  MetricMetadata,
5
5
  MetricsFactory,
6
6
  ObservableGauge,
7
+ ObservableGaugeObservation,
7
8
  Precision,
8
9
  UpDownCounter
9
10
  } from '../metrics-interfaces.js';
@@ -31,12 +32,16 @@ export class OpenTelemetryMetricsFactory implements MetricsFactory {
31
32
  });
32
33
 
33
34
  return {
34
- setValueProvider(valueProvider: () => Promise<number | undefined>) {
35
+ setValueProvider(valueProvider: () => Promise<number | ObservableGaugeObservation[] | undefined>) {
35
36
  gauge.addCallback(async (result) => {
36
37
  const value = await valueProvider();
37
38
 
38
- if (value != undefined) {
39
+ if (typeof value === 'number') {
39
40
  result.observe(value);
41
+ } else if (value != undefined) {
42
+ for (const observation of value) {
43
+ result.observe(observation.value, observation.attributes);
44
+ }
40
45
  }
41
46
  });
42
47
  }
@@ -4,6 +4,7 @@ import { MeterProvider, MetricReader, PeriodicExportingMetricReader } from '@ope
4
4
  import { logger } from '@powersync/lib-services-framework';
5
5
  import { ServiceContext } from '../../system/ServiceContext.js';
6
6
  import { MetricsFactory } from '../metrics-interfaces.js';
7
+ import { MetricAttributeFilteringExporter } from './MetricAttributeFilteringExporter.js';
7
8
  import { OpenTelemetryMetricsFactory } from './OpenTelemetryMetricsFactory.js';
8
9
 
9
10
  import { resourceFromAttributes } from '@opentelemetry/resources';
@@ -29,10 +30,13 @@ export function createOpenTelemetryMetricsFactory(context: ServiceContext): Metr
29
30
  }
30
31
 
31
32
  if (!configuration.telemetry.disable_telemetry_sharing) {
33
+ const otlpExporter = new OTLPMetricExporter({
34
+ url: configuration.telemetry.internal_service_endpoint
35
+ });
32
36
  const periodicExporter = new PeriodicExportingMetricReader({
33
- exporter: new OTLPMetricExporter({
34
- url: configuration.telemetry.internal_service_endpoint
35
- }),
37
+ // sync_config_id uniquely identifies these metric series after version_label is removed,
38
+ // so filtering cannot create duplicate data points.
39
+ exporter: new MetricAttributeFilteringExporter(otlpExporter, new Set(['version_label'])),
36
40
  exportIntervalMillis: 1000 * 60 * 5 // 5 minutes
37
41
  });
38
42
 
@@ -156,7 +156,8 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
156
156
 
157
157
  let configuredLock: storage.ReplicationLock | undefined = undefined;
158
158
  if (loadedSyncConfig != null) {
159
- this.logger.info('Loaded sync config');
159
+ const versionLabel = this.syncRuleProvider.versionLabel;
160
+ this.logger.info(`Loaded sync config${versionLabel == null ? '' : ` ${versionLabel}`}`);
160
161
  try {
161
162
  // Configure new sync config, if they have changed.
162
163
  // In that case, also immediately take out a lock, so that another process doesn't start replication on it.
@@ -165,7 +166,11 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
165
166
  // the same time.
166
167
 
167
168
  const { lock } = await this.storage.configureSyncRules(
168
- storage.updateSyncRulesFromYaml(loadedSyncConfig, { lock: true, validate: this.syncRuleProvider.exitOnError })
169
+ storage.updateSyncRulesFromYaml(loadedSyncConfig, {
170
+ lock: true,
171
+ validate: this.syncRuleProvider.exitOnError,
172
+ version_label: versionLabel
173
+ })
169
174
  );
170
175
  if (lock) {
171
176
  configuredLock = lock;
@@ -184,7 +189,11 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
184
189
  while (!this.stopped) {
185
190
  await container.probes.touch();
186
191
  try {
187
- const refreshResult = await this.refresh({ configuredLock, loadedSyncConfig });
192
+ const refreshResult = await this.refresh({
193
+ configuredLock,
194
+ loadedSyncConfig,
195
+ loadedVersionLabel: this.syncRuleProvider.versionLabel
196
+ });
188
197
  if (refreshResult.replicationJobStarted || Date.now() >= fastRefreshDeadline) {
189
198
  useFastRefresh = false;
190
199
  }
@@ -216,6 +225,7 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
216
225
  private async refresh(options?: {
217
226
  configuredLock?: storage.ReplicationLock;
218
227
  loadedSyncConfig?: string;
228
+ loadedVersionLabel?: string;
219
229
  }): Promise<{ replicationJobStarted: boolean }> {
220
230
  if (this.stopped) {
221
231
  return { replicationJobStarted: false };
@@ -233,7 +243,9 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
233
243
  const jobId = replicationStream.replicationJobId;
234
244
  const existingJob = existingJobs.get(jobId);
235
245
  const syncConfigMismatchMessage = 'Ignoring replication stream for sync config not loaded by this process';
236
- if (!this.shouldHandleReplicationStream(replicationStream, options?.loadedSyncConfig)) {
246
+ if (
247
+ !this.shouldHandleReplicationStream(replicationStream, options?.loadedSyncConfig, options?.loadedVersionLabel)
248
+ ) {
237
249
  this.logReplicationStreamInfoOnce(replicationStream, 'sync-config-mismatch', () => {
238
250
  replicationStream.logger.info(syncConfigMismatchMessage);
239
251
  });
@@ -371,7 +383,8 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
371
383
  */
372
384
  private shouldHandleReplicationStream(
373
385
  replicationStream: storage.PersistedReplicationStream,
374
- loadedSyncRules: string | undefined
386
+ loadedSyncRules: string | undefined,
387
+ loadedVersionLabel: string | undefined
375
388
  ) {
376
389
  if (loadedSyncRules == null) {
377
390
  return true;
@@ -380,7 +393,10 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
380
393
  const processingConfig = replicationStream.syncConfigContent.find(
381
394
  (syncConfig) => syncConfig.syncConfigState == storage.SyncRuleState.PROCESSING
382
395
  );
383
- return processingConfig == null || processingConfig.sync_rules_content == loadedSyncRules;
396
+ return (
397
+ processingConfig == null ||
398
+ (processingConfig.sync_rules_content == loadedSyncRules && processingConfig.version_label == loadedVersionLabel)
399
+ );
384
400
  }
385
401
 
386
402
  protected createJobId(syncRuleId: number) {
@@ -165,7 +165,9 @@ export const reprocess = routeDefinition({
165
165
  storage.updateSyncRulesFromYaml(active.content.sync_rules_content, {
166
166
  // This sync config already passed validation. But if the config is not valid anymore due
167
167
  // to a service change, we do want to report the error here.
168
- validate: true
168
+ validate: true,
169
+ version_label: active.content.version_label,
170
+ forceNewReplicationStream: true
169
171
  })
170
172
  );
171
173
 
@@ -180,7 +180,9 @@ export const reprocessSyncRules = routeDefinition({
180
180
  updateSyncRulesFromYaml(sync_rules.sync_rules_content, {
181
181
  // This sync config already passed validation. But if the rules are not valid anymore due
182
182
  // to a service change, we do want to report the error here.
183
- validate: true
183
+ validate: true,
184
+ version_label: sync_rules.version_label,
185
+ forceNewReplicationStream: true
184
186
  })
185
187
  );
186
188
  return {
@@ -176,7 +176,8 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
176
176
  * Queue a custom checkpoint request to be persisted after operations are
177
177
  * flushed. Set `checkpoint_requested_at` when the custom checkpoint came from
178
178
  * a client checkpoint request and should be cleaned up by request-retention
179
- * compaction; omit it for persistent source-owned checkpoints.
179
+ * compaction; omit it for persistent source-owned checkpoints. Incremental
180
+ * event handlers should copy `event_id` from their {@link ReplicationEventPayload}.
180
181
  */
181
182
  addCustomWriteCheckpoint(checkpoint: BatchedCustomWriteCheckpointOptions): void;
182
183
  }
@@ -37,10 +37,17 @@ export abstract class BucketStorageFactory
37
37
  const deploying = await this.getDeployingSyncConfig();
38
38
  const active = await this.getActiveSyncConfig();
39
39
 
40
- if (deploying?.content.sync_rules_content == options.config.yaml) {
40
+ if (
41
+ deploying?.content.sync_rules_content == options.config.yaml &&
42
+ deploying.content.version_label == options.version_label
43
+ ) {
41
44
  logger.info('Sync config unchanged');
42
45
  return { updated: false };
43
- } else if (deploying == null && active?.content.sync_rules_content == options.config.yaml) {
46
+ } else if (
47
+ deploying == null &&
48
+ active?.content.sync_rules_content == options.config.yaml &&
49
+ active.content.version_label == options.version_label
50
+ ) {
44
51
  logger.info('Sync config unchanged');
45
52
  return { updated: false };
46
53
  } else {
@@ -141,9 +148,32 @@ export interface StorageMetrics {
141
148
  * Size of current_data.
142
149
  */
143
150
  replication_size_bytes: number;
151
+
152
+ /**
153
+ * Size of active object-storage references, such as S3 bucket-data objects.
154
+ */
155
+ object_storage_size_bytes?: number;
156
+
157
+ /**
158
+ * Per-sync-config storage sizes, when the storage backend can provide them.
159
+ */
160
+ sync_config_metrics?: StorageSyncConfigMetrics[];
161
+ }
162
+
163
+ export interface StorageSyncConfigMetrics {
164
+ sync_config_id: string;
165
+ sync_config_state: string;
166
+ /** Optional operator-supplied label identifying this sync config version. */
167
+ version_label?: string;
168
+ attributed_bucket_data_bytes: number;
169
+ attributed_parameter_indexes_bytes: number;
170
+ attributed_source_records_bytes: number;
171
+ attributed_object_storage_bytes: number;
144
172
  }
145
173
 
146
174
  export interface UpdateSyncRulesOptions {
175
+ /** Optional operator-supplied label identifying this deployed sync config version. */
176
+ version_label?: string;
147
177
  config: {
148
178
  yaml: string;
149
179
  /**
@@ -161,6 +191,12 @@ export interface UpdateSyncRulesOptions {
161
191
  lock?: boolean;
162
192
  storageVersion?: number;
163
193
 
194
+ /**
195
+ * Start a replacement replication stream instead of incrementally reprocessing a
196
+ * compatible active stream. Used when the current stream needs a full reprocess.
197
+ */
198
+ forceNewReplicationStream?: boolean;
199
+
164
200
  /**
165
201
  * Only relevant if the result is used. This does not affect the persisted config.
166
202
  */
@@ -174,11 +210,10 @@ export interface SerializedSyncPlan {
174
210
  plan: RawSerializedSyncPlan;
175
211
  compatibility: SerializedCompatibilityContext;
176
212
  /**
177
- * Event descriptors are not currently represented in the sync plan because they don't use the sync streams compiler
178
- * yet.
213
+ * Raw event SQL persisted as a compatibility mirror for compiled {@link plan} events.
179
214
  *
180
- * We might revisit that in the future, but for now we store SQL text of their definitions here to be able to restore
181
- * them.
215
+ * Compiled events are an additive plan field. Older services ignore that field and restore these descriptors through
216
+ * the legacy evaluator. Keep dual-writing this field until a future plan version explicitly removes that support.
182
217
  */
183
218
  eventDescriptors: Record<string, string[]>;
184
219
  errors?: ReplicationError[];
@@ -206,13 +241,14 @@ export function updateSyncRulesFromConfig(
206
241
  const { config, errors } = parsed;
207
242
  if (config instanceof PrecompiledSyncConfig) {
208
243
  const eventDescriptors: Record<string, string[]> = {};
209
- for (const event of config.eventDescriptors) {
210
- eventDescriptors[event.name] = event.sourceQueries.map((q) => q.sql);
244
+ for (const event of config.plan.events) {
245
+ eventDescriptors[event.name] = event.sourceQueries.map((query) => query.sql);
211
246
  }
212
247
 
213
248
  plan = {
214
249
  compatibility: config.compatibility.serialize(),
215
250
  plan: serializeSyncPlan(config.plan),
251
+ // Dual-write raw SQL so older services can ignore additive compiled plan events without losing event behavior.
216
252
  eventDescriptors,
217
253
  errors: errors.map((e) => syncConfigYamlErrorToReplicationError(e))
218
254
  };
@@ -2,6 +2,7 @@ import { logger as defaultLogger, ErrorCode, ServiceError } from '@powersync/lib
2
2
  import {
3
3
  CompatibilityContext,
4
4
  CompatibilityOption,
5
+ compileEventDefinitions,
5
6
  DEFAULT_HYDRATION_STATE,
6
7
  deserializeSyncPlan,
7
8
  ErrorLocation,
@@ -9,7 +10,6 @@ import {
9
10
  HydrationState,
10
11
  nodeSqlite,
11
12
  PrecompiledSyncConfig,
12
- SqlEventDescriptor,
13
13
  SqlSyncRules,
14
14
  SyncConfigWithErrors,
15
15
  versionedHydrationState,
@@ -40,17 +40,28 @@ export function parsePersistedSyncConfigContent(options: ParsePersistedSyncConfi
40
40
 
41
41
  const plan = deserializeSyncPlan(compiledPlan.plan);
42
42
  const compatibility = CompatibilityContext.deserialize(compiledPlan.compatibility);
43
- const eventDefinitions: SqlEventDescriptor[] = [];
44
- for (const [name, queries] of Object.entries(compiledPlan.eventDescriptors)) {
45
- const descriptor = new SqlEventDescriptor(name, compatibility);
46
- for (const query of queries) {
47
- descriptor.addSourceQuery(query, parseOptions);
43
+ const errors: YamlError[] = [];
44
+ // Compiled events are additive to plan versions 1 and 2. New readers prefer them when present; when an older plan
45
+ // does not contain them, normalize the dual-written raw SQL at this loading boundary. This keeps legacy event
46
+ // evaluators out of PrecompiledSyncConfig while older binaries can continue reading the same persisted config.
47
+ if (compiledPlan.plan.events == null) {
48
+ const normalized = compileEventDefinitions(compiledPlan.eventDescriptors, {
49
+ ...parseOptions,
50
+ // The legacy evaluator ignored event payload filters. Preserve that behavior for plans deployed before compiled
51
+ // events existed; a redeploy compiles and validates those filters before the replacement config is activated.
52
+ compileEventPayloadFilters: false
53
+ });
54
+ const fatalErrors = normalized.errors.filter((error) => error.type == 'fatal');
55
+ if (fatalErrors.length != 0) {
56
+ throw new Error(
57
+ `Failed to compile persisted replication events: ${fatalErrors.map((error) => error.message).join(', ')}`
58
+ );
48
59
  }
49
-
50
- eventDefinitions.push(descriptor);
60
+ plan.events = normalized.events;
61
+ errors.push(...normalized.errors.map((error) => new YamlError(error)));
51
62
  }
52
63
 
53
- const precompiled = new PrecompiledSyncConfig(plan, compatibility, eventDefinitions, {
64
+ const precompiled = new PrecompiledSyncConfig(plan, compatibility, {
54
65
  defaultSchema: parseOptions.defaultSchema,
55
66
  sourceText: content
56
67
  });
@@ -59,7 +70,6 @@ export function parsePersistedSyncConfigContent(options: ParsePersistedSyncConfi
59
70
  // This means asUpdateOptions will not change the storage version, even if the default changes.
60
71
  precompiled.storageVersion = storageVersion;
61
72
 
62
- const errors: YamlError[] = [];
63
73
  if (compiledPlan.errors) {
64
74
  for (const error of compiledPlan.errors) {
65
75
  const location: ErrorLocation | undefined = error.location && {
@@ -93,6 +103,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
93
103
  readonly logger: Logger;
94
104
  readonly syncConfigId: PersistedSyncConfigId | null;
95
105
  readonly syncConfigState: SyncRuleState;
106
+ readonly version_label: string | undefined;
96
107
 
97
108
  constructor(data: PersistedSyncConfigContentData) {
98
109
  this.replicationStreamId = data.replicationStreamId;
@@ -102,7 +113,9 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
102
113
  this.storageVersion = data.storageVersion;
103
114
  this.syncConfigId = data.syncConfigId ?? null;
104
115
  this.syncConfigState = data.syncConfigState;
105
- this.logger = defaultLogger.child({ prefix: `[${this.replicationStreamName}] ` });
116
+ this.version_label = data.version_label;
117
+ const versionPrefix = this.version_label == null ? '' : `[${this.version_label}]`;
118
+ this.logger = defaultLogger.child({ prefix: `[${this.replicationStreamName}]${versionPrefix} ` });
106
119
  }
107
120
 
108
121
  /**
@@ -167,6 +180,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
167
180
  const parsed = this.parseSingleConfig({ defaultSchema: 'not_applicable' });
168
181
  return {
169
182
  config: { yaml: this.sync_rules_content, plan: this.compiled_plan, parsed },
183
+ version_label: this.version_label,
170
184
  ...options
171
185
  };
172
186
  }
@@ -185,6 +199,7 @@ export interface PersistedSyncConfigContentData {
185
199
 
186
200
  readonly syncConfigId?: PersistedSyncConfigId | null;
187
201
  readonly syncConfigState: SyncRuleState;
202
+ readonly version_label?: string;
188
203
  }
189
204
  export type PersistedSyncConfigId = string;
190
205
  export interface ParseSyncConfigOptions {
@@ -11,6 +11,11 @@ export type EventData = {
11
11
  export type ReplicationEventPayload = {
12
12
  batch: BucketStorageBatch;
13
13
  data: EventData;
14
- event: sync_rules.SqlEventDescriptor;
14
+ event: sync_rules.HydratedEventDescriptor;
15
+ /**
16
+ * Storage-assigned id for this event definition. Present when storage uses persisted event mappings, allowing event
17
+ * handlers to route custom checkpoints without deriving an id from compiled event content.
18
+ */
19
+ event_id?: sync_rules.EventDefinitionId;
15
20
  table: SourceTable;
16
21
  };
@@ -1,5 +1,11 @@
1
1
  import { SourceTableRef } from '@powersync/service-sync-rules';
2
2
 
3
+ /**
4
+ * Source-specific JSON metadata. Storage does not interpret it. Source-table APIs use null to
5
+ * represent the absence of metadata.
6
+ */
7
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
8
+
3
9
  export interface ColumnDescriptor {
4
10
  name: string;
5
11
  /**
@@ -19,8 +25,6 @@ export interface SourceEntityDescriptor extends SourceTableRef {
19
25
  * If specified, this is specifically used to detect renames.
20
26
  */
21
27
  objectId: number | string | undefined;
22
- schema: string;
23
- name: string;
24
28
  /**
25
29
  * The columns that are used to uniquely identify a record in the source entity.
26
30
  */