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

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 (230) hide show
  1. package/CHANGELOG.md +152 -4
  2. package/dist/api/RouteAPI.d.ts +42 -14
  3. package/dist/api/diagnostics.d.ts +12 -1
  4. package/dist/api/diagnostics.js +36 -22
  5. package/dist/api/diagnostics.js.map +1 -1
  6. package/dist/entry/commands/compact-action.js +30 -22
  7. package/dist/entry/commands/compact-action.js.map +1 -1
  8. package/dist/metrics/metrics-interfaces.d.ts +5 -1
  9. package/dist/metrics/metrics-interfaces.js.map +1 -1
  10. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.d.ts +36 -0
  11. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js +70 -0
  12. package/dist/metrics/open-telemetry/MetricAttributeFilteringExporter.js.map +1 -0
  13. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js +6 -1
  14. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js.map +1 -1
  15. package/dist/metrics/open-telemetry/util.js +7 -3
  16. package/dist/metrics/open-telemetry/util.js.map +1 -1
  17. package/dist/modules/AbstractModule.d.ts +6 -2
  18. package/dist/modules/AbstractModule.js.map +1 -1
  19. package/dist/replication/AbstractReplicator.d.ts +31 -7
  20. package/dist/replication/AbstractReplicator.js +168 -74
  21. package/dist/replication/AbstractReplicator.js.map +1 -1
  22. package/dist/replication/ReplicationModule.d.ts +3 -3
  23. package/dist/replication/ReplicationModule.js.map +1 -1
  24. package/dist/routes/RouterEngine.d.ts +1 -0
  25. package/dist/routes/RouterEngine.js +6 -1
  26. package/dist/routes/RouterEngine.js.map +1 -1
  27. package/dist/routes/configure-fastify.d.ts +104 -56
  28. package/dist/routes/configure-fastify.js +15 -2
  29. package/dist/routes/configure-fastify.js.map +1 -1
  30. package/dist/routes/endpoints/admin.d.ts +42 -24
  31. package/dist/routes/endpoints/admin.js +49 -36
  32. package/dist/routes/endpoints/admin.js.map +1 -1
  33. package/dist/routes/endpoints/checkpointing.d.ts +62 -0
  34. package/dist/routes/endpoints/checkpointing.js +65 -7
  35. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  36. package/dist/routes/endpoints/socket-route.js +3 -2
  37. package/dist/routes/endpoints/socket-route.js.map +1 -1
  38. package/dist/routes/endpoints/sync-rules.js +25 -14
  39. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  40. package/dist/routes/endpoints/sync-stream.js +1 -1
  41. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  42. package/dist/runner/teardown.js +8 -8
  43. package/dist/runner/teardown.js.map +1 -1
  44. package/dist/storage/BucketStorageBatch.d.ts +64 -17
  45. package/dist/storage/BucketStorageBatch.js.map +1 -1
  46. package/dist/storage/BucketStorageFactory.d.ts +50 -31
  47. package/dist/storage/BucketStorageFactory.js +10 -20
  48. package/dist/storage/BucketStorageFactory.js.map +1 -1
  49. package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
  50. package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
  51. package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -0
  52. package/dist/storage/ChecksumCache.d.ts +11 -6
  53. package/dist/storage/ChecksumCache.js +5 -4
  54. package/dist/storage/ChecksumCache.js.map +1 -1
  55. package/dist/storage/ParsedSyncConfigSet.d.ts +23 -0
  56. package/dist/storage/ParsedSyncConfigSet.js +2 -0
  57. package/dist/storage/ParsedSyncConfigSet.js.map +1 -0
  58. package/dist/storage/PersistedReplicationStream.d.ts +32 -0
  59. package/dist/storage/PersistedReplicationStream.js +26 -0
  60. package/dist/storage/PersistedReplicationStream.js.map +1 -0
  61. package/dist/storage/PersistedSyncConfigContent.d.ts +65 -0
  62. package/dist/storage/PersistedSyncConfigContent.js +138 -0
  63. package/dist/storage/PersistedSyncConfigContent.js.map +1 -0
  64. package/dist/storage/PersistedSyncConfigStatus.d.ts +13 -0
  65. package/dist/storage/PersistedSyncConfigStatus.js +2 -0
  66. package/dist/storage/PersistedSyncConfigStatus.js.map +1 -0
  67. package/dist/storage/ReplicationEventPayload.d.ts +6 -1
  68. package/dist/storage/SourceEntity.d.ts +13 -2
  69. package/dist/storage/SourceTable.d.ts +53 -4
  70. package/dist/storage/SourceTable.js +62 -7
  71. package/dist/storage/SourceTable.js.map +1 -1
  72. package/dist/storage/SourceTableReconciler.d.ts +83 -0
  73. package/dist/storage/SourceTableReconciler.js +95 -0
  74. package/dist/storage/SourceTableReconciler.js.map +1 -0
  75. package/dist/storage/StorageVersionConfig.d.ts +4 -0
  76. package/dist/storage/StorageVersionConfig.js +10 -1
  77. package/dist/storage/StorageVersionConfig.js.map +1 -1
  78. package/dist/storage/SyncRulesBucketStorage.d.ts +162 -26
  79. package/dist/storage/SyncRulesBucketStorage.js +3 -0
  80. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  81. package/dist/storage/WriteCheckpointAPI.d.ts +85 -6
  82. package/dist/storage/WriteCheckpointAPI.js +33 -0
  83. package/dist/storage/WriteCheckpointAPI.js.map +1 -1
  84. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +133 -0
  85. package/dist/storage/implementation/BucketDefinitionMapping.js +351 -0
  86. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -0
  87. package/dist/storage/implementation/IncrementalReprocessingCompatibility.d.ts +6 -0
  88. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +31 -0
  89. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -0
  90. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.d.ts +17 -0
  91. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js +70 -0
  92. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js.map +1 -0
  93. package/dist/storage/implementation/ReplicationStreamStorageIds.d.ts +15 -0
  94. package/dist/storage/implementation/ReplicationStreamStorageIds.js +20 -0
  95. package/dist/storage/implementation/ReplicationStreamStorageIds.js.map +1 -0
  96. package/dist/storage/storage-index.d.ts +10 -1
  97. package/dist/storage/storage-index.js +10 -1
  98. package/dist/storage/storage-index.js.map +1 -1
  99. package/dist/storage/storage-metrics.js +120 -4
  100. package/dist/storage/storage-metrics.js.map +1 -1
  101. package/dist/sync/BucketChecksumState.d.ts +12 -2
  102. package/dist/sync/BucketChecksumState.js +69 -21
  103. package/dist/sync/BucketChecksumState.js.map +1 -1
  104. package/dist/sync/RequestTracker.d.ts +7 -0
  105. package/dist/sync/RequestTracker.js +12 -0
  106. package/dist/sync/RequestTracker.js.map +1 -1
  107. package/dist/sync/sync.js +184 -43
  108. package/dist/sync/sync.js.map +1 -1
  109. package/dist/sync/util.d.ts +5 -0
  110. package/dist/sync/util.js +19 -1
  111. package/dist/sync/util.js.map +1 -1
  112. package/dist/system/ServiceContext.d.ts +2 -0
  113. package/dist/system/ServiceContext.js +2 -0
  114. package/dist/system/ServiceContext.js.map +1 -1
  115. package/dist/tracing/PerformanceTracer.d.ts +23 -1
  116. package/dist/tracing/PerformanceTracer.js +11 -0
  117. package/dist/tracing/PerformanceTracer.js.map +1 -1
  118. package/dist/util/checkpointing.d.ts +4 -8
  119. package/dist/util/checkpointing.js +1 -16
  120. package/dist/util/checkpointing.js.map +1 -1
  121. package/dist/util/config/compound-config-collector.js +23 -2
  122. package/dist/util/config/compound-config-collector.js.map +1 -1
  123. package/dist/util/config/defaults.d.ts +2 -0
  124. package/dist/util/config/defaults.js +2 -0
  125. package/dist/util/config/defaults.js.map +1 -1
  126. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js +1 -0
  127. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js.map +1 -1
  128. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -0
  129. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  130. package/dist/util/config/sync-rules/sync-rules-provider.d.ts +2 -0
  131. package/dist/util/config/sync-rules/sync-rules-provider.js +3 -0
  132. package/dist/util/config/sync-rules/sync-rules-provider.js.map +1 -1
  133. package/dist/util/config/types.d.ts +4 -1
  134. package/dist/util/lsn.d.ts +4 -0
  135. package/dist/util/lsn.js +10 -0
  136. package/dist/util/lsn.js.map +1 -1
  137. package/dist/util/protocol-types.d.ts +4 -4
  138. package/dist/util/protocol-types.js +3 -2
  139. package/dist/util/protocol-types.js.map +1 -1
  140. package/dist/util/util-index.d.ts +1 -0
  141. package/dist/util/util-index.js +1 -0
  142. package/dist/util/util-index.js.map +1 -1
  143. package/dist/util/utils.d.ts +3 -2
  144. package/dist/util/utils.js +17 -2
  145. package/dist/util/utils.js.map +1 -1
  146. package/dist/util/write-checkpoint-batcher.d.ts +19 -0
  147. package/dist/util/write-checkpoint-batcher.js +100 -0
  148. package/dist/util/write-checkpoint-batcher.js.map +1 -0
  149. package/package.json +7 -7
  150. package/src/api/RouteAPI.ts +43 -14
  151. package/src/api/diagnostics.ts +45 -26
  152. package/src/entry/commands/compact-action.ts +37 -23
  153. package/src/metrics/metrics-interfaces.ts +6 -1
  154. package/src/metrics/open-telemetry/MetricAttributeFilteringExporter.ts +85 -0
  155. package/src/metrics/open-telemetry/OpenTelemetryMetricsFactory.ts +7 -2
  156. package/src/metrics/open-telemetry/util.ts +7 -3
  157. package/src/modules/AbstractModule.ts +6 -2
  158. package/src/replication/AbstractReplicator.ts +212 -78
  159. package/src/replication/ReplicationModule.ts +2 -3
  160. package/src/routes/RouterEngine.ts +7 -1
  161. package/src/routes/configure-fastify.ts +25 -2
  162. package/src/routes/endpoints/admin.ts +75 -51
  163. package/src/routes/endpoints/checkpointing.ts +79 -8
  164. package/src/routes/endpoints/socket-route.ts +3 -2
  165. package/src/routes/endpoints/sync-rules.ts +26 -14
  166. package/src/routes/endpoints/sync-stream.ts +1 -1
  167. package/src/runner/teardown.ts +8 -8
  168. package/src/storage/BucketStorageBatch.ts +65 -18
  169. package/src/storage/BucketStorageFactory.ts +73 -46
  170. package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
  171. package/src/storage/ChecksumCache.ts +27 -14
  172. package/src/storage/ParsedSyncConfigSet.ts +25 -0
  173. package/src/storage/PersistedReplicationStream.ts +55 -0
  174. package/src/storage/PersistedSyncConfigContent.ts +207 -0
  175. package/src/storage/PersistedSyncConfigStatus.ts +14 -0
  176. package/src/storage/ReplicationEventPayload.ts +6 -1
  177. package/src/storage/SourceEntity.ts +12 -2
  178. package/src/storage/SourceTable.ts +96 -8
  179. package/src/storage/SourceTableReconciler.ts +188 -0
  180. package/src/storage/StorageVersionConfig.ts +11 -1
  181. package/src/storage/SyncRulesBucketStorage.ts +189 -27
  182. package/src/storage/WriteCheckpointAPI.ts +140 -6
  183. package/src/storage/implementation/BucketDefinitionMapping.ts +539 -0
  184. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +41 -0
  185. package/src/storage/implementation/IncrementalReprocessingSyncConfigLog.ts +126 -0
  186. package/src/storage/implementation/ReplicationStreamStorageIds.ts +21 -0
  187. package/src/storage/storage-index.ts +11 -1
  188. package/src/storage/storage-metrics.ts +154 -8
  189. package/src/sync/BucketChecksumState.ts +105 -21
  190. package/src/sync/RequestTracker.ts +20 -0
  191. package/src/sync/sync.ts +214 -50
  192. package/src/sync/util.ts +33 -8
  193. package/src/system/ServiceContext.ts +7 -0
  194. package/src/tracing/PerformanceTracer.ts +41 -1
  195. package/src/util/checkpointing.ts +4 -22
  196. package/src/util/config/compound-config-collector.ts +31 -1
  197. package/src/util/config/defaults.ts +2 -0
  198. package/src/util/config/sync-rules/impl/base64-sync-rules-collector.ts +1 -0
  199. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -0
  200. package/src/util/config/sync-rules/sync-rules-provider.ts +5 -0
  201. package/src/util/config/types.ts +4 -1
  202. package/src/util/lsn.ts +9 -0
  203. package/src/util/protocol-types.ts +3 -2
  204. package/src/util/util-index.ts +1 -0
  205. package/src/util/utils.ts +16 -2
  206. package/src/util/write-checkpoint-batcher.ts +142 -0
  207. package/test/src/AbstractReplicator.test.ts +177 -0
  208. package/test/src/MetricAttributeFilteringExporter.test.ts +96 -0
  209. package/test/src/PersistedSyncConfigContent.test.ts +152 -0
  210. package/test/src/checksum_cache.test.ts +22 -0
  211. package/test/src/config.test.ts +142 -0
  212. package/test/src/diagnostics.test.ts +87 -34
  213. package/test/src/module-loader.test.ts +33 -1
  214. package/test/src/routes/admin.test.ts +188 -18
  215. package/test/src/routes/checkpointing.test.ts +54 -0
  216. package/test/src/routes/mocks.ts +6 -2
  217. package/test/src/routes/stream.test.ts +123 -2
  218. package/test/src/source-table-reconciler.test.ts +244 -0
  219. package/test/src/storage/SourceTable.test.ts +92 -0
  220. package/test/src/storage-metrics.test.ts +148 -0
  221. package/test/src/sync/BucketChecksumState.test.ts +98 -12
  222. package/test/src/sync/util.test.ts +28 -1
  223. package/test/src/util/checkpointing.test.ts +249 -0
  224. package/test/src/util/utils.test.ts +23 -0
  225. package/tsconfig.json +1 -4
  226. package/tsconfig.tsbuildinfo +1 -1
  227. package/dist/storage/PersistedSyncRulesContent.d.ts +0 -59
  228. package/dist/storage/PersistedSyncRulesContent.js +0 -100
  229. package/dist/storage/PersistedSyncRulesContent.js.map +0 -1
  230. package/src/storage/PersistedSyncRulesContent.ts +0 -174
