@powersync/service-core 1.26.1 → 1.27.1

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 (90) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/api/RouteAPI.d.ts +8 -1
  3. package/dist/api/api-index.d.ts +1 -0
  4. package/dist/api/api-index.js +1 -0
  5. package/dist/api/api-index.js.map +1 -1
  6. package/dist/api/diagnostics.js +33 -16
  7. package/dist/api/diagnostics.js.map +1 -1
  8. package/dist/api/source-capabilities.d.ts +7 -0
  9. package/dist/api/source-capabilities.js +23 -0
  10. package/dist/api/source-capabilities.js.map +1 -0
  11. package/dist/auth/JwtPayload.d.ts +2 -2
  12. package/dist/auth/JwtPayload.js +2 -2
  13. package/dist/auth/JwtPayload.js.map +1 -1
  14. package/dist/entry/commands/compact-action.js +1 -1
  15. package/dist/entry/commands/compact-action.js.map +1 -1
  16. package/dist/entry/commands/config-command.js +1 -1
  17. package/dist/entry/commands/config-command.js.map +1 -1
  18. package/dist/migrations/PowerSyncMigrationManager.js +2 -1
  19. package/dist/migrations/PowerSyncMigrationManager.js.map +1 -1
  20. package/dist/replication/AbstractReplicationJob.d.ts +4 -6
  21. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  22. package/dist/replication/AbstractReplicator.d.ts +15 -0
  23. package/dist/replication/AbstractReplicator.js +37 -5
  24. package/dist/replication/AbstractReplicator.js.map +1 -1
  25. package/dist/replication/ReplicationModule.d.ts +0 -7
  26. package/dist/replication/ReplicationModule.js +13 -3
  27. package/dist/replication/ReplicationModule.js.map +1 -1
  28. package/dist/routes/configure-fastify.d.ts +39 -27
  29. package/dist/routes/endpoints/admin.d.ts +78 -54
  30. package/dist/routes/endpoints/admin.js +24 -10
  31. package/dist/routes/endpoints/admin.js.map +1 -1
  32. package/dist/routes/endpoints/socket-route.js +7 -5
  33. package/dist/routes/endpoints/socket-route.js.map +1 -1
  34. package/dist/routes/endpoints/sync-rules.js +39 -6
  35. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  36. package/dist/storage/BucketStorageFactory.d.ts +20 -3
  37. package/dist/storage/BucketStorageFactory.js +25 -2
  38. package/dist/storage/BucketStorageFactory.js.map +1 -1
  39. package/dist/storage/PersistedSyncConfigContent.d.ts +2 -1
  40. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  41. package/dist/storage/StorageProvider.d.ts +3 -0
  42. package/dist/storage/StorageVersionConfig.d.ts +0 -4
  43. package/dist/storage/StorageVersionConfig.js +0 -4
  44. package/dist/storage/StorageVersionConfig.js.map +1 -1
  45. package/dist/storage/SyncRulesBucketStorage.d.ts +3 -1
  46. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  47. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +9 -5
  48. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
  49. package/dist/sync/BucketChecksumState.d.ts +1 -1
  50. package/dist/sync/BucketChecksumState.js +5 -4
  51. package/dist/sync/BucketChecksumState.js.map +1 -1
  52. package/dist/util/param-logging.d.ts +5 -2
  53. package/dist/util/param-logging.js +17 -9
  54. package/dist/util/param-logging.js.map +1 -1
  55. package/dist/util/protocol-types.d.ts +7 -7
  56. package/dist/util/protocol-types.js +3 -3
  57. package/package.json +11 -11
  58. package/src/api/RouteAPI.ts +9 -1
  59. package/src/api/api-index.ts +1 -0
  60. package/src/api/diagnostics.ts +30 -14
  61. package/src/api/source-capabilities.ts +24 -0
  62. package/src/auth/JwtPayload.ts +2 -2
  63. package/src/entry/commands/compact-action.ts +1 -1
  64. package/src/entry/commands/config-command.ts +1 -1
  65. package/src/migrations/PowerSyncMigrationManager.ts +2 -1
  66. package/src/replication/AbstractReplicationJob.ts +2 -4
  67. package/src/replication/AbstractReplicator.ts +46 -5
  68. package/src/replication/ReplicationModule.ts +12 -12
  69. package/src/routes/endpoints/admin.ts +29 -11
  70. package/src/routes/endpoints/socket-route.ts +9 -6
  71. package/src/routes/endpoints/sync-rules.ts +42 -9
  72. package/src/storage/BucketStorageFactory.ts +33 -4
  73. package/src/storage/PersistedSyncConfigContent.ts +3 -0
  74. package/src/storage/StorageProvider.ts +3 -0
  75. package/src/storage/StorageVersionConfig.ts +0 -5
  76. package/src/storage/SyncRulesBucketStorage.ts +3 -1
  77. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +17 -5
  78. package/src/sync/BucketChecksumState.ts +5 -4
  79. package/src/util/param-logging.ts +21 -14
  80. package/src/util/protocol-types.ts +5 -5
  81. package/test/src/AbstractReplicator.test.ts +156 -0
  82. package/test/src/broadcast_iterable.test.ts +26 -8
  83. package/test/src/diagnostics.test.ts +128 -0
  84. package/test/src/routes/admin.test.ts +235 -4
  85. package/test/src/routes/sync-rules.test.ts +124 -0
  86. package/test/src/source_table_config.test.ts +104 -0
  87. package/test/src/storage/BucketStorageFactory.test.ts +46 -0
  88. package/test/src/sync/BucketChecksumState.test.ts +195 -6
  89. package/test/src/util/param-logging.test.ts +59 -0
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -4,10 +4,16 @@ import { delayEach } from 'ix/asynciterable/operators/delayeach.js';
4
4
  import { take } from 'ix/asynciterable/operators/take.js';
