@powersync/service-core 1.21.0 → 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 (71) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api/diagnostics.js +1 -1
  3. package/dist/api/diagnostics.js.map +1 -1
  4. package/dist/auth/RemoteJWKSCollector.js +3 -2
  5. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  6. package/dist/replication/RelationCache.d.ts +9 -2
  7. package/dist/replication/RelationCache.js +21 -2
  8. package/dist/replication/RelationCache.js.map +1 -1
  9. package/dist/routes/configure-fastify.js +3 -1
  10. package/dist/routes/configure-fastify.js.map +1 -1
  11. package/dist/routes/endpoints/admin.js +9 -5
  12. package/dist/routes/endpoints/admin.js.map +1 -1
  13. package/dist/routes/endpoints/sync-rules.js +1 -1
  14. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  15. package/dist/routes/route-register.d.ts +2 -0
  16. package/dist/routes/route-register.js +65 -3
  17. package/dist/routes/route-register.js.map +1 -1
  18. package/dist/storage/BucketStorageBatch.d.ts +29 -0
  19. package/dist/storage/BucketStorageBatch.js.map +1 -1
  20. package/dist/storage/BucketStorageFactory.d.ts +4 -0
  21. package/dist/storage/BucketStorageFactory.js +1 -1
  22. package/dist/storage/BucketStorageFactory.js.map +1 -1
  23. package/dist/storage/PersistedSyncRulesContent.d.ts +3 -3
  24. package/dist/storage/PersistedSyncRulesContent.js +6 -6
  25. package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
  26. package/dist/storage/SourceEntity.d.ts +8 -1
  27. package/dist/storage/SourceTable.d.ts +29 -8
  28. package/dist/storage/SourceTable.js +38 -12
  29. package/dist/storage/SourceTable.js.map +1 -1
  30. package/dist/storage/SyncRulesBucketStorage.d.ts +26 -13
  31. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  32. package/dist/sync/BucketChecksumState.d.ts +4 -4
  33. package/dist/sync/BucketChecksumState.js +1 -1
  34. package/dist/sync/BucketChecksumState.js.map +1 -1
  35. package/dist/sync/sync.d.ts +2 -2
  36. package/dist/sync/sync.js.map +1 -1
  37. package/dist/tracing/PerformanceTracer.d.ts +17 -1
  38. package/dist/tracing/PerformanceTracer.js +3 -0
  39. package/dist/tracing/PerformanceTracer.js.map +1 -1
  40. package/dist/util/util-index.d.ts +1 -0
  41. package/dist/util/util-index.js +1 -0
  42. package/dist/util/util-index.js.map +1 -1
  43. package/dist/util/utils.d.ts +5 -0
  44. package/dist/util/utils.js +7 -0
  45. package/dist/util/utils.js.map +1 -1
  46. package/package.json +4 -4
  47. package/src/api/diagnostics.ts +3 -3
  48. package/src/auth/RemoteJWKSCollector.ts +3 -1
  49. package/src/replication/RelationCache.ts +23 -4
  50. package/src/routes/configure-fastify.ts +8 -1
  51. package/src/routes/endpoints/admin.ts +10 -5
  52. package/src/routes/endpoints/sync-rules.ts +1 -1
  53. package/src/routes/route-register.ts +73 -4
  54. package/src/storage/BucketStorageBatch.ts +32 -0
  55. package/src/storage/BucketStorageFactory.ts +6 -1
  56. package/src/storage/PersistedSyncRulesContent.ts +9 -9
  57. package/src/storage/SourceEntity.ts +9 -1
  58. package/src/storage/SourceTable.ts +53 -19
  59. package/src/storage/SyncRulesBucketStorage.ts +28 -15
  60. package/src/sync/BucketChecksumState.ts +5 -5
  61. package/src/sync/sync.ts +3 -3
  62. package/src/tracing/PerformanceTracer.ts +24 -1
  63. package/src/util/util-index.ts +1 -0
  64. package/src/util/utils.ts +8 -0
  65. package/test/src/auth.test.ts +11 -0
  66. package/test/src/diagnostics.test.ts +10 -6
  67. package/test/src/routes/error-handler.integration.test.ts +275 -0
  68. package/test/src/routes/stream.test.ts +15 -4
  69. package/test/src/storage/SourceTable.test.ts +89 -0
  70. package/test/src/sync/BucketChecksumState.test.ts +25 -17
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -1,10 +1,11 @@
1
1
  import { Logger, ObserverClient } from '@powersync/lib-services-framework';