@@ -0,0 +1,152 @@
1
+ import {
2
+ DEFAULT_HYDRATION_STATE,
3
+ DEFAULT_TAG,
4
+ nodeSqlite,
5
+ PrecompiledSyncConfig,
6
+ serializeSyncPlan,
7
+ SqlSyncRules
8
+ } from '@powersync/service-sync-rules';
9
+ import * as sqlite from 'node:sqlite';
10
+ import { describe, expect, test } from 'vitest';
11
+ import {
12
+ parsePersistedSyncConfigContent,
13
+ SerializedSyncPlan,
14
+ updateSyncRulesFromConfig
15
+ } from '../../src/storage/storage-index.js';
16
+
17
+ const EVENT_QUERY = 'SELECT user_id, checkpoint FROM checkpoints WHERE active = true';
18
+ const yamlWithoutEvents = `
19
+ config:
20
+ edition: 3
21
+
22
+ streams:
23
+ checkpoints:
24
+ query: SELECT * FROM checkpoints
25
+ `;
26
+ const yamlWithEvents = `${yamlWithoutEvents}
27
+ event_definitions:
28
+ write_checkpoints:
29
+ payloads:
30
+ - ${EVENT_QUERY}
31
+ `;
32
+
33
+ describe('persisted compiled replication events', () => {
34
+ test('dual-writes additive compiled events and restores the compiled evaluator', () => {
35
+ const parsed = SqlSyncRules.fromYaml(yamlWithEvents, { defaultSchema: 'test_schema' });
36
+ const update = updateSyncRulesFromConfig(parsed);
37
+ const compiled = update.config.plan!;
38
+ const originalEvent = compiled.plan.events![0];
39
+
40
+ expect(compiled.plan.version).toBeLessThanOrEqual(2);
41
+ expect(compiled.plan.events).toHaveLength(1);
42
+ expect(originalEvent.name).toBe('write_checkpoints');
43
+ expect(compiled.eventDescriptors).toEqual({ write_checkpoints: [EVENT_QUERY] });
44
+
45
+ const restored = restore(compiled);
46
+ expect(restored.config).toBeInstanceOf(PrecompiledSyncConfig);
47
+ expect(restored.config).not.toHaveProperty('eventDescriptors');
48
+ expect(restored.config.eventDefinitions).toHaveLength(1);
49
+ expect(restored.config.eventDefinitions[0].name).toBe('write_checkpoints');
50
+
51
+ const hydrated = restored.config.hydrate({
52
+ hydrationState: DEFAULT_HYDRATION_STATE,
53
+ sqlite: nodeSqlite(sqlite)
54
+ });
55
+ expect(
56
+ hydrated.eventDescriptors[0].evaluateRowWithErrors({
57
+ sourceTable: { connectionTag: DEFAULT_TAG, schema: 'test_schema', name: 'checkpoints' },
58
+ record: { user_id: 'user-1', checkpoint: 4n, active: 1 }
59
+ })
60
+ ).toEqual({ results: [{ data: { user_id: 'user-1', checkpoint: 4n } }], errors: [] });
61
+
62
+ // This represents what an older compiler sees after ignoring the additive plan.events field. The raw descriptor
63
+ // mirror is normalized into the compiled representation by the new loading boundary, retaining the legacy behavior
64
+ // of ignoring payload filters until the sync config is redeployed.
65
+ const { events: _ignored, ...planWithoutCompiledEvents } = compiled.plan;
66
+ const legacyView = restore({ ...compiled, plan: planWithoutCompiledEvents });
67
+ expect(legacyView.config).not.toHaveProperty('eventDescriptors');
68
+ expect(legacyView.config.eventDefinitions).toHaveLength(1);
69
+ expect(legacyView.config.eventDefinitions[0].name).toBe('write_checkpoints');
70
+ expect(legacyView.errors).toHaveLength(1);
71
+ expect(legacyView.errors[0]).toMatchObject({ type: 'warning' });
72
+
73
+ const legacyEvent = serializeSyncPlan((legacyView.config as PrecompiledSyncConfig).plan).events![0];
74
+ expect(legacyEvent.sourceQueries[0].variants[0].filters).toEqual([]);
75
+ expect(originalEvent.sourceQueries[0].variants[0].filters).not.toEqual([]);
76
+
77
+ const legacyHydrated = legacyView.config.hydrate({
78
+ hydrationState: DEFAULT_HYDRATION_STATE,
79
+ sqlite: nodeSqlite(sqlite)
80
+ });
81
+ expect(
82
+ legacyHydrated.eventDescriptors[0].evaluateRowWithErrors({
83
+ sourceTable: { connectionTag: DEFAULT_TAG, schema: 'test_schema', name: 'checkpoints' },
84
+ record: { user_id: 'user-1', checkpoint: 4n, active: 0 }
85
+ })
86
+ ).toEqual({ results: [{ data: { user_id: 'user-1', checkpoint: 4n } }], errors: [] });
87
+ });
88
+
89
+ test('restores raw event descriptors attached to version 1 and 2 plans', () => {
90
+ const parsed = SqlSyncRules.fromYaml(yamlWithoutEvents, { defaultSchema: 'test_schema' });
91
+ const update = updateSyncRulesFromConfig(parsed);
92
+ const legacy: SerializedSyncPlan = {
93
+ ...update.config.plan!,
94
+ eventDescriptors: { write_checkpoints: [EVENT_QUERY] }
95
+ };
96
+
97
+ expect(legacy.plan.version).toBeLessThanOrEqual(2);
98
+ const restored = restore(legacy);
99
+ expect(restored.config).not.toHaveProperty('eventDescriptors');
100
+ expect(restored.config.eventDefinitions).toHaveLength(1);
101
+ expect((restored.config as PrecompiledSyncConfig).plan.events).toMatchObject([
102
+ { name: 'write_checkpoints', sourceQueries: [{ sql: EVENT_QUERY }] }
103
+ ]);
104
+ expect(restored.errors).toHaveLength(1);
105
+ expect(restored.errors[0]).toMatchObject({ type: 'warning' });
106
+ expect(
107
+ serializeSyncPlan((restored.config as PrecompiledSyncConfig).plan).events![0].sourceQueries[0].variants[0].filters
108
+ ).toEqual([]);
109
+ });
110
+
111
+ /**
112
+ * Event SQL accepted by the legacy evaluator must remain loadable after an upgrade, even when its ignored filter uses
113
+ * expressions that the compiled event evaluator intentionally rejects for new deployments.
114
+ */
115
+ test.each([
116
+ ['request parameters', 'SELECT user_id FROM checkpoints WHERE user_id = request.user_id()'],
117
+ ['unsupported expressions', 'SELECT user_id FROM checkpoints WHERE active IN (1, 2)']
118
+ ])('loads legacy event descriptors without validating ignored %s', (_description, eventQuery) => {
119
+ const parsed = SqlSyncRules.fromYaml(yamlWithoutEvents, { defaultSchema: 'test_schema' });
120
+ const update = updateSyncRulesFromConfig(parsed);
121
+ const legacy: SerializedSyncPlan = {
122
+ ...update.config.plan!,
123
+ eventDescriptors: {
124
+ write_checkpoints: [eventQuery]
125
+ }
126
+ };
127
+
128
+ const restored = restore(legacy);
129
+ expect(restored.errors).toHaveLength(1);
130
+ expect(restored.errors[0]).toMatchObject({ type: 'warning' });
131
+
132
+ const event = restored.config.hydrate({
133
+ hydrationState: DEFAULT_HYDRATION_STATE,
134
+ sqlite: nodeSqlite(sqlite)
135
+ }).eventDescriptors[0];
136
+ expect(
137
+ event.evaluateRowWithErrors({
138
+ sourceTable: { connectionTag: DEFAULT_TAG, schema: 'test_schema', name: 'checkpoints' },
139
+ record: { user_id: 'user-1' }
140
+ })
141
+ ).toEqual({ results: [{ data: { user_id: 'user-1' } }], errors: [] });
142
+ });
143
+ });
144
+
145
+ function restore(compiledPlan: SerializedSyncPlan) {
146
+ return parsePersistedSyncConfigContent({
147
+ content: yamlWithEvents,
148
+ compiledPlan,
149
+ storageVersion: 1,
150
+ parseOptions: { defaultSchema: 'test_schema' }
151
+ });
152
+ }
@@ -482,4 +482,26 @@ describe('checksum cache', function () {
482
482
  expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
483
483
  expect(await cache.getChecksums(1234n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_1234]);
484
484
  });
