@powersync/service-core 1.27.0 → 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 (58) hide show
  1. package/CHANGELOG.md +43 -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/replication/AbstractReplicationJob.d.ts +4 -6
  15. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  16. package/dist/replication/AbstractReplicator.d.ts +15 -0
  17. package/dist/replication/AbstractReplicator.js +35 -3
  18. package/dist/replication/AbstractReplicator.js.map +1 -1
  19. package/dist/replication/ReplicationModule.js +13 -2
  20. package/dist/replication/ReplicationModule.js.map +1 -1
  21. package/dist/routes/configure-fastify.d.ts +39 -27
  22. package/dist/routes/endpoints/admin.d.ts +78 -54
  23. package/dist/routes/endpoints/admin.js +23 -9
  24. package/dist/routes/endpoints/admin.js.map +1 -1
  25. package/dist/routes/endpoints/sync-rules.js +39 -6
  26. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  27. package/dist/storage/BucketStorageFactory.d.ts +11 -1
  28. package/dist/storage/BucketStorageFactory.js +22 -2
  29. package/dist/storage/BucketStorageFactory.js.map +1 -1
  30. package/dist/storage/PersistedSyncConfigContent.d.ts +2 -1
  31. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  32. package/dist/storage/StorageProvider.d.ts +3 -0
  33. package/dist/storage/SyncRulesBucketStorage.d.ts +1 -1
  34. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +5 -1
  35. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
  36. package/package.json +11 -11
  37. package/src/api/RouteAPI.ts +9 -1
  38. package/src/api/api-index.ts +1 -0
  39. package/src/api/diagnostics.ts +30 -14
  40. package/src/api/source-capabilities.ts +24 -0
  41. package/src/auth/JwtPayload.ts +2 -2
  42. package/src/replication/AbstractReplicationJob.ts +2 -4
  43. package/src/replication/AbstractReplicator.ts +44 -3
  44. package/src/replication/ReplicationModule.ts +12 -2
  45. package/src/routes/endpoints/admin.ts +28 -10
  46. package/src/routes/endpoints/sync-rules.ts +42 -9
  47. package/src/storage/BucketStorageFactory.ts +22 -2
  48. package/src/storage/PersistedSyncConfigContent.ts +3 -0
  49. package/src/storage/StorageProvider.ts +3 -0
  50. package/src/storage/SyncRulesBucketStorage.ts +1 -1
  51. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +13 -1
  52. package/test/src/AbstractReplicator.test.ts +156 -0
  53. package/test/src/diagnostics.test.ts +128 -0
  54. package/test/src/routes/admin.test.ts +235 -4
  55. package/test/src/routes/sync-rules.test.ts +124 -0
  56. package/test/src/source_table_config.test.ts +104 -0
  57. package/test/src/storage/BucketStorageFactory.test.ts +46 -0
  58. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ });
