@powersync/service-core 1.21.0 → 1.23.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 +48 -0
- package/dist/api/RouteAPI.d.ts +2 -2
- package/dist/api/diagnostics.d.ts +12 -1
- package/dist/api/diagnostics.js +34 -22
- 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/compact-action.js +1 -1
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/modules/AbstractModule.d.ts +6 -2
- package/dist/modules/AbstractModule.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +2 -2
- package/dist/replication/AbstractReplicator.js +24 -23
- 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/replication/ReplicationModule.d.ts +3 -3
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +12 -12
- package/dist/routes/configure-fastify.js +3 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.d.ts +24 -24
- package/dist/routes/endpoints/admin.js +49 -35
- 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 +22 -13
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +1 -1
- 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 +8 -8
- package/dist/runner/teardown.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +29 -6
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +26 -27
- package/dist/storage/BucketStorageFactory.js +5 -19
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/ParsedSyncConfigSet.d.ts +23 -0
- package/dist/storage/ParsedSyncConfigSet.js +2 -0
- package/dist/storage/ParsedSyncConfigSet.js.map +1 -0
- package/dist/storage/PersistedReplicationStream.d.ts +32 -0
- package/dist/storage/PersistedReplicationStream.js +26 -0
- package/dist/storage/PersistedReplicationStream.js.map +1 -0
- package/dist/storage/PersistedSyncConfigContent.d.ts +60 -0
- package/dist/storage/PersistedSyncConfigContent.js +123 -0
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -0
- package/dist/storage/PersistedSyncConfigStatus.d.ts +13 -0
- package/dist/storage/PersistedSyncConfigStatus.js +2 -0
- package/dist/storage/PersistedSyncConfigStatus.js.map +1 -0
- package/dist/storage/SourceEntity.d.ts +8 -1
- package/dist/storage/SourceTable.d.ts +37 -10
- package/dist/storage/SourceTable.js +50 -14
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +80 -24
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +117 -0
- package/dist/storage/implementation/BucketDefinitionMapping.js +277 -0
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -0
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.d.ts +6 -0
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +31 -0
- package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -0
- package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.d.ts +17 -0
- package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js +62 -0
- package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js.map +1 -0
- package/dist/storage/implementation/ReplicationStreamStorageIds.d.ts +15 -0
- package/dist/storage/implementation/ReplicationStreamStorageIds.js +20 -0
- package/dist/storage/implementation/ReplicationStreamStorageIds.js.map +1 -0
- package/dist/storage/storage-index.d.ts +8 -1
- package/dist/storage/storage-index.js +8 -1
- package/dist/storage/storage-index.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 +5 -0
- 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/checkpointing.js +1 -1
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -1
- package/dist/util/lsn.d.ts +4 -0
- package/dist/util/lsn.js +10 -0
- package/dist/util/lsn.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 +6 -7
- package/src/api/RouteAPI.ts +2 -2
- package/src/api/diagnostics.ts +45 -28
- package/src/auth/RemoteJWKSCollector.ts +3 -1
- package/src/entry/commands/compact-action.ts +1 -1
- package/src/modules/AbstractModule.ts +6 -2
- package/src/replication/AbstractReplicator.ts +27 -26
- package/src/replication/RelationCache.ts +23 -4
- package/src/replication/ReplicationModule.ts +2 -3
- package/src/routes/configure-fastify.ts +8 -1
- package/src/routes/endpoints/admin.ts +75 -49
- package/src/routes/endpoints/checkpointing.ts +1 -1
- package/src/routes/endpoints/socket-route.ts +1 -1
- package/src/routes/endpoints/sync-rules.ts +23 -13
- package/src/routes/endpoints/sync-stream.ts +1 -1
- package/src/routes/route-register.ts +73 -4
- package/src/runner/teardown.ts +8 -8
- package/src/storage/BucketStorageBatch.ts +32 -7
- package/src/storage/BucketStorageFactory.ts +36 -40
- package/src/storage/ParsedSyncConfigSet.ts +25 -0
- package/src/storage/PersistedReplicationStream.ts +55 -0
- package/src/storage/PersistedSyncConfigContent.ts +188 -0
- package/src/storage/PersistedSyncConfigStatus.ts +14 -0
- package/src/storage/SourceEntity.ts +9 -1
- package/src/storage/SourceTable.ts +72 -21
- package/src/storage/SyncRulesBucketStorage.ts +87 -26
- package/src/storage/implementation/BucketDefinitionMapping.ts +436 -0
- package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +41 -0
- package/src/storage/implementation/IncrementalReprocessingSyncConfigLog.ts +109 -0
- package/src/storage/implementation/ReplicationStreamStorageIds.ts +21 -0
- package/src/storage/storage-index.ts +9 -1
- package/src/sync/BucketChecksumState.ts +5 -5
- package/src/sync/sync.ts +8 -3
- package/src/tracing/PerformanceTracer.ts +24 -1
- package/src/util/checkpointing.ts +1 -1
- package/src/util/config/types.ts +1 -1
- package/src/util/lsn.ts +9 -0
- 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 +64 -33
- package/test/src/module-loader.test.ts +33 -1
- package/test/src/routes/admin.test.ts +180 -18
- package/test/src/routes/error-handler.integration.test.ts +275 -0
- package/test/src/routes/mocks.ts +6 -2
- 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.json +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/PersistedSyncRulesContent.d.ts +0 -59
- package/dist/storage/PersistedSyncRulesContent.js +0 -100
- package/dist/storage/PersistedSyncRulesContent.js.map +0 -1
- package/src/storage/PersistedSyncRulesContent.ts +0 -174
|
@@ -1,27 +1,90 @@
|
|
|
1
|
-
import { BasicRouterRequest, Context, JwtPayload } from '@/index.js';
|
|
1
|
+
import { BasicRouterRequest, Context, JwtPayload, ParsedSyncConfigSet, storage } from '@/index.js';
|
|
2
2
|
import { logger } from '@powersync/lib-services-framework';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { SqlSyncRules } from '@powersync/service-sync-rules';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { diagnostics, reprocess, validate } from '../../../src/routes/endpoints/admin.js';
|
|
5
6
|
import { mockServiceContext } from './mocks.js';
|
|
6
7
|
|
|
7
8
|
describe('admin routes', () => {
|
|
9
|
+
const request: BasicRouterRequest = {
|
|
10
|
+
headers: {},
|
|
11
|
+
hostname: '',
|
|
12
|
+
protocol: 'http'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function makeContext(activeBucketStorage?: any): Context {
|
|
16
|
+
const service_context = mockServiceContext(null);
|
|
17
|
+
if (activeBucketStorage != null) {
|
|
18
|
+
(service_context.storageEngine as any).activeBucketStorage = activeBucketStorage;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
logger: logger,
|
|
23
|
+
service_context,
|
|
24
|
+
token_payload: new JwtPayload({
|
|
25
|
+
sub: '',
|
|
26
|
+
exp: 0,
|
|
27
|
+
iat: 0
|
|
28
|
+
})
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeSyncConfigContent(options: {
|
|
33
|
+
id?: number;
|
|
34
|
+
syncConfigId?: string;
|
|
35
|
+
active?: boolean;
|
|
36
|
+
content?: string;
|
|
37
|
+
}): storage.PersistedSyncConfigContent {
|
|
38
|
+
const id = options.id ?? 1;
|
|
39
|
+
const syncConfigId = options.syncConfigId ?? String(id);
|
|
40
|
+
const active = options.active ?? true;
|
|
41
|
+
const state = active ? storage.SyncRuleState.ACTIVE : storage.SyncRuleState.PROCESSING;
|
|
42
|
+
const lastKeepaliveTs = new Date('2026-01-01T00:00:00.000Z');
|
|
43
|
+
const lastCheckpointTs = new Date('2026-01-01T00:00:00.000Z');
|
|
44
|
+
const content = {
|
|
45
|
+
replicationStreamId: id,
|
|
46
|
+
syncConfigId,
|
|
47
|
+
replicationStreamName: `slot_${id}`,
|
|
48
|
+
sync_rules_content:
|
|
49
|
+
options.content ??
|
|
50
|
+
`
|
|
51
|
+
bucket_definitions:
|
|
52
|
+
global:
|
|
53
|
+
data:
|
|
54
|
+
- SELECT id FROM test
|
|
55
|
+
`,
|
|
56
|
+
compiled_plan: null,
|
|
57
|
+
storageVersion: storage.LEGACY_STORAGE_VERSION,
|
|
58
|
+
parsed(options?: any) {
|
|
59
|
+
const syncRules = SqlSyncRules.fromYaml(content.sync_rules_content, {
|
|
60
|
+
...options,
|
|
61
|
+
defaultSchema: 'public'
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
syncConfigs: [syncRules]
|
|
65
|
+
} as ParsedSyncConfigSet;
|
|
66
|
+
},
|
|
67
|
+
asUpdateOptions: vi.fn(),
|
|
68
|
+
getStorageConfig: vi.fn(),
|
|
69
|
+
async getSyncConfigStatus() {
|
|
70
|
+
return {
|
|
71
|
+
id: syncConfigId,
|
|
72
|
+
replicationStreamId: id,
|
|
73
|
+
state,
|
|
74
|
+
last_checkpoint_lsn: null,
|
|
75
|
+
last_fatal_error: null,
|
|
76
|
+
last_fatal_error_ts: null,
|
|
77
|
+
last_keepalive_ts: lastKeepaliveTs,
|
|
78
|
+
last_checkpoint_ts: lastCheckpointTs
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return content as unknown as storage.PersistedSyncConfigContent;
|
|
83
|
+
}
|
|
84
|
+
|
|
8
85
|
describe('validate', () => {
|
|
9
86
|
it('reports errors with source location', async () => {
|
|
10
|
-
const context
|
|
11
|
-
logger: logger,
|
|
12
|
-
service_context: mockServiceContext(null),
|
|
13
|
-
token_payload: new JwtPayload({
|
|
14
|
-
sub: '',
|
|
15
|
-
exp: 0,
|
|
16
|
-
iat: 0
|
|
17
|
-
})
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const request: BasicRouterRequest = {
|
|
21
|
-
headers: {},
|
|
22
|
-
hostname: '',
|
|
23
|
-
protocol: 'http'
|
|
24
|
-
};
|
|
87
|
+
const context = makeContext();
|
|
25
88
|
|
|
26
89
|
const response = await validate.handler({
|
|
27
90
|
context,
|
|
@@ -45,4 +108,103 @@ bucket_definitions:
|
|
|
45
108
|
]);
|
|
46
109
|
});
|
|
47
110
|
});
|
|
111
|
+
|
|
112
|
+
describe('diagnostics', () => {
|
|
113
|
+
it('returns deploying config status', async () => {
|
|
114
|
+
const active = makeSyncConfigContent({ id: 1, syncConfigId: 'active-config' });
|
|
115
|
+
const deploying = makeSyncConfigContent({ id: 2, syncConfigId: 'deploying-config', active: false });
|
|
116
|
+
const getInstance = vi.fn(() => ({
|
|
117
|
+
async getStatus() {
|
|
118
|
+
return {
|
|
119
|
+
snapshotDone: false,
|
|
120
|
+
resumeLsn: null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
const activeBucketStorage = {
|
|
125
|
+
getActiveSyncConfig: vi.fn(async () => ({
|
|
126
|
+
content: active,
|
|
127
|
+
replicationStream: {},
|
|
128
|
+
storage: getInstance()
|
|
129
|
+
})),
|
|
130
|
+
getDeployingSyncConfig: vi.fn(async () => ({
|
|
131
|
+
content: deploying,
|
|
132
|
+
replicationStream: {},
|
|
133
|
+
storage: getInstance()
|
|
134
|
+
}))
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const response = await diagnostics.handler({
|
|
138
|
+
context: makeContext(activeBucketStorage),
|
|
139
|
+
params: {},
|
|
140
|
+
request
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(response.deploying_sync_rules?.connections[0].slot_name).toBe('slot_2');
|
|
144
|
+
expect(response.active_sync_rules?.connections[0].slot_name).toBe('slot_1');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('reprocess', () => {
|
|
149
|
+
it('reprocesses the active sync config', async () => {
|
|
150
|
+
const active = makeSyncConfigContent({ id: 7, syncConfigId: 'active-config' });
|
|
151
|
+
const updateSyncRules = vi.fn(async () => ({
|
|
152
|
+
replicationStreamId: 8,
|
|
153
|
+
replicationStreamName: 'new_slot',
|
|
154
|
+
state: storage.SyncRuleState.PROCESSING,
|
|
155
|
+
storageVersion: storage.LEGACY_STORAGE_VERSION,
|
|
156
|
+
replicationJobId: '8',
|
|
157
|
+
current_lock: null
|
|
158
|
+
}));
|
|
159
|
+
const activeBucketStorage = {
|
|
160
|
+
getDeployingSyncConfig: vi.fn(async () => null),
|
|
161
|
+
getActiveSyncConfig: vi.fn(async () => ({
|
|
162
|
+
content: active,
|
|
163
|
+
replicationStream: {},
|
|
164
|
+
storage: {}
|
|
165
|
+
})),
|
|
166
|
+
getSyncConfigContent: vi.fn(),
|
|
167
|
+
updateSyncRules
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const response = await reprocess.handler({
|
|
171
|
+
context: makeContext(activeBucketStorage),
|
|
172
|
+
params: {},
|
|
173
|
+
request
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(activeBucketStorage.getActiveSyncConfig).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(activeBucketStorage.getSyncConfigContent).not.toHaveBeenCalled();
|
|
178
|
+
expect(updateSyncRules).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(response.connections[0].slot_name).toBe('new_slot');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('rejects reprocess while a sync config is deploying', async () => {
|
|
183
|
+
const activeBucketStorage = {
|
|
184
|
+
getDeployingSyncConfig: vi.fn(async () => ({
|
|
185
|
+
content: makeSyncConfigContent({ id: 2, active: false }),
|
|
186
|
+
replicationStream: {},
|
|
187
|
+
storage: {}
|
|
188
|
+
})),
|
|
189
|
+
getActiveSyncConfig: vi.fn(),
|
|
190
|
+
updateSyncRules: vi.fn()
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
reprocess.handler({
|
|
195
|
+
context: makeContext(activeBucketStorage),
|
|
196
|
+
params: {},
|
|
197
|
+
request
|
|
198
|
+
})
|
|
199
|
+
).rejects.toMatchObject({
|
|
200
|
+
errorData: {
|
|
201
|
+
status: 409,
|
|
202
|
+
code: 'PSYNC_S4106',
|
|
203
|
+
description: 'Busy processing sync config - cannot reprocess'
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
expect(activeBucketStorage.getActiveSyncConfig).not.toHaveBeenCalled();
|
|
207
|
+
expect(activeBucketStorage.updateSyncRules).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
48
210
|
});
|
|
@@ -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
|
+
});
|
package/test/src/routes/mocks.ts
CHANGED
|
@@ -18,8 +18,12 @@ export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | nu
|
|
|
18
18
|
|
|
19
19
|
const storageEngine: StorageEngine = {
|
|
20
20
|
activeBucketStorage: {
|
|
21
|
-
async
|
|
22
|
-
return storage
|
|
21
|
+
async getActiveSyncConfig() {
|
|
22
|
+
return storage == null
|
|
23
|
+
? null
|
|
24
|
+
: {
|
|
25
|
+
storage
|
|
26
|
+
};
|
|
23
27
|
}
|
|
24
28
|
} as Partial<BucketStorageFactory>
|
|
25
29
|
} as any;
|
|
@@ -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');
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import * as storage from '../../../src/storage/storage-index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a SourceTable with the current `ref`-based options shape.
|
|
6
|
+
* Data/parameter sources are not relevant to the storeCurrentData behaviour, so they default to empty.
|
|
7
|
+
*/
|
|
8
|
+
function makeTable(options: Partial<storage.SourceTableOptions> = {}): storage.SourceTable {
|
|
9
|
+
return new storage.SourceTable({
|
|
10
|
+
id: 'test-id',
|
|
11
|
+
ref: { connectionTag: 'test', schema: 'public', name: 'test_table' },
|
|
12
|
+
objectId: 123,
|
|
13
|
+
replicaIdColumns: [{ name: 'id' }],
|
|
14
|
+
snapshotComplete: true,
|
|
15
|
+
bucketDataSources: [],
|
|
16
|
+
parameterLookupSources: [],
|
|
17
|
+
...options
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SourceTable', () => {
|
|
22
|
+
describe('storeCurrentData property', () => {
|
|
23
|
+
test('defaults to true', () => {
|
|
24
|
+
const table = makeTable();
|
|
25
|
+
expect(table.storeCurrentData).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('can be set to false', () => {
|
|
29
|
+
const table = makeTable();
|
|
30
|
+
table.storeCurrentData = false;
|
|
31
|
+
expect(table.storeCurrentData).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('is preserved when cloning', () => {
|
|
35
|
+
const table = makeTable();
|
|
36
|
+
table.storeCurrentData = false;
|
|
37
|
+
const cloned = table.clone();
|
|
38
|
+
|
|
39
|
+
expect(cloned.storeCurrentData).toBe(false);
|
|
40
|
+
expect(cloned).not.toBe(table);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('clone preserves all properties including storeCurrentData', () => {
|
|
44
|
+
const table = makeTable({
|
|
45
|
+
replicaIdColumns: [{ name: 'id', type: 'int4' }],
|
|
46
|
+
snapshotComplete: false
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
table.syncData = false;
|
|
50
|
+
table.syncParameters = false;
|
|
51
|
+
table.syncEvent = false;
|
|
52
|
+
table.storeCurrentData = false;
|
|
53
|
+
table.snapshotStatus = {
|
|
54
|
+
totalEstimatedCount: 100,
|
|
55
|
+
replicatedCount: 50,
|
|
56
|
+
lastKey: Buffer.from('test')
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const cloned = table.clone();
|
|
60
|
+
|
|
61
|
+
expect(cloned.syncData).toBe(false);
|
|
62
|
+
expect(cloned.syncParameters).toBe(false);
|
|
63
|
+
expect(cloned.syncEvent).toBe(false);
|
|
64
|
+
expect(cloned.storeCurrentData).toBe(false);
|
|
65
|
+
expect(cloned.snapshotStatus).toEqual(table.snapshotStatus);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('integration with other properties', () => {
|
|
70
|
+
test('storeCurrentData does not affect syncAny', () => {
|
|
71
|
+
const table = makeTable();
|
|
72
|
+
|
|
73
|
+
table.storeCurrentData = false;
|
|
74
|
+
table.syncData = true;
|
|
75
|
+
table.syncParameters = false;
|
|
76
|
+
table.syncEvent = false;
|
|
77
|
+
|
|
78
|
+
expect(table.syncAny).toBe(true); // Should still be true
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('storeCurrentData is independent of snapshot status', () => {
|
|
82
|
+
const table = makeTable({ snapshotComplete: false });
|
|
83
|
+
|
|
84
|
+
table.storeCurrentData = false;
|
|
85
|
+
expect(table.snapshotComplete).toBe(false);
|
|
86
|
+
expect(table.storeCurrentData).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|