@powersync/service-core 1.20.5 → 1.22.0
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 +66 -0
- package/dist/api/RouteAPI.d.ts +3 -3
- package/dist/api/diagnostics.d.ts +1 -1
- package/dist/api/diagnostics.js +19 -3
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/auth/RemoteJWKSCollector.js +3 -2
- package/dist/auth/RemoteJWKSCollector.js.map +1 -1
- package/dist/entry/commands/teardown-action.js +1 -1
- package/dist/entry/commands/teardown-action.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/AbstractModule.d.ts +1 -1
- package/dist/replication/AbstractReplicationJob.js +1 -1
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +6 -6
- package/dist/replication/AbstractReplicator.js +21 -21
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/RelationCache.d.ts +9 -2
- package/dist/replication/RelationCache.js +21 -2
- package/dist/replication/RelationCache.js.map +1 -1
- package/dist/routes/configure-fastify.js +3 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.js +16 -8
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.js +1 -1
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +1 -1
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +8 -8
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +2 -2
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/route-register.d.ts +2 -0
- package/dist/routes/route-register.js +65 -3
- package/dist/routes/route-register.js.map +1 -1
- package/dist/runner/teardown.js +4 -4
- package/dist/runner/teardown.js.map +1 -1
- package/dist/storage/BucketStorage.d.ts +9 -9
- package/dist/storage/BucketStorage.js +9 -9
- package/dist/storage/BucketStorageBatch.d.ts +29 -0
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +27 -18
- package/dist/storage/BucketStorageFactory.js +13 -12
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncRulesContent.d.ts +6 -4
- package/dist/storage/PersistedSyncRulesContent.js +15 -8
- package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
- package/dist/storage/SourceEntity.d.ts +8 -1
- package/dist/storage/SourceTable.d.ts +32 -11
- package/dist/storage/SourceTable.js +41 -15
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/StorageVersionConfig.d.ts +1 -1
- package/dist/storage/StorageVersionConfig.js +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +63 -18
- package/dist/storage/SyncRulesBucketStorage.js +14 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +6 -6
- package/dist/storage/WriteCheckpointAPI.js +1 -1
- package/dist/storage/bson.d.ts +0 -1
- package/dist/storage/bson.js +0 -4
- package/dist/storage/bson.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +6 -9
- package/dist/sync/BucketChecksumState.js +117 -58
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.d.ts +2 -2
- package/dist/sync/sync.js.map +1 -1
- package/dist/tracing/PerformanceTracer.d.ts +60 -0
- package/dist/tracing/PerformanceTracer.js +105 -0
- package/dist/tracing/PerformanceTracer.js.map +1 -0
- package/dist/tracing/TraceWriter.d.ts +22 -0
- package/dist/tracing/TraceWriter.js +63 -0
- package/dist/tracing/TraceWriter.js.map +1 -0
- package/dist/util/checkpointing.js +1 -1
- package/dist/util/config/compound-config-collector.d.ts +1 -1
- package/dist/util/config/compound-config-collector.js +2 -2
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -1
- package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
- package/dist/util/env.js +1 -1
- package/dist/util/protocol-types.d.ts +1 -1
- package/dist/util/protocol-types.js +1 -1
- package/dist/util/util-index.d.ts +1 -0
- package/dist/util/util-index.js +1 -0
- package/dist/util/util-index.js.map +1 -1
- package/dist/util/utils.d.ts +5 -0
- package/dist/util/utils.js +7 -0
- package/dist/util/utils.js.map +1 -1
- package/package.json +11 -11
- package/src/api/RouteAPI.ts +3 -3
- package/src/api/diagnostics.ts +29 -6
- package/src/auth/RemoteJWKSCollector.ts +3 -1
- package/src/entry/commands/teardown-action.ts +1 -1
- package/src/index.ts +2 -0
- package/src/modules/AbstractModule.ts +1 -1
- package/src/replication/AbstractReplicationJob.ts +1 -1
- package/src/replication/AbstractReplicator.ts +23 -23
- package/src/replication/RelationCache.ts +23 -4
- package/src/routes/configure-fastify.ts +8 -1
- package/src/routes/endpoints/admin.ts +17 -8
- package/src/routes/endpoints/checkpointing.ts +1 -1
- package/src/routes/endpoints/socket-route.ts +1 -1
- package/src/routes/endpoints/sync-rules.ts +8 -8
- package/src/routes/endpoints/sync-stream.ts +2 -2
- package/src/routes/route-register.ts +73 -4
- package/src/runner/teardown.ts +4 -4
- package/src/storage/BucketStorage.ts +9 -9
- package/src/storage/BucketStorageBatch.ts +32 -0
- package/src/storage/BucketStorageFactory.ts +35 -23
- package/src/storage/PersistedSyncRulesContent.ts +20 -12
- package/src/storage/SourceEntity.ts +9 -1
- package/src/storage/SourceTable.ts +56 -22
- package/src/storage/StorageVersionConfig.ts +1 -1
- package/src/storage/SyncRulesBucketStorage.ts +74 -22
- package/src/storage/WriteCheckpointAPI.ts +6 -6
- package/src/storage/bson.ts +0 -5
- package/src/sync/BucketChecksumState.ts +142 -78
- package/src/sync/sync.ts +4 -4
- package/src/tracing/PerformanceTracer.ts +149 -0
- package/src/tracing/TraceWriter.ts +67 -0
- package/src/util/checkpointing.ts +1 -1
- package/src/util/config/compound-config-collector.ts +3 -3
- package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -1
- package/src/util/env.ts +1 -1
- package/src/util/protocol-types.ts +1 -1
- package/src/util/util-index.ts +1 -0
- package/src/util/utils.ts +8 -0
- package/test/src/auth.test.ts +120 -1
- package/test/src/diagnostics.test.ts +155 -0
- package/test/src/routes/error-handler.integration.test.ts +275 -0
- package/test/src/routes/stream.test.ts +15 -4
- package/test/src/storage/SourceTable.test.ts +89 -0
- package/test/src/sync/BucketChecksumState.test.ts +244 -80
- package/test/tsconfig.json +0 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/util/env.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const env = utils.collectEnvironmentVariables({
|
|
|
13
13
|
POWERSYNC_CONFIG_B64: utils.type.string.optional(),
|
|
14
14
|
/**
|
|
15
15
|
* @deprecated use POWERSYNC_SYNC_CONFIG_B64 instead.
|
|
16
|
-
* Base64 encoded contents of sync
|
|
16
|
+
* Base64 encoded contents of sync config YAML
|
|
17
17
|
*/
|
|
18
18
|
POWERSYNC_SYNC_RULES_B64: utils.type.string.optional(),
|
|
19
19
|
/**
|
package/src/util/util-index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './config/collectors/config-collector.js';
|
|
|
17
17
|
export * from './config/collectors/impl/base64-config-collector.js';
|
|
18
18
|
export * from './config/collectors/impl/fallback-config-collector.js';
|
|
19
19
|
export * from './config/collectors/impl/filesystem-config-collector.js';
|
|
20
|
+
export * from './config/collectors/impl/yaml-env.js';
|
|
20
21
|
|
|
21
22
|
export * from './config/sync-rules/impl/base64-sync-rules-collector.js';
|
|
22
23
|
export * from './config/sync-rules/impl/filesystem-sync-rules-collector.js';
|
package/src/util/utils.ts
CHANGED
|
@@ -39,6 +39,14 @@ export function escapeIdentifier(identifier: string) {
|
|
|
39
39
|
return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sanitized name of the entity in the format of "{schema}.{entity name}"
|
|
44
|
+
* Suitable for safe use in Postgres queries and in log output.
|
|
45
|
+
*/
|
|
46
|
+
export function qualifiedName(ref: sync_rules.SourceTableRef) {
|
|
47
|
+
return `${escapeIdentifier(ref.schema)}.${escapeIdentifier(ref.name)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
export function hashData(type: string, id: string, data: string): number {
|
|
43
51
|
const hash = crypto.createHash('sha256');
|
|
44
52
|
hash.update(`put.${type}.${id}.${data}`);
|
package/test/src/auth.test.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { StaticSupabaseKeyCollector } from '@/index.js';
|
|
2
|
+
import { configFile } from '@powersync/service-types';
|
|
2
3
|
import * as jose from 'jose';
|
|
3
4
|
import { describe, expect, test } from 'vitest';
|
|
4
5
|
import { CachedKeyCollector } from '../../src/auth/CachedKeyCollector.js';
|
|
5
6
|
import { KeyResult } from '../../src/auth/KeyCollector.js';
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
EC_ALGORITHMS,
|
|
9
|
+
HS_ALGORITHMS,
|
|
10
|
+
KeySpec,
|
|
11
|
+
OKP_ALGORITHMS,
|
|
12
|
+
RSA_ALGORITHMS,
|
|
13
|
+
SUPPORTED_ALGORITHMS
|
|
14
|
+
} from '../../src/auth/KeySpec.js';
|
|
7
15
|
import { KeyStore } from '../../src/auth/KeyStore.js';
|
|
8
16
|
import { RemoteJWKSCollector } from '../../src/auth/RemoteJWKSCollector.js';
|
|
9
17
|
import { StaticKeyCollector } from '../../src/auth/StaticKeyCollector.js';
|
|
@@ -52,6 +60,47 @@ const privateKeyECDSA: jose.JWK = {
|
|
|
52
60
|
alg: 'ES256'
|
|
53
61
|
};
|
|
54
62
|
|
|
63
|
+
const EC_ALGORITHM_CURVES = [
|
|
64
|
+
['ES256', 'P-256'],
|
|
65
|
+
['ES384', 'P-384'],
|
|
66
|
+
['ES512', 'P-521']
|
|
67
|
+
] satisfies [string, string][];
|
|
68
|
+
|
|
69
|
+
const EDDSA_CURVES = ['Ed25519', 'Ed448'];
|
|
70
|
+
|
|
71
|
+
function roundTripJwkThroughPowerSyncConfig(key: jose.JWK): jose.JWK {
|
|
72
|
+
const encoded = configFile.strictJwks.encode({
|
|
73
|
+
keys: [key as configFile.StrictJwk]
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const decoded = configFile.strictJwks.decode(encoded);
|
|
77
|
+
return decoded.keys[0] as jose.JWK;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function signAndVerifyWithKey(alg: string, key: jose.JWK, signKey: jose.KeyLike | Uint8Array) {
|
|
81
|
+
const parsedKey = roundTripJwkThroughPowerSyncConfig(key);
|
|
82
|
+
expect(parsedKey).toEqual(key);
|
|
83
|
+
|
|
84
|
+
const keys = await StaticKeyCollector.importKeys([parsedKey]);
|
|
85
|
+
const store = new KeyStore(keys);
|
|
86
|
+
|
|
87
|
+
const signedJwt = await new jose.SignJWT({ claim: alg })
|
|
88
|
+
.setProtectedHeader({ alg, kid: key.kid })
|
|
89
|
+
.setSubject('f1')
|
|
90
|
+
.setIssuedAt()
|
|
91
|
+
.setIssuer('tester')
|
|
92
|
+
.setAudience('tests')
|
|
93
|
+
.setExpirationTime('5m')
|
|
94
|
+
.sign(signKey);
|
|
95
|
+
|
|
96
|
+
const verified = await store.verifyJwt(signedJwt, {
|
|
97
|
+
defaultAudiences: ['tests'],
|
|
98
|
+
maxAge: '6m'
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(verified.parsedPayload.claim).toEqual(alg);
|
|
102
|
+
}
|
|
103
|
+
|
|
55
104
|
describe('JWT Auth', () => {
|
|
56
105
|
test('KeyStore basics', async () => {
|
|
57
106
|
const keys = await StaticKeyCollector.importKeys([sharedKey]);
|
|
@@ -208,6 +257,65 @@ describe('JWT Auth', () => {
|
|
|
208
257
|
).rejects.toThrow('Unexpected token algorithm HS256');
|
|
209
258
|
});
|
|
210
259
|
|
|
260
|
+
describe('supported JWT algorithms', () => {
|
|
261
|
+
test('covers every declared supported algorithm', () => {
|
|
262
|
+
const testedAlgorithms = new Set([
|
|
263
|
+
...HS_ALGORITHMS,
|
|
264
|
+
...RSA_ALGORITHMS,
|
|
265
|
+
...EC_ALGORITHM_CURVES.map(([alg]) => alg),
|
|
266
|
+
...OKP_ALGORITHMS
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
expect([...testedAlgorithms].sort()).toEqual([...SUPPORTED_ALGORITHMS].sort());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test.each(HS_ALGORITHMS)('verifies %s tokens', async (alg) => {
|
|
273
|
+
const secret = await jose.generateSecret(alg);
|
|
274
|
+
const key = await jose.exportJWK(secret);
|
|
275
|
+
key.kid = `test-${alg}`;
|
|
276
|
+
key.alg = alg;
|
|
277
|
+
|
|
278
|
+
await signAndVerifyWithKey(alg, key, secret);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test.each(RSA_ALGORITHMS)('verifies %s tokens', async (alg) => {
|
|
282
|
+
const { privateKey, publicKey } = await jose.generateKeyPair(alg);
|
|
283
|
+
const key = await jose.exportJWK(publicKey);
|
|
284
|
+
key.kid = `test-${alg}`;
|
|
285
|
+
key.alg = alg;
|
|
286
|
+
key.use = 'sig';
|
|
287
|
+
|
|
288
|
+
await signAndVerifyWithKey(alg, key, privateKey);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test.each(EC_ALGORITHM_CURVES)('verifies %s tokens with curve %s', async (alg, crv) => {
|
|
292
|
+
expect(EC_ALGORITHMS).toContain(alg);
|
|
293
|
+
|
|
294
|
+
const { privateKey, publicKey } = await jose.generateKeyPair(alg);
|
|
295
|
+
const key = await jose.exportJWK(publicKey);
|
|
296
|
+
key.kid = `test-${alg}`;
|
|
297
|
+
key.alg = alg;
|
|
298
|
+
key.use = 'sig';
|
|
299
|
+
|
|
300
|
+
expect(key.crv).toEqual(crv);
|
|
301
|
+
await signAndVerifyWithKey(alg, key, privateKey);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test.each(EDDSA_CURVES)('verifies EdDSA tokens with curve %s', async (crv) => {
|
|
305
|
+
const alg = 'EdDSA';
|
|
306
|
+
expect(OKP_ALGORITHMS).toContain(alg);
|
|
307
|
+
|
|
308
|
+
const { privateKey, publicKey } = await jose.generateKeyPair(alg, { crv });
|
|
309
|
+
const key = await jose.exportJWK(publicKey);
|
|
310
|
+
key.kid = `test-${alg}-${crv}`;
|
|
311
|
+
key.alg = alg;
|
|
312
|
+
key.use = 'sig';
|
|
313
|
+
|
|
314
|
+
expect(key.crv).toEqual(crv);
|
|
315
|
+
await signAndVerifyWithKey(alg, key, privateKey);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
211
319
|
test('key selection for key with kid', async () => {
|
|
212
320
|
const keys = await StaticKeyCollector.importKeys([publicKeyRSA, sharedKey, sharedKey2]);
|
|
213
321
|
const store = new KeyStore(keys);
|
|
@@ -376,6 +484,17 @@ describe('JWT Auth', () => {
|
|
|
376
484
|
}
|
|
377
485
|
})
|
|
378
486
|
).toThrowError('IPs in this range are not supported');
|
|
487
|
+
|
|
488
|
+
// `URL.hostname` exposes IPv6 literals wrapped in brackets; ensure they are
|
|
489
|
+
// handled like other direct-IP cases.
|
|
490
|
+
expect(
|
|
491
|
+
() =>
|
|
492
|
+
new RemoteJWKSCollector('https://[::1]/.well-known/jwks.json', {
|
|
493
|
+
lookupOptions: {
|
|
494
|
+
reject_ip_ranges: ['local']
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
).toThrowError('IPs in this range are not supported');
|
|
379
498
|
});
|
|
380
499
|
|
|
381
500
|
test('http not blocking local IPs', async () => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { DiagnosticsOptions, getSyncRulesStatus } from '@/api/diagnostics.js';
|
|
2
|
+
import { RouteAPI, SlotWalBudgetInfo } from '@/api/RouteAPI.js';
|
|
3
|
+
import { BucketStorageFactory, PersistedSyncRules, storage } from '@/index.js';
|
|
4
|
+
import { SqlSyncRules } from '@powersync/service-sync-rules';
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const GB = 1024 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const MINIMAL_SYNC_RULES = `
|
|
10
|
+
bucket_definitions:
|
|
11
|
+
global:
|
|
12
|
+
data:
|
|
13
|
+
- SELECT id FROM test_table
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
function makeSyncRulesContent(overrides?: { slot_name?: string }): storage.PersistedSyncRulesContent {
|
|
17
|
+
// We don't implement the entire interface correctly here - just enough to test the diagnostics logic.
|
|
18
|
+
return {
|
|
19
|
+
id: 1,
|
|
20
|
+
slot_name: overrides?.slot_name ?? 'test_slot',
|
|
21
|
+
sync_rules_content: MINIMAL_SYNC_RULES,
|
|
22
|
+
compiled_plan: null,
|
|
23
|
+
active: true,
|
|
24
|
+
storageVersion: 1,
|
|
25
|
+
last_checkpoint_lsn: 'some_lsn',
|
|
26
|
+
last_fatal_error: null,
|
|
27
|
+
last_fatal_error_ts: null,
|
|
28
|
+
last_keepalive_ts: new Date(),
|
|
29
|
+
last_checkpoint_ts: new Date(),
|
|
30
|
+
parsed(options?: any) {
|
|
31
|
+
const syncRules = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
|
|
32
|
+
...options,
|
|
33
|
+
defaultSchema: 'public'
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
syncConfigWithErrors: syncRules
|
|
37
|
+
} as PersistedSyncRules;
|
|
38
|
+
},
|
|
39
|
+
lock() {
|
|
40
|
+
throw new Error('Not implemented in mock');
|
|
41
|
+
},
|
|
42
|
+
current_lock: null,
|
|
43
|
+
logger: null as any,
|
|
44
|
+
asUpdateOptions: null as any,
|
|
45
|
+
getStorageConfig: null as any
|
|
46
|
+
} as storage.PersistedSyncRulesContent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeBucketStorage() {
|
|
50
|
+
return {
|
|
51
|
+
getInstance() {
|
|
52
|
+
return {
|
|
53
|
+
async getStatus() {
|
|
54
|
+
return {
|
|
55
|
+
snapshot_done: true,
|
|
56
|
+
checkpoint_lsn: 'some_lsn',
|
|
57
|
+
active: true
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
} as unknown as BucketStorageFactory;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeRouteAPI(walBudget?: SlotWalBudgetInfo | undefined): RouteAPI {
|
|
66
|
+
return {
|
|
67
|
+
getParseSyncRulesOptions() {
|
|
68
|
+
return { defaultSchema: 'public' };
|
|
69
|
+
},
|
|
70
|
+
async getSourceConfig() {
|
|
71
|
+
return { tag: 'test', id: 'test', type: 'postgresql' };
|
|
72
|
+
},
|
|
73
|
+
async getConnectionStatus() {
|
|
74
|
+
return { connected: true };
|
|
75
|
+
},
|
|
76
|
+
async getDebugTablesInfo() {
|
|
77
|
+
return [];
|
|
78
|
+
},
|
|
79
|
+
async getReplicationLagBytes() {
|
|
80
|
+
return 0;
|
|
81
|
+
},
|
|
82
|
+
...(walBudget !== undefined
|
|
83
|
+
? {
|
|
84
|
+
async getSlotWalBudget() {
|
|
85
|
+
return walBudget;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
: {})
|
|
89
|
+
} as unknown as RouteAPI;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const OPTIONS: DiagnosticsOptions = {
|
|
93
|
+
live_status: true,
|
|
94
|
+
check_connection: true,
|
|
95
|
+
include_content: false
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
describe('getSyncRulesStatus WAL budget warnings', () => {
|
|
99
|
+
test('warns when WAL budget is at 40%', async () => {
|
|
100
|
+
const api = makeRouteAPI({
|
|
101
|
+
wal_status: 'extended',
|
|
102
|
+
safe_wal_size: 4 * GB,
|
|
103
|
+
max_slot_wal_keep_size: 10 * GB
|
|
104
|
+
});
|
|
105
|
+
const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
|
|
106
|
+
const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
|
|
107
|
+
expect(walWarnings).toHaveLength(1);
|
|
108
|
+
expect(walWarnings[0].level).toBe('warning');
|
|
109
|
+
expect(walWarnings[0].message).toContain('40%');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('no warning when WAL budget is at 80%', async () => {
|
|
113
|
+
const api = makeRouteAPI({
|
|
114
|
+
wal_status: 'reserved',
|
|
115
|
+
safe_wal_size: 8 * GB,
|
|
116
|
+
max_slot_wal_keep_size: 10 * GB
|
|
117
|
+
});
|
|
118
|
+
const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
|
|
119
|
+
const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
|
|
120
|
+
expect(walWarnings).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('clamps negative safe_wal_size to 0%', async () => {
|
|
124
|
+
const api = makeRouteAPI({
|
|
125
|
+
wal_status: 'unreserved',
|
|
126
|
+
safe_wal_size: -2.4 * GB,
|
|
127
|
+
max_slot_wal_keep_size: 1 * 1024 * 1024 // 1MB
|
|
128
|
+
});
|
|
129
|
+
const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
|
|
130
|
+
const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
|
|
131
|
+
expect(walWarnings).toHaveLength(1);
|
|
132
|
+
expect(walWarnings[0].message).toContain('0%');
|
|
133
|
+
expect(walWarnings[0].message).not.toMatch(/-\d+%/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('no WAL budget error when slot status is lost', async () => {
|
|
137
|
+
const api = makeRouteAPI({
|
|
138
|
+
wal_status: 'lost'
|
|
139
|
+
});
|
|
140
|
+
const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
|
|
141
|
+
const walErrors = result!.errors.filter(
|
|
142
|
+
(e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
|
|
143
|
+
);
|
|
144
|
+
expect(walErrors).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('no WAL error when getSlotWalBudget is not defined', async () => {
|
|
148
|
+
const api = makeRouteAPI();
|
|
149
|
+
const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
|
|
150
|
+
const walErrors = result!.errors.filter(
|
|
151
|
+
(e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
|
|
152
|
+
);
|
|
153
|
+
expect(walErrors).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { errors } from '@powersync/lib-services-framework';
|
|
2
|
+
import Fastify, { FastifyInstance } from 'fastify';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import * as zlib from 'node:zlib';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { registerFastifyErrorHandler } from '../../../src/routes/route-register.js';
|
|
7
|
+
|
|
8
|
+
describe('Fastify error handler', () => {
|
|
9
|
+
let app: FastifyInstance;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
app = Fastify();
|
|
13
|
+
registerFastifyErrorHandler(app);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await app.close();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('errors thrown in route before sending a response', () => {
|
|
21
|
+
it('returns a JSON error body with no content-encoding when none was set', async () => {
|
|
22
|
+
app.get('/boom', async () => {
|
|
23
|
+
throw new errors.ServiceError({
|
|
24
|
+
status: 503,
|
|
25
|
+
code: 'PSYNC_S2003' as any,
|
|
26
|
+
description: 'Service unavailable'
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
await app.ready();
|
|
30
|
+
|
|
31
|
+
const response = await app.inject({ method: 'GET', url: '/boom' });
|
|
32
|
+
|
|
33
|
+
expect(response.statusCode).toBe(503);
|
|
34
|
+
expect(response.headers['content-type']).toMatch(/application\/json/);
|
|
35
|
+
expect(response.headers['content-encoding']).toBeUndefined();
|
|
36
|
+
expect(JSON.parse(response.payload)).toMatchObject({
|
|
37
|
+
error: { code: 'PSYNC_S2003', status: 503 }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('gzip-encodes the error body when content-encoding=gzip was set before the throw', async () => {
|
|
42
|
+
app.get('/boom', async (_request, reply) => {
|
|
43
|
+
reply.header('content-encoding', 'gzip');
|
|
44
|
+
throw new Error('kaboom');
|
|
45
|
+
});
|
|
46
|
+
await app.ready();
|
|
47
|
+
|
|
48
|
+
const response = await app.inject({ method: 'GET', url: '/boom' });
|
|
49
|
+
|
|
50
|
+
expect(response.statusCode).toBe(500);
|
|
51
|
+
expect(response.headers['content-encoding']).toBe('gzip');
|
|
52
|
+
const decoded = zlib.gunzipSync(response.rawPayload).toString('utf8');
|
|
53
|
+
expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('zstd-encodes the error body when content-encoding=zstd was set before the throw', async () => {
|
|
57
|
+
app.get('/boom', async (_request, reply) => {
|
|
58
|
+
reply.header('content-encoding', 'zstd');
|
|
59
|
+
throw new Error('kaboom');
|
|
60
|
+
});
|
|
61
|
+
await app.ready();
|
|
62
|
+
|
|
63
|
+
const response = await app.inject({ method: 'GET', url: '/boom' });
|
|
64
|
+
|
|
65
|
+
expect(response.statusCode).toBe(500);
|
|
66
|
+
expect(response.headers['content-encoding']).toBe('zstd');
|
|
67
|
+
const decoded = zlib.zstdDecompressSync(response.rawPayload).toString('utf8');
|
|
68
|
+
expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('errors after responding with a stream, before any data is sent', () => {
|
|
73
|
+
it('still returns a JSON error response with no encoding', async () => {
|
|
74
|
+
app.get('/stream', async (_request, reply) => {
|
|
75
|
+
const stream = new Readable({
|
|
76
|
+
read() {
|
|
77
|
+
process.nextTick(() => this.destroy(new Error('pre-data failure')));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
reply.header('content-type', 'application/x-ndjson');
|
|
81
|
+
return reply.send(stream);
|
|
82
|
+
});
|
|
83
|
+
await app.ready();
|
|
84
|
+
|
|
85
|
+
const response = await app.inject({ method: 'GET', url: '/stream' });
|
|
86
|
+
|
|
87
|
+
expect(response.statusCode).toBe(500);
|
|
88
|
+
expect(response.headers['content-encoding']).toBeUndefined();
|
|
89
|
+
expect(response.headers['content-type']).toMatch(/application\/json/);
|
|
90
|
+
expect(JSON.parse(response.payload).error.code).toBe('PSYNC_S2001');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('still returns a gzip-encoded JSON error response when encoding was set first', async () => {
|
|
94
|
+
app.get('/stream', async (_request, reply) => {
|
|
95
|
+
reply.header('content-encoding', 'gzip');
|
|
96
|
+
reply.header('content-type', 'application/x-ndjson');
|
|
97
|
+
const stream = new Readable({
|
|
98
|
+
read() {
|
|
99
|
+
process.nextTick(() => this.destroy(new Error('pre-data failure')));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return reply.send(stream);
|
|
103
|
+
});
|
|
104
|
+
await app.ready();
|
|
105
|
+
|
|
106
|
+
const response = await app.inject({ method: 'GET', url: '/stream' });
|
|
107
|
+
|
|
108
|
+
expect(response.statusCode).toBe(500);
|
|
109
|
+
expect(response.headers['content-encoding']).toBe('gzip');
|
|
110
|
+
const decoded = zlib.gunzipSync(response.rawPayload).toString('utf8');
|
|
111
|
+
expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('errors after a stream has sent data', () => {
|
|
116
|
+
it('lets fastify tear the response down without raising an uncaught exception', async () => {
|
|
117
|
+
app.get('/stream', async (_request, reply) => {
|
|
118
|
+
let chunks = 0;
|
|
119
|
+
const stream = new Readable({
|
|
120
|
+
read() {
|
|
121
|
+
if (chunks++ === 0) {
|
|
122
|
+
this.push('first chunk\n');
|
|
123
|
+
} else {
|
|
124
|
+
this.destroy(new Error('mid-stream failure'));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
reply.header('content-type', 'application/x-ndjson');
|
|
129
|
+
return reply.send(stream);
|
|
130
|
+
});
|
|
131
|
+
await app.ready();
|
|
132
|
+
|
|
133
|
+
const uncaught: Error[] = [];
|
|
134
|
+
const onUncaught = (err: Error) => uncaught.push(err);
|
|
135
|
+
const onUnhandled = (err: any) => uncaught.push(err);
|
|
136
|
+
process.on('uncaughtException', onUncaught);
|
|
137
|
+
process.on('unhandledRejection', onUnhandled);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await expect(app.inject({ method: 'GET', url: '/stream' })).rejects.toThrow(/destroyed/);
|
|
141
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
142
|
+
} finally {
|
|
143
|
+
process.off('uncaughtException', onUncaught);
|
|
144
|
+
process.off('unhandledRejection', onUnhandled);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
expect(uncaught).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('handler inheritance into child scopes', () => {
|
|
152
|
+
it('handles errors thrown in routes registered inside a child scope', async () => {
|
|
153
|
+
await app.register(async (child) => {
|
|
154
|
+
child.get('/boom', async () => {
|
|
155
|
+
throw new Error('child scope failure');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
await app.ready();
|
|
159
|
+
|
|
160
|
+
const response = await app.inject({ method: 'GET', url: '/boom' });
|
|
161
|
+
|
|
162
|
+
expect(response.statusCode).toBe(500);
|
|
163
|
+
expect(response.headers['content-type']).toMatch(/application\/json/);
|
|
164
|
+
const body = JSON.parse(response.payload);
|
|
165
|
+
expect(body.error?.code).toBe('PSYNC_S2001');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('built-in Fastify errors', () => {
|
|
170
|
+
it('preserves the 400 status code for an invalid JSON body', async () => {
|
|
171
|
+
app.post('/echo', async (request) => ({ received: request.body }));
|
|
172
|
+
await app.ready();
|
|
173
|
+
|
|
174
|
+
const response = await app.inject({
|
|
175
|
+
method: 'POST',
|
|
176
|
+
url: '/echo',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
payload: '{ not json'
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(response.statusCode).toBe(400);
|
|
182
|
+
const body = JSON.parse(response.payload);
|
|
183
|
+
expect(body.error.status).toBe(400);
|
|
184
|
+
expect(body.error.code).toBe('FST_ERR_CTP_INVALID_JSON_BODY');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('preserves the 400 status code for an empty JSON body', async () => {
|
|
188
|
+
app.post('/echo', async (request) => ({ received: request.body }));
|
|
189
|
+
await app.ready();
|
|
190
|
+
|
|
191
|
+
const response = await app.inject({
|
|
192
|
+
method: 'POST',
|
|
193
|
+
url: '/echo',
|
|
194
|
+
headers: { 'content-type': 'application/json' },
|
|
195
|
+
payload: ''
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(response.statusCode).toBe(400);
|
|
199
|
+
const body = JSON.parse(response.payload);
|
|
200
|
+
expect(body.error.status).toBe(400);
|
|
201
|
+
expect(body.error.code).toBe('FST_ERR_CTP_EMPTY_JSON_BODY');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('preserves the 400 status code for a schema validation failure', async () => {
|
|
205
|
+
app.post(
|
|
206
|
+
'/schema',
|
|
207
|
+
{
|
|
208
|
+
schema: {
|
|
209
|
+
body: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
required: ['name'],
|
|
212
|
+
properties: { name: { type: 'string' } }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async () => ({ ok: true })
|
|
217
|
+
);
|
|
218
|
+
await app.ready();
|
|
219
|
+
|
|
220
|
+
const response = await app.inject({
|
|
221
|
+
method: 'POST',
|
|
222
|
+
url: '/schema',
|
|
223
|
+
headers: { 'content-type': 'application/json' },
|
|
224
|
+
payload: JSON.stringify({})
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(response.statusCode).toBe(400);
|
|
228
|
+
const body = JSON.parse(response.payload);
|
|
229
|
+
expect(body.error.status).toBe(400);
|
|
230
|
+
expect(body.error.code).toBe('FST_ERR_VALIDATION');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('preserves the 413 status code when the body exceeds the limit', async () => {
|
|
234
|
+
const tinyApp = Fastify({ bodyLimit: 16 });
|
|
235
|
+
registerFastifyErrorHandler(tinyApp);
|
|
236
|
+
tinyApp.post('/echo', async (request) => ({ received: request.body }));
|
|
237
|
+
await tinyApp.ready();
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const response = await tinyApp.inject({
|
|
241
|
+
method: 'POST',
|
|
242
|
+
url: '/echo',
|
|
243
|
+
headers: { 'content-type': 'application/json' },
|
|
244
|
+
payload: JSON.stringify({ data: 'x'.repeat(64) })
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(response.statusCode).toBe(413);
|
|
248
|
+
const body = JSON.parse(response.payload);
|
|
249
|
+
expect(body.error.status).toBe(413);
|
|
250
|
+
expect(body.error.code).toBe('FST_ERR_CTP_BODY_TOO_LARGE');
|
|
251
|
+
} finally {
|
|
252
|
+
await tinyApp.close();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('preserves the 415 status code for an unsupported media type', async () => {
|
|
257
|
+
app.post('/json-only', async (request) => ({ received: request.body }));
|
|
258
|
+
// Drop the default text/plain parser so non-JSON content types are rejected.
|
|
259
|
+
app.removeContentTypeParser('text/plain');
|
|
260
|
+
await app.ready();
|
|
261
|
+
|
|
262
|
+
const response = await app.inject({
|
|
263
|
+
method: 'POST',
|
|
264
|
+
url: '/json-only',
|
|
265
|
+
headers: { 'content-type': 'text/plain' },
|
|
266
|
+
payload: 'hello'
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(response.statusCode).toBe(415);
|
|
270
|
+
const body = JSON.parse(response.payload);
|
|
271
|
+
expect(body.error.status).toBe(415);
|
|
272
|
+
expect(body.error.code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { BasicRouterRequest, Context, JwtPayload, SyncRulesBucketStorage } from '@/index.js';
|
|
2
|
-
import { RouterResponse, ServiceError
|
|
3
|
-
import {
|
|
2
|
+
import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_HYDRATION_STATE,
|
|
5
|
+
HydrateSyncConfigParams,
|
|
6
|
+
nodeSqlite,
|
|
7
|
+
SqlSyncRules
|
|
8
|
+
} from '@powersync/service-sync-rules';
|
|
9
|
+
import * as sqlite from 'node:sqlite';
|
|
4
10
|
import { Readable, Writable } from 'stream';
|
|
5
11
|
import { pipeline } from 'stream/promises';
|
|
6
12
|
import { describe, expect, it } from 'vitest';
|
|
@@ -10,6 +16,11 @@ import { DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS, limitParamsForLogging } from '../
|
|
|
10
16
|
import { mockServiceContext } from './mocks.js';
|
|
11
17
|
|
|
12
18
|
describe('Stream Route', () => {
|
|
19
|
+
const defaultHydrationOptions: HydrateSyncConfigParams = {
|
|
20
|
+
hydrationState: DEFAULT_HYDRATION_STATE,
|
|
21
|
+
sqlite: nodeSqlite(sqlite)
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
describe('compressed stream', () => {
|
|
14
25
|
it('handles missing sync rules', async () => {
|
|
15
26
|
const context: Context = {
|
|
@@ -45,7 +56,7 @@ describe('Stream Route', () => {
|
|
|
45
56
|
|
|
46
57
|
const storage = {
|
|
47
58
|
getParsedSyncRules() {
|
|
48
|
-
return new SqlSyncRules('bucket_definitions: {}').hydrate(
|
|
59
|
+
return new SqlSyncRules('bucket_definitions: {}').hydrate(defaultHydrationOptions);
|
|
49
60
|
},
|
|
50
61
|
watchCheckpointChanges: async function* (options) {
|
|
51
62
|
throw new Error('Simulated storage error');
|
|
@@ -83,7 +94,7 @@ describe('Stream Route', () => {
|
|
|
83
94
|
it('logs the application metadata', async () => {
|
|
84
95
|
const storage = {
|
|
85
96
|
getParsedSyncRules() {
|
|
86
|
-
return new SqlSyncRules('bucket_definitions: {}').hydrate(
|
|
97
|
+
return new SqlSyncRules('bucket_definitions: {}').hydrate(defaultHydrationOptions);
|
|
87
98
|
},
|
|
88
99
|
watchCheckpointChanges: async function* (options) {
|
|
89
100
|
throw new Error('Simulated storage error');
|