@@ -0,0 +1,104 @@
1
+ import { isCompatible, parsePersistedSyncConfigContent, updateSyncRulesFromConfig } from '@/storage/storage-index.js';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { PrecompiledSyncConfig, SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import { describe, expect, test } from 'vitest';
5
+
6
+ const CONFIGURED = /* yaml */ `
7
+ # sync config fixture.
8
+ config:
9
+ edition: 3
10
+ source_table_options:
11
+ orders:
12
+ mongodb_filter_expression: { $eq: ['$$doc.active', true] }
13
+ streams:
14
+ orders:
15
+ query: SELECT * FROM orders
16
+ `;
17
+
18
+ describe('source-table options', () => {
19
+ test('preserves filters through compiled storage', () => {
20
+ const parsed = SqlSyncRules.fromYaml(CONFIGURED, { defaultSchema: 'app' });
21
+ const update = updateSyncRulesFromConfig(parsed);
22
+ const restored = parsePersistedSyncConfigContent({
23
+ content: CONFIGURED,
24
+ compiledPlan: update.config.plan!,
25
+ storageVersion: 2,
26
+ parseOptions: { defaultSchema: 'app' }
27
+ });
28
+ expect((restored.config as PrecompiledSyncConfig).plan.sourceTableConfig).toEqual({
29
+ orders: { mongodb_filter_expression: { $eq: ['$$doc.active', true] } }
30
+ });
31
+ });
32
+ test('reports invalid expressions before storage', () => {
33
+ expect(() =>
34
+ SqlSyncRules.fromYaml(CONFIGURED.replace('$$doc.active', '$doc.active'), {
35
+ defaultSchema: 'app'
36
+ })
37
+ ).toThrow('Expected a source field');
38
+ });
39
+ test.each([
40
+ ['a..orders: {}', 'Source table patterns must use'],
41
+ ['Users: {}, users: {}', 'resolve to the same pattern']
42
+ ])('persists fatal diagnostics for invalid source-table options: %s', (options, message) => {
43
+ const yaml = /* yaml */ `
44
+ # sync config fixture.
45
+ config:
46
+ edition: 3
47
+ source_table_options: { ${options} }
48
+ streams:
49
+ orders:
50
+ query: SELECT * FROM orders
51
+ `;
52
+ const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app', throwOnError: false });
53
+ expect(parsed.errors).toContainEqual(
54
+ expect.objectContaining({ type: 'fatal', message: expect.stringContaining(message) })
55
+ );
56
+ const diagnostic = parsed.errors.find((error) => error.type === 'fatal' && error.message.includes(message))!;
57
+ const offendingKey = options.startsWith('a..') ? 'a..orders' : 'users';
58
+ expect(yaml.slice(diagnostic.location.start, diagnostic.location.end)).toContain(offendingKey);
59
+ const update = updateSyncRulesFromConfig(parsed);
60
+ expect(update.config.yaml).toBe(yaml);
61
+ const restored = parsePersistedSyncConfigContent({
62
+ content: update.config.yaml,
63
+ compiledPlan: update.config.plan!,
64
+ storageVersion: 2,
65
+ parseOptions: { defaultSchema: 'app' }
66
+ });
67
+ expect(restored.errors.map(({ message, type, location }) => ({ message, type, location }))).toEqual(
68
+ parsed.errors.map(({ message, type, location }) => ({ message, type, location }))
69
+ );
70
+ expect((restored.config as PrecompiledSyncConfig).plan.sourceTableConfig ?? {}).toEqual({});
71
+ });
72
+
73
+ test('keeps valid filters on a partial plan with an unrelated SQL error', () => {
74
+ const yaml = CONFIGURED.replace('SELECT * FROM orders', 'SELECT FROM');
75
+ const parsed = SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app', throwOnError: false });
76
+ expect(parsed.errors.some((error) => error.type === 'fatal')).toBe(true);
77
+ const update = updateSyncRulesFromConfig(parsed);
78
+ expect(update.config.plan!.plan.sourceTableConfig).toEqual({
79
+ orders: { mongodb_filter_expression: { $eq: ['$$doc.active', true] } }
80
+ });
81
+ });
82
+ test('requires replacement processing when filters change or are removed', () => {
83
+ const deploy = (yaml: string) => updateSyncRulesFromConfig(SqlSyncRules.fromYaml(yaml, { defaultSchema: 'app' }));
84
+ const first = deploy(CONFIGURED);
85
+ const same = deploy(CONFIGURED.replace('SELECT *', 'SELECT id'));
86
+ const changed = deploy(CONFIGURED.replace('true', 'false'));
87
+ const removed = deploy(
88
+ CONFIGURED.replace(
89
+ "mongodb_filter_expression: { $eq: ['$$doc.active', true] }",
90
+ 'mongodb_filter_expression: disabled'
91
+ )
92
+ );
93
+ expect(isCompatible([first.config.plan], same.config, logger)).toBe(true);
94
+ expect(isCompatible([first.config.plan], changed.config, logger)).toBe(false);
95
+ expect(isCompatible([first.config.plan], removed.config, logger)).toBe(false);
96
+ });
97
+ test('requires replacement processing when saved source-table options no longer validate', () => {
98
+ const update = updateSyncRulesFromConfig(SqlSyncRules.fromYaml(CONFIGURED, { defaultSchema: 'app' }));
99
+ const existing = structuredClone(update.config.plan!);
100
+ // A saved expression can become unsupported after a module or version change.
101
+ Object.assign(existing.plan.sourceTableConfig!.orders!, { mongodb_filter_expression: { $unsupported: [] } });
102
+ expect(isCompatible([existing], update.config, logger)).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import * as storage from '../../../src/storage/storage-index.js';
3
+
4
+ describe('logSyncConfigErrors', () => {
5
+ function makeLogger() {
6
+ return { warn: vi.fn(), error: vi.fn() };
7
+ }
8
+
9
+ test('logs fatal errors and warnings when the sync config is not validated', () => {
10
+ // Without validation, as at startup with exit_on_error: false, parsing doesn't throw on fatal errors.
11
+ const updateOptions = storage.updateSyncRulesFromYaml(
12
+ `
13
+ bucket_definitions:
14
+ mybucket:
15
+ data:
16
+ - SELECT FROM
17
+ `,
18
+ { validate: false }
19
+ );
20
+ const log = makeLogger();
21
+
22
+ storage.logSyncConfigErrors(updateOptions.config.parsed, log as any);
23
+
24
+ expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Sync config error:'));
25
+ expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Sync Rules (`bucket_definitions`) are deprecated'));
26
+ });
27
+
28
+ test('logs nothing for a valid Sync Streams config', () => {
29
+ const updateOptions = storage.updateSyncRulesFromYaml(
30
+ `
31
+ config:
32
+ edition: 3
33
+ streams:
34
+ global:
35
+ query: SELECT id FROM test
36
+ `,
37
+ { validate: true }
38
+ );
39
+ const log = makeLogger();
40
+
41
+ storage.logSyncConfigErrors(updateOptions.config.parsed, log as any);
42
+
43
+ expect(log.error).not.toHaveBeenCalled();
44
+ expect(log.warn).not.toHaveBeenCalled();
45
+ });
46
+ });