@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.
- package/CHANGELOG.md +67 -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/entry/commands/compact-action.js +1 -1
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/entry/commands/config-command.js +1 -1
- package/dist/entry/commands/config-command.js.map +1 -1
- package/dist/migrations/PowerSyncMigrationManager.js +2 -1
- package/dist/migrations/PowerSyncMigrationManager.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 +37 -5
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.d.ts +0 -7
- package/dist/replication/ReplicationModule.js +13 -3
- 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 +24 -10
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +7 -5
- package/dist/routes/endpoints/socket-route.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 +20 -3
- package/dist/storage/BucketStorageFactory.js +25 -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/StorageVersionConfig.d.ts +0 -4
- package/dist/storage/StorageVersionConfig.js +0 -4
- package/dist/storage/StorageVersionConfig.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +3 -1
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +9 -5
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +1 -1
- package/dist/sync/BucketChecksumState.js +5 -4
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/util/param-logging.d.ts +5 -2
- package/dist/util/param-logging.js +17 -9
- package/dist/util/param-logging.js.map +1 -1
- package/dist/util/protocol-types.d.ts +7 -7
- package/dist/util/protocol-types.js +3 -3
- 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/entry/commands/compact-action.ts +1 -1
- package/src/entry/commands/config-command.ts +1 -1
- package/src/migrations/PowerSyncMigrationManager.ts +2 -1
- package/src/replication/AbstractReplicationJob.ts +2 -4
- package/src/replication/AbstractReplicator.ts +46 -5
- package/src/replication/ReplicationModule.ts +12 -12
- package/src/routes/endpoints/admin.ts +29 -11
- package/src/routes/endpoints/socket-route.ts +9 -6
- package/src/routes/endpoints/sync-rules.ts +42 -9
- package/src/storage/BucketStorageFactory.ts +33 -4
- package/src/storage/PersistedSyncConfigContent.ts +3 -0
- package/src/storage/StorageProvider.ts +3 -0
- package/src/storage/StorageVersionConfig.ts +0 -5
- package/src/storage/SyncRulesBucketStorage.ts +3 -1
- package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +17 -5
- package/src/sync/BucketChecksumState.ts +5 -4
- package/src/util/param-logging.ts +21 -14
- package/src/util/protocol-types.ts +5 -5
- package/test/src/AbstractReplicator.test.ts +156 -0
- package/test/src/broadcast_iterable.test.ts +26 -8
- 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/test/src/sync/BucketChecksumState.test.ts +195 -6
- package/test/src/util/param-logging.test.ts +59 -0
- 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
|
|
8
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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).
|
|
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
|
+
});
|