485
+
486
+ it('should refresh cached checksums after the configured TTL', async function () {
487
+ let lookups: FetchPartialBucketChecksum[][] = [];
488
+ const cache = new ChecksumCache({
489
+ fetchChecksums: async (batch) => {
490
+ lookups.push(batch);
491
+ return fetchTestChecksums(batch);
492
+ },
493
+ ttlMs: 1
494
+ });
495
+
496
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
497
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
498
+
499
+ await new Promise((resolve) => setTimeout(resolve, 1001)); // Needs to be >1s
500
+
501
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
502
+ expect(lookups.map(removeLookupSources)).toEqual([
503
+ [{ bucket: 'test', end: 123n }],
504
+ [{ bucket: 'test', end: 123n }]
505
+ ]);
506
+ });
485
507
  });
@@ -70,6 +70,146 @@ describe('Config', () => {
70
70
  expect(config.api_parameters.max_buckets_per_connection).toBe(1);
71
71
  });
72
72
 
73
+ it('should resolve the storage default version', {}, async () => {
74
+ const yamlConfig = /* yaml */ `
75
+ # PowerSync config
76
+ replication:
77
+ connections: []
78
+ storage:
79
+ type: mongodb
80
+ default_storage_version: 4
81
+ `;
82
+
83
+ const collector = new CompoundConfigCollector();
84
+
85
+ const config = await collector.collectConfig({
86
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
87
+ });
88
+
89
+ expect(config.storage.default_storage_version).toBe(4);
90
+ });
91
+
92
+ it.each([1, 1.5, 5])('should reject unsupported storage default version %s', async (defaultStorageVersion) => {
93
+ const yamlConfig = /* yaml */ `
94
+ # PowerSync config
95
+ replication:
96
+ connections: []
97
+ storage:
98
+ type: mongodb
99
+ default_storage_version: ${defaultStorageVersion}
100
+ `;
101
+
102
+ const collector = new CompoundConfigCollector();
103
+
104
+ await expect(
105
+ collector.collectConfig({
106
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
107
+ })
108
+ ).rejects.toThrow(`Storage version ${defaultStorageVersion} is not supported`);
109
+ });
110
+
111
+ it('should resolve checkpoint request retention config', {}, async () => {
112
+ const yamlConfig = /* yaml */ `
113
+ # PowerSync config
114
+ replication:
115
+ connections: []
116
+ storage:
117
+ type: mongodb
118
+ api:
119
+ parameters:
120
+ checkpoint_request_retention_minutes: 15
121
+ `;
122
+
123
+ const collector = new CompoundConfigCollector();
124
+
125
+ const config = await collector.collectConfig({
126
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
127
+ });
128
+
129
+ expect(config.api_parameters.checkpoint_request_retention_minutes).toBe(15);
130
+ });
131
+
132
+ it('should default checkpoint request retention to 60 minutes', {}, async () => {
133
+ const yamlConfig = /* yaml */ `
134
+ # PowerSync config
135
+ replication:
136
+ connections: []
137
+ storage:
138
+ type: mongodb
139
+ `;
140
+
141
+ const collector = new CompoundConfigCollector();
142
+
143
+ const config = await collector.collectConfig({
144
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
145
+ });
146
+
147
+ expect(config.api_parameters.checkpoint_request_retention_minutes).toBe(60);
148
+ });
149
+
150
+ it.each([0, -1, 1.5])('should reject checkpoint request retention of %s minutes', async (retentionMinutes) => {
151
+ const yamlConfig = /* yaml */ `
152
+ # PowerSync config
153
+ replication:
154
+ connections: []
155
+ storage:
156
+ type: mongodb
157
+ api:
158
+ parameters:
159
+ checkpoint_request_retention_minutes: ${retentionMinutes}
160
+ `;
161
+
162
+ const collector = new CompoundConfigCollector();
163
+
164
+ await expect(
165
+ collector.collectConfig({
166
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
167
+ })
168
+ ).rejects.toThrow('api.parameters.checkpoint_request_retention_minutes must be a positive integer');
169
+ });
170
+
171
+ it('should resolve checksum cache TTL from API parameters', async () => {
172
+ const yamlConfig = /* yaml */ `
173
+ # PowerSync config
174
+ replication:
175
+ connections: []
176
+ storage:
177
+ type: mongodb
178
+ api:
179
+ parameters:
180
+ bucket_count_cache_ttl_minutes: 15
181
+ `;
182
+
183
+ const collector = new CompoundConfigCollector();
184
+
185
+ const config = await collector.collectConfig({
186
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
187
+ });
188
+
189
+ expect(config.api_parameters.bucket_count_cache_ttl_minutes).toBe(15);
190
+ });
191
+
192
+ it('should reject fractional checksum cache TTL minutes', async () => {
193
+ const yamlConfig = /* yaml */ `
194
+ # PowerSync config
195
+ replication:
196
+ connections: []
197
+ storage:
198
+ type: mongodb
199
+ api:
200
+ parameters:
201
+ bucket_count_cache_ttl_minutes: 1.5
202
+ `;
203
+
204
+ const collector = new CompoundConfigCollector();
205
+
206
+ await expect(
207
+ collector.collectConfig({
208
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
209
+ })
210
+ ).rejects.toThrow('api.parameters.bucket_count_cache_ttl_minutes must be a positive integer');
211
+ });
212
+
73
213
  it('should throw YAML validation error for invalid base64 config', {}, async () => {
74
214
  const yamlConfig = /* yaml */ `
75
215
  # PowerSync config
@@ -96,6 +236,7 @@ describe('Config', () => {
96
236
  storage:
97
237
  type: mongodb
98
238
  sync_config:
239
+ version_label: v6
99
240
  content: |
100
241
  config:
101
242
  edition: 2
@@ -112,6 +253,7 @@ describe('Config', () => {
112
253
 
113
254
  expect(result.sync_rules).toEqual({
114
255
  present: true,
256
+ version_label: 'v6',
115
257
  exit_on_error: true,
116
258
  content: expect.stringContaining('edition: 2')
117
259
  });
@@ -1,6 +1,6 @@
1
1
  import { DiagnosticsOptions, getSyncRulesStatus } from '@/api/diagnostics.js';
2
2
  import { RouteAPI, SlotWalBudgetInfo } from '@/api/RouteAPI.js';
3
- import { BucketStorageFactory, PersistedSyncRules, storage } from '@/index.js';
3
+ import { ParsedSyncConfigSet, storage } from '@/index.js';
4
4
  import { SqlSyncRules } from '@powersync/service-sync-rules';
5
5
  import { describe, expect, test } from 'vitest';
6
6
 
@@ -13,53 +13,59 @@ bucket_definitions:
13
13
  - SELECT id FROM test_table
14
14
  `;
15
15
 
16
- function makeSyncRulesContent(overrides?: { slot_name?: string }): storage.PersistedSyncRulesContent {
16
+ function makeSyncRulesContent(overrides?: {
17
+ slot_name?: string;
18
+ version_label?: string;
19
+ status?: storage.PersistedSyncConfigStatus;
20
+ }): storage.PersistedSyncConfigContent {
17
21
  // We don't implement the entire interface correctly here - just enough to test the diagnostics logic.
18
- return {
19
- id: 1,
20
- slot_name: overrides?.slot_name ?? 'test_slot',
21
- sync_rules_content: MINIMAL_SYNC_RULES,
22
- compiled_plan: null,
23
- active: true,
24
- storageVersion: 1,
22
+ const status = overrides?.status ?? {
23
+ id: '1',
24
+ replicationStreamId: 1,
25
+ state: storage.SyncRuleState.ACTIVE,
25
26
  last_checkpoint_lsn: 'some_lsn',
26
27
  last_fatal_error: null,
27
28
  last_fatal_error_ts: null,
28
29
  last_keepalive_ts: new Date(),
29
- last_checkpoint_ts: new Date(),
30
+ last_checkpoint_ts: new Date()
31
+ };
32
+
33
+ return {
34
+ replicationStreamId: 1,
35
+ syncConfigId: null,
36
+ replicationStreamName: overrides?.slot_name ?? 'test_slot',
37
+ sync_rules_content: MINIMAL_SYNC_RULES,
38
+ compiled_plan: null,
39
+ storageVersion: 1,
40
+ syncConfigState: status.state,
41
+ version_label: overrides?.version_label,
30
42
  parsed(options?: any) {
31
43
  const syncRules = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
32
44
  ...options,
33
45
  defaultSchema: 'public'
34
46
  });
35
47
  return {
36
- syncConfigWithErrors: syncRules
37
- } as PersistedSyncRules;
48
+ syncConfigs: [syncRules]
49
+ } as ParsedSyncConfigSet;
38
50
  },
39
- lock() {
40
- throw new Error('Not implemented in mock');
41
- },
42
- current_lock: null,
43
51
  logger: null as any,
44
52
  asUpdateOptions: null as any,
45
- getStorageConfig: null as any
46
- } as storage.PersistedSyncRulesContent;
53
+ getStorageConfig: null as any,
54
+ async getSyncConfigStatus() {
55
+ return status;
56
+ }
57
+ } as storage.PersistedSyncConfigContent;
47
58
  }
