@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/api/RouteAPI.d.ts +3 -3
  3. package/dist/api/diagnostics.d.ts +1 -1
  4. package/dist/api/diagnostics.js +19 -3
  5. package/dist/api/diagnostics.js.map +1 -1
  6. package/dist/auth/RemoteJWKSCollector.js +3 -2
  7. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  8. package/dist/entry/commands/teardown-action.js +1 -1
  9. package/dist/entry/commands/teardown-action.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/modules/AbstractModule.d.ts +1 -1
  14. package/dist/replication/AbstractReplicationJob.js +1 -1
  15. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  16. package/dist/replication/AbstractReplicator.d.ts +6 -6
  17. package/dist/replication/AbstractReplicator.js +21 -21
  18. package/dist/replication/AbstractReplicator.js.map +1 -1
  19. package/dist/replication/RelationCache.d.ts +9 -2
  20. package/dist/replication/RelationCache.js +21 -2
  21. package/dist/replication/RelationCache.js.map +1 -1
  22. package/dist/routes/configure-fastify.js +3 -1
  23. package/dist/routes/configure-fastify.js.map +1 -1
  24. package/dist/routes/endpoints/admin.js +16 -8
  25. package/dist/routes/endpoints/admin.js.map +1 -1
  26. package/dist/routes/endpoints/checkpointing.js +1 -1
  27. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  28. package/dist/routes/endpoints/socket-route.js +1 -1
  29. package/dist/routes/endpoints/socket-route.js.map +1 -1
  30. package/dist/routes/endpoints/sync-rules.js +8 -8
  31. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  32. package/dist/routes/endpoints/sync-stream.js +2 -2
  33. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  34. package/dist/routes/route-register.d.ts +2 -0
  35. package/dist/routes/route-register.js +65 -3
  36. package/dist/routes/route-register.js.map +1 -1
  37. package/dist/runner/teardown.js +4 -4
  38. package/dist/runner/teardown.js.map +1 -1
  39. package/dist/storage/BucketStorage.d.ts +9 -9
  40. package/dist/storage/BucketStorage.js +9 -9
  41. package/dist/storage/BucketStorageBatch.d.ts +29 -0
  42. package/dist/storage/BucketStorageBatch.js.map +1 -1
  43. package/dist/storage/BucketStorageFactory.d.ts +27 -18
  44. package/dist/storage/BucketStorageFactory.js +13 -12
  45. package/dist/storage/BucketStorageFactory.js.map +1 -1
  46. package/dist/storage/PersistedSyncRulesContent.d.ts +6 -4
  47. package/dist/storage/PersistedSyncRulesContent.js +15 -8
  48. package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
  49. package/dist/storage/SourceEntity.d.ts +8 -1
  50. package/dist/storage/SourceTable.d.ts +32 -11
  51. package/dist/storage/SourceTable.js +41 -15
  52. package/dist/storage/SourceTable.js.map +1 -1
  53. package/dist/storage/StorageVersionConfig.d.ts +1 -1
  54. package/dist/storage/StorageVersionConfig.js +1 -1
  55. package/dist/storage/SyncRulesBucketStorage.d.ts +63 -18
  56. package/dist/storage/SyncRulesBucketStorage.js +14 -0
  57. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  58. package/dist/storage/WriteCheckpointAPI.d.ts +6 -6
  59. package/dist/storage/WriteCheckpointAPI.js +1 -1
  60. package/dist/storage/bson.d.ts +0 -1
  61. package/dist/storage/bson.js +0 -4
  62. package/dist/storage/bson.js.map +1 -1
  63. package/dist/sync/BucketChecksumState.d.ts +6 -9
  64. package/dist/sync/BucketChecksumState.js +117 -58
  65. package/dist/sync/BucketChecksumState.js.map +1 -1
  66. package/dist/sync/sync.d.ts +2 -2
  67. package/dist/sync/sync.js.map +1 -1
  68. package/dist/tracing/PerformanceTracer.d.ts +60 -0
  69. package/dist/tracing/PerformanceTracer.js +105 -0
  70. package/dist/tracing/PerformanceTracer.js.map +1 -0
  71. package/dist/tracing/TraceWriter.d.ts +22 -0
  72. package/dist/tracing/TraceWriter.js +63 -0
  73. package/dist/tracing/TraceWriter.js.map +1 -0
  74. package/dist/util/checkpointing.js +1 -1
  75. package/dist/util/config/compound-config-collector.d.ts +1 -1
  76. package/dist/util/config/compound-config-collector.js +2 -2
  77. package/dist/util/config/compound-config-collector.js.map +1 -1
  78. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -1
  79. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  80. package/dist/util/env.js +1 -1
  81. package/dist/util/protocol-types.d.ts +1 -1
  82. package/dist/util/protocol-types.js +1 -1
  83. package/dist/util/util-index.d.ts +1 -0
  84. package/dist/util/util-index.js +1 -0
  85. package/dist/util/util-index.js.map +1 -1
  86. package/dist/util/utils.d.ts +5 -0
  87. package/dist/util/utils.js +7 -0
  88. package/dist/util/utils.js.map +1 -1
  89. package/package.json +11 -11
  90. package/src/api/RouteAPI.ts +3 -3
  91. package/src/api/diagnostics.ts +29 -6
  92. package/src/auth/RemoteJWKSCollector.ts +3 -1
  93. package/src/entry/commands/teardown-action.ts +1 -1
  94. package/src/index.ts +2 -0
  95. package/src/modules/AbstractModule.ts +1 -1
  96. package/src/replication/AbstractReplicationJob.ts +1 -1
  97. package/src/replication/AbstractReplicator.ts +23 -23
  98. package/src/replication/RelationCache.ts +23 -4
  99. package/src/routes/configure-fastify.ts +8 -1
  100. package/src/routes/endpoints/admin.ts +17 -8
  101. package/src/routes/endpoints/checkpointing.ts +1 -1
  102. package/src/routes/endpoints/socket-route.ts +1 -1
  103. package/src/routes/endpoints/sync-rules.ts +8 -8
  104. package/src/routes/endpoints/sync-stream.ts +2 -2
  105. package/src/routes/route-register.ts +73 -4
  106. package/src/runner/teardown.ts +4 -4
  107. package/src/storage/BucketStorage.ts +9 -9
  108. package/src/storage/BucketStorageBatch.ts +32 -0
  109. package/src/storage/BucketStorageFactory.ts +35 -23
  110. package/src/storage/PersistedSyncRulesContent.ts +20 -12
  111. package/src/storage/SourceEntity.ts +9 -1
  112. package/src/storage/SourceTable.ts +56 -22
  113. package/src/storage/StorageVersionConfig.ts +1 -1
  114. package/src/storage/SyncRulesBucketStorage.ts +74 -22
  115. package/src/storage/WriteCheckpointAPI.ts +6 -6
  116. package/src/storage/bson.ts +0 -5
  117. package/src/sync/BucketChecksumState.ts +142 -78
  118. package/src/sync/sync.ts +4 -4
  119. package/src/tracing/PerformanceTracer.ts +149 -0
  120. package/src/tracing/TraceWriter.ts +67 -0
  121. package/src/util/checkpointing.ts +1 -1
  122. package/src/util/config/compound-config-collector.ts +3 -3
  123. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -1
  124. package/src/util/env.ts +1 -1
  125. package/src/util/protocol-types.ts +1 -1
  126. package/src/util/util-index.ts +1 -0
  127. package/src/util/utils.ts +8 -0
  128. package/test/src/auth.test.ts +120 -1
  129. package/test/src/diagnostics.test.ts +155 -0
  130. package/test/src/routes/error-handler.integration.test.ts +275 -0
  131. package/test/src/routes/stream.test.ts +15 -4
  132. package/test/src/storage/SourceTable.test.ts +89 -0
  133. package/test/src/sync/BucketChecksumState.test.ts +244 -80
  134. package/test/tsconfig.json +0 -1
  135. 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 rules YAML
16
+ * Base64 encoded contents of sync config YAML
17
17
  */
18
18
  POWERSYNC_SYNC_RULES_B64: utils.type.string.optional(),
19
19
  /**
@@ -77,7 +77,7 @@ export const StreamingSyncRequest = t.object({
77
77
  raw_data: t.boolean.optional(),
78
78
 
79
79
  /**
80
- * Client parameters to be passed to the sync rules.
80
+ * Client parameters to be passed to the sync config.
81
81
  */
82
82
  parameters: t.record(t.any).optional(),
83
83
 
@@ -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}`);
@@ -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 { KeySpec } from '../../src/auth/KeySpec.js';
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, logger } from '@powersync/lib-services-framework';
3
- import { DEFAULT_HYDRATION_STATE, SqlSyncRules } from '@powersync/service-sync-rules';
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({ hydrationState: DEFAULT_HYDRATION_STATE });
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({ hydrationState: DEFAULT_HYDRATION_STATE });
97
+ return new SqlSyncRules('bucket_definitions: {}').hydrate(defaultHydrationOptions);
87
98
  },
88
99
  watchCheckpointChanges: async function* (options) {
89
100
  throw new Error('Simulated storage error');