2
2
  import {
3
3
  BucketDataSource,
4
- HydratedSyncRules,
4
+ HydratedSyncConfig,
5
5
  ParameterLookupRows,
6
6
  ScopedParameterLookup
7
7
  } from '@powersync/service-sync-rules';
8
+ import * as bson from 'bson';
8
9
  import { PerformanceTracer } from '../tracing/PerformanceTracer.js';
9
10
  import * as util from '../util/util-index.js';
10
11
  import { BucketStorageBatch, FlushedResult, SaveUpdate } from './BucketStorageBatch.js';
@@ -12,6 +13,7 @@ import { BucketStorageFactory } from './BucketStorageFactory.js';
12
13
  import { ParseSyncRulesOptions } from './PersistedSyncRulesContent.js';
13
14
  import { SourceEntityDescriptor } from './SourceEntity.js';
14
15
  import { SourceTable } from './SourceTable.js';
16
+ import { StorageVersionConfig } from './StorageVersionConfig.js';
15
17
  import { SyncStorageWriteCheckpointAPI } from './WriteCheckpointAPI.js';
16
18
 
17
19
  /**
@@ -22,15 +24,11 @@ export interface SyncRulesBucketStorage
22
24
  SyncStorageWriteCheckpointAPI {
23
25
  readonly group_id: number;
24
26
  readonly slot_name: string;
27
+ readonly storageConfig: StorageVersionConfig;
25
28
 
26
29
  readonly factory: BucketStorageFactory;
27
30
  readonly logger: Logger;
28
31
 
29
- /**
30
- * Resolve a table, keeping track of it internally.
31
- */
32
- resolveTable(options: ResolveTableOptions): Promise<ResolveTableResult>;
33
-
34
32
  /**
35
33
  * Create a new writer.
36
34
  *
@@ -46,7 +44,7 @@ export interface SyncRulesBucketStorage
46
44
  callback: (batch: BucketStorageBatch) => Promise<void>
47
45
  ): Promise<FlushedResult | null>;
48
46
 
49
- getParsedSyncRules(options: ParseSyncRulesOptions): HydratedSyncRules;
47
+ getParsedSyncRules(options: ParseSyncRulesOptions): HydratedSyncConfig;
50
48
 
51
49
  /**
52
50
  * Terminate the replication stream.
@@ -156,18 +154,26 @@ export interface SyncRuleStatus {
156
154
  active: boolean;
157
155
  snapshot_done: boolean;
158
156
  snapshot_lsn: string | null;
157
+ /**
158
+ * Last persisted operation that must be included in the next checkpoint once checkpointing is unblocked.
159
+ */
160
+ keepalive_op: util.InternalOpId | null;
159
161
  }