48
59
 
49
- function makeBucketStorage() {
60
+ function makeSystemStorage() {
50
61
  return {
51
- getInstance() {
62
+ async getStatus() {
52
63
  return {
53
- async getStatus() {
54
- return {
55
- snapshot_done: true,
56
- checkpoint_lsn: 'some_lsn',
57
- active: true
58
- };
59
- }
64
+ snapshotDone: true,
65
+ resumeLsn: 'some_lsn'
60
66
  };
61
67
  }
62
- } as unknown as BucketStorageFactory;
68
+ } as storage.SyncRulesBucketStorage;
63
69
  }
64
70
 
65
71
  function makeRouteAPI(walBudget?: SlotWalBudgetInfo | undefined): RouteAPI {
@@ -92,7 +98,8 @@ function makeRouteAPI(walBudget?: SlotWalBudgetInfo | undefined): RouteAPI {
92
98
  const OPTIONS: DiagnosticsOptions = {
93
99
  live_status: true,
94
100
  check_connection: true,
95
- include_content: false
101
+ include_content: false,
102
+ active: true
96
103
  };
97
104
 
98
105
  describe('getSyncRulesStatus WAL budget warnings', () => {
@@ -102,7 +109,7 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
102
109
  safe_wal_size: 4 * GB,
103
110
  max_slot_wal_keep_size: 10 * GB
104
111
  });
105
- const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
112
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
106
113
  const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
107
114
  expect(walWarnings).toHaveLength(1);
108
115
  expect(walWarnings[0].level).toBe('warning');
@@ -115,7 +122,7 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
115
122
  safe_wal_size: 8 * GB,
116
123
  max_slot_wal_keep_size: 10 * GB
117
124
  });
118
- const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
125
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
119
126
  const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
120
127
  expect(walWarnings).toHaveLength(0);
121
128
  });
