@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.
- package/CHANGELOG.md +31 -0
- package/dist/api/diagnostics.js +1 -1
- 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/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 +9 -5
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +1 -1
- package/dist/routes/endpoints/sync-rules.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/storage/BucketStorageBatch.d.ts +29 -0
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +4 -0
- package/dist/storage/BucketStorageFactory.js +1 -1
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncRulesContent.d.ts +3 -3
- package/dist/storage/PersistedSyncRulesContent.js +6 -6
- package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
- package/dist/storage/SourceEntity.d.ts +8 -1
- package/dist/storage/SourceTable.d.ts +29 -8
- package/dist/storage/SourceTable.js +38 -12
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +26 -13
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +4 -4
- package/dist/sync/BucketChecksumState.js +1 -1
- 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 +17 -1
- package/dist/tracing/PerformanceTracer.js +3 -0
- package/dist/tracing/PerformanceTracer.js.map +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 +4 -4
- package/src/api/diagnostics.ts +3 -3
- package/src/auth/RemoteJWKSCollector.ts +3 -1
- package/src/replication/RelationCache.ts +23 -4
- package/src/routes/configure-fastify.ts +8 -1
- package/src/routes/endpoints/admin.ts +10 -5
- package/src/routes/endpoints/sync-rules.ts +1 -1
- package/src/routes/route-register.ts +73 -4
- package/src/storage/BucketStorageBatch.ts +32 -0
- package/src/storage/BucketStorageFactory.ts +6 -1
- package/src/storage/PersistedSyncRulesContent.ts +9 -9
- package/src/storage/SourceEntity.ts +9 -1
- package/src/storage/SourceTable.ts +53 -19
- package/src/storage/SyncRulesBucketStorage.ts +28 -15
- package/src/sync/BucketChecksumState.ts +5 -5
- package/src/sync/sync.ts +3 -3
- package/src/tracing/PerformanceTracer.ts +24 -1
- package/src/util/util-index.ts +1 -0
- package/src/util/utils.ts +8 -0
- package/test/src/auth.test.ts +11 -0
- package/test/src/diagnostics.test.ts +10 -6
- 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 +25 -17
- 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
|
-
|
|
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):
|
|
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
|
|
161
|
-
group_id: number;
|
|
162
|
+
export interface ResolveTablesOptions {
|
|
162
163
|
connection_id: number;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
}
|
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
|
@@ -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
|
-
|
|
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:
|
|
42
|
-
|
|
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
|
|
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');
|