160
- export interface ResolveTableOptions {
161
- group_id: number;
162
+ export interface ResolveTablesOptions {
162
163
  connection_id: number;
163
- connection_tag: string;
164
- entity_descriptor: SourceEntityDescriptor;
165
-
166
- sync_rules: HydratedSyncRules;
164
+ source: SourceEntityDescriptor;
165
+ /**
166
+ * For tests only - custom id generator for stable ids.
167
+ */
168
+ idGenerator?: () => string | bson.ObjectId;
169
+ /**
170
+ * For tests only - override the sync rules used.
171
+ */
172
+ syncRules?: HydratedSyncConfig;
167
173
  }
168
174
 
169
- export interface ResolveTableResult {
170
- table: SourceTable;
175
+ export interface ResolveTablesResult {
176
+ tables: SourceTable[];
171
177
  dropTables: SourceTable[];
172
178
  }
173
179
 
@@ -198,11 +204,18 @@ export interface CreateWriterOptions extends ParseSyncRulesOptions {
198
204
  */
199
205
  markRecordUnavailable?: BucketStorageMarkRecordUnavailable;
200
206
 
207
+ hooks?: StorageHooks;
208
+
201
209
  tracer?: PerformanceTracer<'storage' | 'evaluate'>;
202
210
 
203
211
  logger?: Logger;
204
212
  }
205
213
 
214
+ export interface StorageHooks {
215
+ beforeBatchFlush?: (batch: BucketStorageBatch) => Promise<void>;
216
+ afterBatchFlush?: (batch: BucketStorageBatch) => Promise<void>;
217
+ }
218
+
206
219
  /**
207
220
  * @deprecated Use `CreateWriterOptions`.
208
221
  */
@@ -2,7 +2,7 @@ import {
2
2
  BucketParameterQuerier,
3
3
  BucketPriority,
4
4
  BucketSource,
5
- HydratedSyncRules,
5
+ HydratedSyncConfig,
6
6
  mergeBuckets,
7
7
  QuerierError,
8
8
  RequestedStream,
@@ -28,7 +28,7 @@ import { getIntersection, hasIntersection } from './util.js';
28
28
  export interface BucketChecksumStateOptions {
29
29
  syncContext: SyncContext;
30
30
  bucketStorage: BucketChecksumStateStorage;
31
- syncRules: HydratedSyncRules;
31
+ syncRules: HydratedSyncConfig;
32
32
  tokenPayload: JwtPayload;
33
33
  syncRequest: util.StreamingSyncRequest;
34
34
  logger?: Logger;
@@ -285,7 +285,7 @@ export class BucketChecksumState {
285
285
  const streamNameToIndex = new Map<string, number>();
286
286
  this.streamNameToIndex = streamNameToIndex;
287
287
 
288
- for (const source of this.parameterState.syncRules.definition.bucketSources) {
288
+ for (const source of this.parameterState.syncRules.bucketSourceDefinitions) {
289
289
  if (this.parameterState.isSubscribedToStream(source)) {
290
290
  streamNameToIndex.set(source.name, subscriptions.length);
291
291
 
@@ -416,7 +416,7 @@ export interface CheckpointUpdate {
416
416
  export class BucketParameterState {
417
417
  private readonly context: SyncContext;
418
418
  public readonly bucketStorage: BucketChecksumStateStorage;
419
- public readonly syncRules: HydratedSyncRules;
419
+ public readonly syncRules: HydratedSyncConfig;
420
420
  public readonly syncParams: RequestParameters;
421
421
  private readonly querier: BucketParameterQuerier;
422
422
  /**
@@ -439,7 +439,7 @@ export class BucketParameterState {
439
439
  constructor(
440
440
  context: SyncContext,
441
441
  bucketStorage: BucketChecksumStateStorage,
442
- syncRules: HydratedSyncRules,
442
+ syncRules: HydratedSyncConfig,
443
443
  tokenPayload: JwtPayload,
444
444
  request: util.StreamingSyncRequest,
445
445
  logger: Logger
package/src/sync/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
2
- import { BucketPriority, HydratedSyncRules, ResolvedBucket, SqliteJsonValue } from '@powersync/service-sync-rules';
2
+ import { BucketPriority, HydratedSyncConfig, ResolvedBucket, SqliteJsonValue } from '@powersync/service-sync-rules';
3
3
 
4
4
  import { AbortError } from 'ix/aborterror.js';
5
5
 
@@ -17,7 +17,7 @@ import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStr
17
17
  export interface SyncStreamParameters {
18
18
  syncContext: SyncContext;
19
19
  bucketStorage: storage.SyncRulesBucketStorage;
20
- syncRules: HydratedSyncRules;
20
+ syncRules: HydratedSyncConfig;
21
21
  params: util.StreamingSyncRequest;
22
22
  token: auth.JwtPayload;
23
23
  logger?: Logger;
@@ -94,7 +94,7 @@ export async function* streamResponse(
94
94
  async function* streamResponseInner(
95
95
  syncContext: SyncContext,
96
96
  bucketStorage: storage.SyncRulesBucketStorage,
97
- syncRules: HydratedSyncRules,
97
+ syncRules: HydratedSyncConfig,
98
98
  params: util.StreamingSyncRequest,
99
99
  tokenPayload: auth.JwtPayload,
100
100
  tracker: RequestTracker,
@@ -2,19 +2,38 @@ import { traceWriter } from './TraceWriter.js';
2
2
 
3
3
  export interface Span extends Disposable {
4
4
  name: string;
5
+ /**
6
+ * Start time in microseconds since an arbitrary epoch.
7
+ */
5
8
  startAt: number;
9
+ /**
10
+ * End time in microseconds since an arbitrary epoch.
11
+ */
6
12
  endAt: number;
13
+ /**
14
+ * Time spent not in nested spans.
15
+ */
7
16
  selfDuration: number;
17
+
8
18
  nestedSince: number | undefined;
9
19
  subtrackFromSelf: number;
20
+
21
+ /**
22
+ * Durations spent in nested spans, in microseconds.
23
+ */
10
24
  nestedDurations: Record<string, number>;
11
25
 
26
+ /**
27
+ * Total duration of this span in milliseconds, rounded up. Only valid after the span has ended.
28
+ */
29
+ durationMillis: number;
30
+
12
31
  /**
13
32
  * End the span - same as [Symbol.dispose]().
14
33
  *
15
34
  * Safe to call multiple times. Any nested spans will automatically end as well.
16
35
  *
17
- * Returns an aggregate record of category -> "selfDuration".
36
+ * Returns an aggregate record of category -> "selfDuration", in microseconds.
18
37
  */
19
38
  end(): Record<string, number>;
20
39
  }
@@ -115,6 +134,10 @@ export class PerformanceTracer<K extends string> {
115
134
  }
116
135
  return this.nestedDurations;
117
136
  },
137
+
138
+ get durationMillis() {
139
+ return Math.ceil((this.endAt - this.startAt) / 1000);
140
+ },
118
141
  [Symbol.dispose]() {
119
142
  this.end();
120
143
  }
@@ -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}`);
@@ -484,6 +484,17 @@ describe('JWT Auth', () => {
484
484
  }
485
485
  })
486
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');
487
498
  });
488
499
 
489
500
  test('http not blocking local IPs', async () => {
@@ -1,6 +1,6 @@
1
1
  import { DiagnosticsOptions, getSyncRulesStatus } from '@/api/diagnostics.js';
2
2
  import { RouteAPI, SlotWalBudgetInfo } from '@/api/RouteAPI.js';
3
- import { BucketStorageFactory } from '@/index.js';
3
+ import { BucketStorageFactory, PersistedSyncRules, storage } from '@/index.js';
4
4
  import { SqlSyncRules } from '@powersync/service-sync-rules';
5
5
  import { describe, expect, test } from 'vitest';
6
6
 
@@ -13,7 +13,8 @@ bucket_definitions:
13
13
  - SELECT id FROM test_table
14
14
  `;
15
15
 
16
- function makeSyncRulesContent(overrides?: { slot_name?: string }) {
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.
17
18
  return {
18
19
  id: 1,
19
20
  slot_name: overrides?.slot_name ?? 'test_slot',
@@ -32,14 +33,17 @@ function makeSyncRulesContent(overrides?: { slot_name?: string }) {
32
33
  defaultSchema: 'public'
33
34
  });
34
35
  return {
35
- sync_rules: syncRules
36
- };
36
+ syncConfigWithErrors: syncRules
37
+ } as PersistedSyncRules;
37
38
  },
38
39
  lock() {
39
40
  throw new Error('Not implemented in mock');
40
41
  },
41
- current_lock: undefined
42
- } as any;
42
+ current_lock: null,
43
+ logger: null as any,
44
+ asUpdateOptions: null as any,
45
+ getStorageConfig: null as any
46
+ } as storage.PersistedSyncRulesContent;
43
47
  }
44
48
 
45
49
  function makeBucketStorage() {
@@ -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');