@@ -126,7 +133,7 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
126
133
  safe_wal_size: -2.4 * GB,
127
134
  max_slot_wal_keep_size: 1 * 1024 * 1024 // 1MB
128
135
  });
129
- const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
136
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
130
137
  const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
131
138
  expect(walWarnings).toHaveLength(1);
132
139
  expect(walWarnings[0].message).toContain('0%');
@@ -137,7 +144,7 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
137
144
  const api = makeRouteAPI({
138
145
  wal_status: 'lost'
139
146
  });
140
- const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
147
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
141
148
  const walErrors = result!.errors.filter(
142
149
  (e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
143
150
  );
@@ -146,10 +153,56 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
146
153
 
147
154
  test('no WAL error when getSlotWalBudget is not defined', async () => {
148
155
  const api = makeRouteAPI();
149
- const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
156
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
150
157
  const walErrors = result!.errors.filter(
151
158
  (e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
152
159
  );
153
160
  expect(walErrors).toHaveLength(0);
154
161
  });
162
+
163
+ test('uses sync config status for status-derived diagnostics fields', async () => {
164
+ const configStatus: storage.PersistedSyncConfigStatus = {
165
+ id: 'config-a',
166
+ replicationStreamId: 1,
167
+ state: storage.SyncRuleState.ACTIVE,
168
+ last_checkpoint_lsn: 'config_lsn',
169
+ last_fatal_error: 'config failed',
170
+ last_fatal_error_ts: new Date('2026-01-01T00:00:00.000Z'),
171
+ last_keepalive_ts: new Date('2026-01-01T00:01:00.000Z'),
172
+ last_checkpoint_ts: new Date('2026-01-01T00:02:00.000Z')
173
+ };
174
+ const result = await getSyncRulesStatus(
175
+ makeRouteAPI(),
176
+ makeSyncRulesContent({ status: configStatus }),
177
+ OPTIONS,
178
+ makeSystemStorage()
179
+ );
180
+
181
+ expect(result!.connections[0].last_checkpoint_ts).toBe('2026-01-01T00:02:00.000Z');
182
+ expect(result!.connections[0].last_keepalive_ts).toBe('2026-01-01T00:01:00.000Z');
183
+ expect(result!.errors.some((error) => error.message == 'config failed')).toBe(true);
184
+ });
185
+
186
+ test('includes the sync config version label', async () => {
187
+ const result = await getSyncRulesStatus(
188
+ makeRouteAPI(),
189
+ makeSyncRulesContent({ version_label: 'v6' }),
190
+ OPTIONS,
191
+ makeSystemStorage()
192
+ );
193
+
194
+ expect(result!.version_label).toBe('v6');
195
+ });
196
+
197
+ test('includes the sync config version label when parsing fails', async () => {
198
+ const content = makeSyncRulesContent({ version_label: 'v6' });
199
+ content.parsed = () => {
200
+ throw new Error('Invalid sync config');
201
+ };
202
+
203
+ const result = await getSyncRulesStatus(makeRouteAPI(), content, OPTIONS, makeSystemStorage());
204
+
205
+ expect(result!.version_label).toBe('v6');
206
+ expect(result!.errors).toEqual([expect.objectContaining({ level: 'fatal', message: 'Invalid sync config' })]);
207
+ });
155
208
  });
