@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.
- package/CHANGELOG.md +43 -0
- package/dist/api/RouteAPI.d.ts +8 -1
- package/dist/api/api-index.d.ts +1 -0
- package/dist/api/api-index.js +1 -0
- package/dist/api/api-index.js.map +1 -1
- package/dist/api/diagnostics.js +33 -16
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/source-capabilities.d.ts +7 -0
- package/dist/api/source-capabilities.js +23 -0
- package/dist/api/source-capabilities.js.map +1 -0
- package/dist/auth/JwtPayload.d.ts +2 -2
- package/dist/auth/JwtPayload.js +2 -2
- package/dist/auth/JwtPayload.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +4 -6
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +15 -0
- package/dist/replication/AbstractReplicator.js +35 -3
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.js +13 -2
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +39 -27
- package/dist/routes/endpoints/admin.d.ts +78 -54
- package/dist/routes/endpoints/admin.js +23 -9
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +39 -6
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +11 -1
- package/dist/storage/BucketStorageFactory.js +22 -2
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.d.ts +2 -1
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/StorageProvider.d.ts +3 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +1 -1
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +5 -1
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
- package/package.json +11 -11
- package/src/api/RouteAPI.ts +9 -1
- package/src/api/api-index.ts +1 -0
- package/src/api/diagnostics.ts +30 -14
- package/src/api/source-capabilities.ts +24 -0
- package/src/auth/JwtPayload.ts +2 -2
- package/src/replication/AbstractReplicationJob.ts +2 -4
- package/src/replication/AbstractReplicator.ts +44 -3
- package/src/replication/ReplicationModule.ts +12 -2
- package/src/routes/endpoints/admin.ts +28 -10
- package/src/routes/endpoints/sync-rules.ts +42 -9
- package/src/storage/BucketStorageFactory.ts +22 -2
- package/src/storage/PersistedSyncConfigContent.ts +3 -0
- package/src/storage/StorageProvider.ts +3 -0
- package/src/storage/SyncRulesBucketStorage.ts +1 -1
- package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +13 -1
- package/test/src/AbstractReplicator.test.ts +156 -0
- package/test/src/diagnostics.test.ts +128 -0
- package/test/src/routes/admin.test.ts +235 -4
- package/test/src/routes/sync-rules.test.ts +124 -0
- package/test/src/source_table_config.test.ts +104 -0
- package/test/src/storage/BucketStorageFactory.test.ts +46 -0
- 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).
|
|
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
|
+
});
|