5
5
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
6
6
  import { toArray } from 'ix/asynciterable/toarray.js';
7
- import * as timers from 'timers/promises';
8
- import { describe, expect, it } from 'vitest';
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ // The global setTimeout, not the one from timers/promises: vi.useFakeTimers() only replaces the global timers
10
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
9
11
 
10
12
  describe('BroadcastIterable', () => {
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
11
17
  it('should iterate', async () => {
12
18
  const range = AsyncIterableX.from([1, 2, 3]);
13
19
  const broadcast = new BroadcastIterable(() => range);
@@ -47,6 +53,7 @@ describe('BroadcastIterable', () => {
47
53
  });
48
54
 
49
55
  it('should handle indefinite sources', async () => {
56
+ vi.useFakeTimers();
50
57
  const source: IterableSource<number> = (signal) => {
51
58
  return wrapWithAbort(interval(1), signal);
52
59
  };
@@ -55,7 +62,9 @@ describe('BroadcastIterable', () => {
55
62
 
56
63
  const delayed = delayEach(10)(broadcast);
57
64
  expect(broadcast.active).toBe(false);
58
- const results = await toArray(take(5)(delayed));
65
+ const resultsPromise = toArray(take(5)(delayed));
66
+ await vi.advanceTimersByTimeAsync(100);
67
+ const results = await resultsPromise;
59
68
 
60
69
  expect(results.length).toEqual(5);
61
70
  expect(results[0]).toEqual(0);
@@ -66,6 +75,7 @@ describe('BroadcastIterable', () => {
66
75
  });
67
76
 
68
77
  it('should handle multiple subscribers', async () => {
78
+ vi.useFakeTimers();
69
79
  let sourceIndex = 0;
70
80
  const source = async function* (signal: AbortSignal) {
71
81
  // Test value out by 1000 means it may have used the wrong iteration of the source
@@ -75,7 +85,7 @@ describe('BroadcastIterable', () => {
75
85
  });
76
86
  for (let i = 0; !signal.aborted; i++) {
77
87
  yield base + i;
78
- await Promise.race([abortedPromise, timers.setTimeout(1)]);
88
+ await Promise.race([abortedPromise, sleep(1)]);
79
89
  }
80
90
  // Test value out by 100 means this wasn't reached
81
91
  sourceIndex += 100;
@@ -88,6 +98,7 @@ describe('BroadcastIterable', () => {
88
98
  expect(broadcast.active).toBe(false);
89
99
  const results1Promise = toArray(take(5)(delayed1));
90
100
  const results2Promise = toArray(take(5)(delayed2));
101
+ await vi.advanceTimersByTimeAsync(100);
91
102
  const [results1, results2] = [await results1Promise, await results2Promise];
92
103
 
93
104
  expect(broadcast.active).toBe(false);
@@ -104,7 +115,9 @@ describe('BroadcastIterable', () => {
104
115
 
105
116
  // This starts a new source
106
117
  const delayed3 = delayEach(10)(broadcast);
107
- const results3 = await toArray(take(5)(delayed3));
118
+ const results3Promise = toArray(take(5)(delayed3));
119
+ await vi.advanceTimersByTimeAsync(100);
120
+ const results3 = await results3Promise;
108
121
  expect(results3.length).toEqual(5);
109
122
  expect(results3[0]).toEqual(2100);
110
123
  expect(results3[4]).toBeGreaterThan(2110);
@@ -112,6 +125,7 @@ describe('BroadcastIterable', () => {
112
125
  });
113
126
 
114
127
  it('should handle errors on multiple subscribers', async () => {
128
+ vi.useFakeTimers();
115
129
  let sourceIndex = 0;
116
130
  const source = async function* (signal: AbortSignal) {
117
131
  // Test value out by 1000 means it may have used the wrong iteration of the source
@@ -124,7 +138,7 @@ describe('BroadcastIterable', () => {
124
138
  throw new Error('simulated failure');
125
139
  }
126
140
  yield base + i;
127
- await Promise.race([abortedPromise, timers.setTimeout(1)]);
141
+ await Promise.race([abortedPromise, sleep(1)]);
128
142
  }
129
143
  // Test value out by 100 means this wasn't reached
130
144
  sourceIndex += 100;
@@ -138,7 +152,9 @@ describe('BroadcastIterable', () => {
138
152
  const results1Promise = toArray(take(5)(delayed1)) as Promise<number[]>;
139
153
  const results2Promise = toArray(take(5)(delayed2)) as Promise<number[]>;
140
154
 
141
- const [r1, r2] = await Promise.allSettled([results1Promise, results2Promise]);
155
+ const settled = Promise.allSettled([results1Promise, results2Promise]);
156
+ await vi.advanceTimersByTimeAsync(100);
157
+ const [r1, r2] = await settled;
142
158
 
143
159
  expect(r1).toEqual({ status: 'rejected', reason: new Error('simulated failure') });
144
160
  expect(r2).toEqual({ status: 'rejected', reason: new Error('simulated failure') });
@@ -147,7 +163,9 @@ describe('BroadcastIterable', () => {
147
163
 
148
164
  // This starts a new source
149
165
  const delayed3 = delayEach(10)(broadcast);
150
- const results3 = await toArray(take(5)(delayed3));
166
+ const results3Promise = toArray(take(5)(delayed3));
167
+ await vi.advanceTimersByTimeAsync(100);
168
+ const results3 = await results3Promise;
151
169
  expect(results3.length).toEqual(5);
152
170
  expect(results3[0]).toEqual(2000);
153
171
  expect(results3[4]).toBeGreaterThan(2010);
@@ -205,4 +205,132 @@ describe('getSyncRulesStatus WAL budget warnings', () => {
205
205
  expect(result!.version_label).toBe('v6');
206
206
  expect(result!.errors).toEqual([expect.objectContaining({ level: 'fatal', message: 'Invalid sync config' })]);
207
207
  });
208
+
209
+ test('reports each sync config error with its source location when parsing fails', async () => {
210
+ const yaml = `
211
+ config:
212
+ edition: 3
213
+ streams:
214
+ global:
215
+ query: SELECT id FROM test_table
216
+ unknown_key: true
217
+ `;
218
+ const content = makeSyncRulesContent();
219
+ content.parsed = (options?: any) => {
220
+ SqlSyncRules.fromYaml(yaml, { ...options, defaultSchema: 'public', throwOnError: true });
221
+ throw new Error('Expected parsing to fail');
222
+ };
223
+
224
+ const result = await getSyncRulesStatus(makeRouteAPI(), content, OPTIONS, makeSystemStorage());
225
+
226
+ expect(result!.errors).toHaveLength(1);
227
+ const [error] = result!.errors;
228
+ expect(error.level).toBe('fatal');
229
+ expect(error.message).not.toContain('Expected parsing to fail');
230
+ expect(yaml.slice(error.location!.start_offset, error.location!.end_offset)).toBe('unknown_key');
231
+ });
232
+ });
233
+
234
+ describe('source capability validation', () => {
235
+ test('exposes stored replication errors when source checks cannot run', async () => {
236
+ const content = makeSyncRulesContent({
237
+ status: {
238
+ id: '1',
239
+ replicationStreamId: 1,
240
+ state: storage.SyncRuleState.PROCESSING,
241
+ last_checkpoint_lsn: null,
242
+ last_fatal_error: 'Source capability unavailable',
243
+ last_fatal_error_ts: new Date(),
244
+ last_keepalive_ts: null,
245
+ last_checkpoint_ts: null
246
+ }
247
+ });
248
+ const result = await getSyncRulesStatus(
249
+ makeRouteAPI(),
250
+ content,
251
+ {
252
+ ...OPTIONS,
253
+ check_connection: false,
254
+ active: false,
255
+ include_content: true
256
+ },
257
+ makeSystemStorage()
258
+ );
259
+ expect(result?.content).toBe(MINIMAL_SYNC_RULES);
260
+ expect(result?.errors).toContainEqual(
261
+ expect.objectContaining({
262
+ level: 'fatal',
263
+ message: 'Source capability unavailable'
264
+ })
265
+ );
266
+ });
267
+
268
+ test('reports capability failures alongside table diagnostics', async () => {
269
+ const api = makeRouteAPI();
270
+ api.validateSourceCapabilities = async () => [
271
+ { level: 'fatal', message: 'Source capability unavailable' },
272
+ { level: 'warning', message: 'Source advisory', location: { start_offset: 1, end_offset: 4 } }
273
+ ];
274
+ api.getDebugTablesInfo = async () => [
275
+ {
276
+ schema: 'public',
277
+ pattern: 'items',
278
+ wildcard: false,
279
+ table: {
280
+ schema: 'public',
281
+ name: 'items',
282
+ data_queries: true,
283
+ parameter_queries: false,
284
+ replication_id: [],
285
+ errors: [{ level: 'warning', message: 'Index missing' }]
286
+ }
287
+ }
288
+ ];
289
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
290
+ expect(result!.errors).toEqual(
291
+ expect.arrayContaining([
292
+ expect.objectContaining({ level: 'fatal', message: 'Source capability unavailable' }),
293
+ expect.objectContaining({ level: 'warning', message: 'Index missing' }),
294
+ expect.objectContaining({
295
+ level: 'warning',
296
+ message: 'Source advisory',
297
+ location: { start_offset: 1, end_offset: 4 }
298
+ })
299
+ ])
300
+ );
301
+ expect(result!.connections[0].tables).toHaveLength(1);
302
+ });
303
+
304
+ test('preserves both validation failures and continues independent diagnostics', async () => {
305
+ const api = makeRouteAPI({
306
+ wal_status: 'extended',
307
+ safe_wal_size: 4 * GB,
308
+ max_slot_wal_keep_size: 10 * GB
309
+ });
310
+ api.validateSourceCapabilities = async () => {
311
+ throw new Error('Source capability unavailable');
312
+ };
313
+ api.getDebugTablesInfo = async () => {
314
+ throw new Error('Table metadata unavailable');
315
+ };
316
+ const result = await getSyncRulesStatus(api, makeSyncRulesContent(), OPTIONS, makeSystemStorage());
317
+ expect(result!.errors).toEqual(
318
+ expect.arrayContaining([
319
+ expect.objectContaining({ level: 'fatal', message: 'Source capability unavailable' }),
320
+ expect.objectContaining({ level: 'fatal', message: 'Table metadata unavailable' }),
321
+ expect.objectContaining({ level: 'warning', message: expect.stringContaining('WAL budget') })
322
+ ])
323
+ );
324
+ });
325
+
326
+ test('does not check capabilities when connection checks are disabled', async () => {
327
+ const api = makeRouteAPI();
328
+ let called = false;
329
+ api.validateSourceCapabilities = async () => {
330
+ called = true;
331
+ return [];
332
+ };
333
+ await getSyncRulesStatus(api, makeSyncRulesContent(), { ...OPTIONS, check_connection: false }, makeSystemStorage());
334
+ expect(called).toBe(false);
335
+ });
208
336
  });
@@ -1,8 +1,9 @@
1
1
  import { BasicRouterRequest, Context, JwtPayload, ParsedSyncConfigSet, storage } from '@/index.js';
2
- import { logger } from '@powersync/lib-services-framework';
3
- import { SqlSyncRules } from '@powersync/service-sync-rules';
2
+ import { ErrorCode, logger } from '@powersync/lib-services-framework';
3
+ import { PrecompiledSyncConfig, SqlSyncRules } from '@powersync/service-sync-rules';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
5
  import { diagnostics, reprocess, validate } from '../../../src/routes/endpoints/admin.js';
6
+ import { deploySyncRules, reprocessSyncRules, validateSyncRules } from '../../../src/routes/endpoints/sync-rules.js';
6
7
  import { mockServiceContext } from './mocks.js';
7
8
 
8
9
  describe('admin routes', () => {
@@ -86,7 +87,136 @@ bucket_definitions:
86
87
  return content as unknown as storage.PersistedSyncConfigContent;
87
88
  }
88
89
 
90
+ it.each([false, true])(
91
+ 'continues sync-config diagnostics after capability failure (table failure: %s)',
92
+ async (tableFailure) => {
93
+ const context = makeContext();
94
+ const api = context.service_context.routerEngine.getAPI();
95
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(api);
96
+ api.validateSourceCapabilities = async () => {
97
+ throw new Error('Unsupported capability');
98
+ };
99
+ const tables = vi.spyOn(api, 'getDebugTablesInfo').mockImplementation(async () => {
100
+ if (tableFailure) throw new Error('Table inspection failed');
101
+ return [{ schema: 'public', pattern: 'items', wildcard: false, tables: [] }];
102
+ });
103
+ const response = await validateSyncRules.handler({
104
+ context,
105
+ request,
106
+ params: {
107
+ content: /* yaml */ `
108
+ # sync config fixture.
109
+ config:
110
+ edition: 3
111
+ streams:
112
+ items:
113
+ query: SELECT * FROM items
114
+ `
115
+ }
116
+ });
117
+ const body = JSON.parse(response.data);
118
+ expect(body.valid).toBe(false);
119
+ expect(body.errors).toEqual(
120
+ tableFailure ? ['Unsupported capability', 'Table inspection failed'] : ['Unsupported capability']
121
+ );
122
+ expect(tables).toHaveBeenCalledOnce();
123
+ expect(body.source_tables).toHaveLength(tableFailure ? 0 : 1);
124
+ expect(body.data_tables).toBeDefined();
125
+ }
126
+ );
127
+
128
+ it('keeps advisory capability diagnostics visible without invalidating the config', async () => {
129
+ const context = makeContext();
130
+ const api = context.service_context.routerEngine.getAPI();
131
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(api);
132
+ api.validateSourceCapabilities = async () => [{ level: 'warning', message: 'Source advisory' }];
133
+ const response = await validateSyncRules.handler({
134
+ context,
135
+ request,
136
+ params: {
137
+ content: /* yaml */ `
138
+ # sync config fixture.
139
+ config:
140
+ edition: 3
141
+ streams:
142
+ items:
143
+ query: SELECT * FROM items
144
+ `
145
+ }
146
+ });
147
+ expect(JSON.parse(response.data)).toMatchObject({ valid: true, warnings: ['Source advisory'] });
148
+ });
149
+
150
+ it.each([true, false])('deployment checks source capabilities before persistence (allowed: %s)', async (allowed) => {
151
+ const updateSyncRules = vi.fn(async () => ({ replicationStreamName: 'new-slot' }));
152
+ const context = makeContext({ updateSyncRules });
153
+ context.service_context.configuration = {
154
+ sync_rules: { present: false }
155
+ } as typeof context.service_context.configuration;
156
+ const api = context.service_context.routerEngine.getAPI();
157
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(api);
158
+ api.validateSourceCapabilities = async (config) => {
159
+ expect((config as PrecompiledSyncConfig).plan.sourceTableConfig?.orders?.mongodb_filter_expression).toEqual({
160
+ $eq: ['$$doc.active', true]
161
+ });
162
+ return allowed
163
+ ? [{ level: 'warning', message: 'Advisory source warning' }]
164
+ : [{ level: 'fatal', message: 'MongoDB pre-filtering is not available for this connection.' }];
165
+ };
166
+ const result = deploySyncRules.handler({
167
+ context,
168
+ request,
169
+ params: {
170
+ content: /* yaml */ `
171
+ # sync config fixture.
172
+ config:
173
+ edition: 3
174
+ source_table_options:
175
+ orders:
176
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
177
+ streams:
178
+ orders:
179
+ query: SELECT * FROM orders
180
+ `
181
+ }
182
+ });
183
+ if (allowed) {
184
+ await expect(result).resolves.toEqual({ slot_name: 'new-slot' });
185
+ expect(updateSyncRules).toHaveBeenCalledOnce();
186
+ } else {
187
+ await expect(result).rejects.toThrow();
188
+ expect(updateSyncRules).not.toHaveBeenCalled();
189
+ }
190
+ });
191
+
89
192
  describe('validate', () => {
193
+ it('uses the service parser and includes its diagnostics', async () => {
194
+ const context = makeContext();
195
+ const api = context.service_context.routerEngine.getAPI();
196
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(api);
197
+ api.validateSourceCapabilities = async () => {
198
+ throw new Error('Rejected by module validation.');
199
+ };
200
+ const response = await validate.handler({
201
+ context,
202
+ request,
203
+ params: {
204
+ sync_rules: /* yaml */ `
205
+ # sync config fixture.
206
+ config:
207
+ edition: 3
208
+ streams: {}
209
+ `
210
+ }
211
+ });
212
+ expect(response.errors).toContainEqual(
213
+ expect.objectContaining({
214
+ level: 'fatal',
215
+ message: 'Rejected by module validation.'
216
+ })
217
+ );
218
+ });
219
+
90
220
  it('reports errors with source location', async () => {
91
221
  const context = makeContext();
92
222
 
@@ -103,13 +233,59 @@ bucket_definitions:
103
233
  request
104
234
  });
105
235
 
106
- expect(response.errors).toEqual([
236
+ expect(response.errors).toContainEqual(
107
237
  expect.objectContaining({
108
238
  level: 'warning',
109
239
  location: { start_offset: 70, end_offset: 83 },
110
240
  message: 'Table public.missing_table not found'
111
241
  })
112
- ]);
242
+ );
243
+ });
244
+
245
+ it('warns that bucket_definitions are deprecated', async () => {
246
+ const context = makeContext();
247
+
248
+ const response = await validate.handler({
249
+ context,
250
+ params: {
251
+ sync_rules: `
252
+ bucket_definitions:
253
+ mybucket:
254
+ data:
255
+ - SELECT * FROM missing_table
256
+ `
257
+ },
258
+ request
259
+ });
260
+
261
+ expect(response.errors).toContainEqual(
262
+ expect.objectContaining({
263
+ level: 'warning',
264
+ message: expect.stringContaining('Sync Rules (`bucket_definitions`) are deprecated')
265
+ })
266
+ );
267
+ });
268
+
269
+ it('does not report the deprecation warning for a Sync Streams config', async () => {
270
+ const context = makeContext();
271
+
272
+ const response = await validate.handler({
273
+ context,
274
+ params: {
275
+ sync_rules: `
276
+ config:
277
+ edition: 3
278
+ streams:
279
+ mystream:
280
+ query: SELECT * FROM missing_table
281
+ `
282
+ },
283
+ request
284
+ });
285
+
286
+ expect(response.errors).not.toContainEqual(
287
+ expect.objectContaining({ message: expect.stringContaining('are deprecated') })
288
+ );
113
289
  });
114
290
  });
115
291
 
@@ -151,6 +327,39 @@ bucket_definitions:
151
327
  });
152
328
 
153
329
  describe('reprocess', () => {
330
+ it.each([reprocess, reprocessSyncRules].flatMap((route) => [false, true].map((throws) => ({ route, throws }))))(
331
+ 'returns 422 for rejected reprocessing at $route.path (adapter throws: $throws)',
332
+ async ({ route, throws }) => {
333
+ const active = makeSyncConfigContent({ id: 7 });
334
+ const updateSyncRules = vi.fn();
335
+ const context = makeContext({
336
+ getDeployingSyncConfig: vi.fn(async () => null),
337
+ getActiveSyncConfig: vi.fn(async () => ({ content: active })),
338
+ updateSyncRules
339
+ });
340
+ const api = context.service_context.routerEngine.getAPI();
341
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(api);
342
+ api.validateSourceCapabilities = async () => {
343
+ if (throws) {
344
+ throw new Error('Unsupported source');
345
+ }
346
+ return [
347
+ { level: 'warning', message: 'Advisory' },
348
+ { level: 'fatal', message: 'Unsupported source' }
349
+ ];
350
+ };
351
+ await expect(route.handler({ context, params: {}, request })).rejects.toMatchObject({
352
+ errorData: {
353
+ status: 422,
354
+ code: ErrorCode.PSYNC_R0001,
355
+ description: 'Source capability validation failed',
356
+ details: 'Unsupported source'
357
+ }
358
+ });
359
+ expect(updateSyncRules).not.toHaveBeenCalled();
360
+ }
361
+ );
362
+
154
363
  it('reprocesses the active sync config', async () => {
155
364
  const active = makeSyncConfigContent({ id: 7, syncConfigId: 'active-config', version_label: 'v6' });
156
365
  const updateSyncRules = vi.fn(async () => ({
@@ -214,5 +423,27 @@ bucket_definitions:
214
423
  expect(activeBucketStorage.getActiveSyncConfig).not.toHaveBeenCalled();
215
424
  expect(activeBucketStorage.updateSyncRules).not.toHaveBeenCalled();
216
425
  });
426
+
427
+ it('logs that bucket_definitions are deprecated', async () => {
428
+ const warnSpy = vi.spyOn(logger, 'warn');
429
+ const activeBucketStorage = {
430
+ getDeployingSyncConfig: vi.fn(async () => null),
431
+ getActiveSyncConfig: vi.fn(async () => ({
432
+ content: makeSyncConfigContent({}),
433
+ replicationStream: {},
434
+ storage: {}
435
+ })),
436
+ updateSyncRules: vi.fn(async () => ({ replicationStreamName: 'new_slot' }))
437
+ };
438
+
439
+ await reprocess.handler({
440
+ context: makeContext(activeBucketStorage),
441
+ params: {},
442
+ request
443
+ });
444
+
445
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sync Rules (`bucket_definitions`) are deprecated'));
446
+ warnSpy.mockRestore();
447
+ });
217
448
  });
218
449
  });
@@ -0,0 +1,124 @@
1
+ import { BasicRouterRequest, Context, JwtPayload } from '@/index.js';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { deploySyncRules, validateSyncRules } from '../../../src/routes/endpoints/sync-rules.js';
5
+ import { mockServiceContext } from './mocks.js';
6
+
7
+ describe('sync-rules routes', () => {
8
+ const request: BasicRouterRequest = {
9
+ headers: {},
10
+ hostname: '',
11
+ protocol: 'http'
12
+ };
13
+
14
+ function makeContext(): Context {
15
+ const service_context = mockServiceContext(null);
16
+ (service_context.storageEngine as any).activeBucketStorage = {
17
+ updateSyncRules: vi.fn(async () => ({ replicationStreamName: 'new_slot' }))
18
+ };
19
+ // Deploying through the API requires the sync config to not be set in the service config.
20
+ (service_context as any).configuration = { sync_rules: { present: false } };
21
+
22
+ return {
23
+ logger: logger,
24
+ service_context,
25
+ token_payload: new JwtPayload({
26
+ sub: '',
27
+ exp: 0,
28
+ iat: 0
29
+ })
30
+ };
31
+ }
32
+
33
+ describe('filter connection validation', () => {
34
+ const FILTERED_CONFIG = /* yaml */ `
35
+ # sync config fixture.
36
+ config:
37
+ edition: 3
38
+ source_table_options:
39
+ orders:
40
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
41
+ streams:
42
+ orders:
43
+ query: SELECT * FROM orders
44
+ `;
45
+
46
+ it.each(['postgresql', 'mongodb', undefined])('reports connection-type validation for %s', async (type) => {
47
+ const context = makeContext();
48
+ const adapter = context.service_context.routerEngine.getAPI();
49
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(adapter);
50
+ vi.spyOn(adapter, 'getParseSyncRulesOptions').mockReturnValue({
51
+ defaultSchema: 'public',
52
+ connections: type === undefined ? undefined : { default: { type } }
53
+ });
54
+ const response = await validateSyncRules.handler({ context, params: { content: FILTERED_CONFIG }, request });
55
+ const result = JSON.parse(response.data);
56
+ expect(result.valid).toBe(type !== 'postgresql');
57
+ if (type === 'postgresql') {
58
+ expect(result.errors).toEqual([expect.stringContaining('only supported by MongoDB sources')]);
59
+ } else if (type === undefined) {
60
+ expect(result.warnings).toEqual([expect.stringContaining('without connection type information')]);
61
+ } else {
62
+ expect(result.warnings).toBeUndefined();
63
+ }
64
+ });
65
+
66
+ it('rejects deployment during parsing before persisting a non-MongoDB filter', async () => {
67
+ const context = makeContext();
68
+ const adapter = context.service_context.routerEngine.getAPI();
69
+ vi.spyOn(context.service_context.routerEngine, 'getAPI').mockReturnValue(adapter);
70
+ vi.spyOn(adapter, 'getParseSyncRulesOptions').mockReturnValue({
71
+ defaultSchema: 'public',
72
+ connections: { default: { type: 'postgresql' } }
73
+ });
74
+ await expect(deploySyncRules.handler({ context, params: { content: FILTERED_CONFIG }, request })).rejects.toThrow(
75
+ 'Sync config parsing failed'
76
+ );
77
+ expect(context.service_context.storageEngine.activeBucketStorage.updateSyncRules).not.toHaveBeenCalled();
78
+ });
79
+ });
80
+
81
+ describe('deploy', () => {
82
+ it('logs that bucket_definitions are deprecated', async () => {
83
+ const warnSpy = vi.spyOn(logger, 'warn');
84
+
85
+ const response = await deploySyncRules.handler({
86
+ context: makeContext(),
87
+ params: {
88
+ content: `
89
+ bucket_definitions:
90
+ global:
91
+ data:
92
+ - SELECT id FROM test
93
+ `
94
+ },
95
+ request
96
+ });
97
+
98
+ expect(response).toEqual({ slot_name: 'new_slot' });
99
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sync Rules (`bucket_definitions`) are deprecated'));
100
+ warnSpy.mockRestore();
101
+ });
102
+
103
+ it('does not log a warning for a Sync Streams config', async () => {
104
+ const warnSpy = vi.spyOn(logger, 'warn');
105
+
106
+ await deploySyncRules.handler({
107
+ context: makeContext(),
108
+ params: {
109
+ content: `
110
+ config:
111
+ edition: 3
112
+ streams:
113
+ global:
114
+ query: SELECT id FROM test
115
+ `
116
+ },
117
+ request
118
+ });
119
+
120
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Sync config warning'));
121
+ warnSpy.mockRestore();
122
+ });
123
+ });
124
+ });