@@ -1,4 +1,11 @@
1
- import { AbstractModule, loadModules, ServiceContextContainer, TearDownOptions } from '@/index.js';
1
+ import {
2
+ AbstractModule,
3
+ loadModules,
4
+ ModuleManager,
5
+ ServiceContextContainer,
6
+ storage,
7
+ TearDownOptions
8
+ } from '@/index.js';
2
9
  import { describe, expect, it } from 'vitest';
3
10
 
4
11
  interface MockConfig {
@@ -27,6 +34,19 @@ class MockPostgresStorageModule extends AbstractModule {
27
34
  async initialize(context: ServiceContextContainer): Promise<void> {}
28
35
  async teardown(options: TearDownOptions): Promise<void> {}
29
36
  }
37
+ class RecordingModule extends AbstractModule {
38
+ teardownOptions: TearDownOptions | undefined;
39
+
40
+ constructor(name: string) {
41
+ super({ name });
42
+ }
43
+
44
+ async initialize(context: ServiceContextContainer): Promise<void> {}
45
+
46
+ async teardown(options: TearDownOptions): Promise<void> {
47
+ this.teardownOptions = options;
48
+ }
49
+ }
30
50
  const mockLoaders = {
31
51
  connection: {
32
52
  mysql: async () => {
@@ -99,4 +119,16 @@ describe('module loader', () => {
99
119
 
100
120
  await expect(loadModules(config as any, loaders)).rejects.toThrowError('Failed to load MySQL module');
101
121
  });
122
+
123
+ it('passes replication streams through teardown options', async () => {
124
+ const module = new RecordingModule('RecordingModule');
125
+ const moduleManager = new ModuleManager();
126
+ const replicationStreams = [{ replicationStreamId: 1 }] as storage.PersistedReplicationStream[];
127
+
128
+ moduleManager.register([module]);
129
+ await moduleManager.tearDown({ replicationStreams, syncRules: replicationStreams });
130
+
131
+ expect(module.teardownOptions?.replicationStreams).toBe(replicationStreams);
132
+ expect(module.teardownOptions?.syncRules).toBe(replicationStreams);
133
+ });
102
134
  });