@powersync/service-core 0.0.0-dev-20260203155513 → 0.0.0-dev-20260223080959
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 +46 -8
- package/dist/api/RouteAPI.d.ts +2 -2
- package/dist/api/diagnostics.js +14 -6
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/auth/JwtPayload.d.ts +7 -8
- package/dist/auth/JwtPayload.js +19 -1
- package/dist/auth/JwtPayload.js.map +1 -1
- package/dist/auth/KeyStore.js +2 -1
- package/dist/auth/KeyStore.js.map +1 -1
- package/dist/replication/AbstractReplicator.js +2 -5
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/auth.d.ts +0 -1
- package/dist/routes/auth.js +2 -4
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +84 -0
- package/dist/routes/configure-fastify.js +0 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.d.ts +171 -0
- package/dist/routes/endpoints/admin.js +36 -21
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.js +3 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +4 -10
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +10 -13
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +3 -8
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/router.d.ts +0 -1
- package/dist/routes/router.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +29 -15
- package/dist/storage/BucketStorageFactory.js +58 -1
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncRulesContent.d.ts +28 -4
- package/dist/storage/PersistedSyncRulesContent.js +56 -1
- package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
- package/dist/storage/ReportStorage.d.ts +1 -8
- package/dist/storage/StorageVersionConfig.d.ts +20 -0
- package/dist/storage/StorageVersionConfig.js +20 -0
- package/dist/storage/StorageVersionConfig.js.map +1 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +8 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/storage-index.d.ts +1 -0
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +4 -6
- package/dist/sync/BucketChecksumState.js +4 -9
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.d.ts +0 -8
- package/dist/sync/sync.js +9 -19
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +0 -22
- package/dist/sync/util.js +0 -24
- package/dist/sync/util.js.map +1 -1
- package/dist/util/config.js +4 -1
- package/dist/util/config.js.map +1 -1
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +2 -2
- package/src/api/diagnostics.ts +16 -7
- package/src/auth/JwtPayload.ts +16 -8
- package/src/auth/KeyStore.ts +1 -1
- package/src/replication/AbstractReplicator.ts +3 -5
- package/src/routes/auth.ts +2 -4
- package/src/routes/configure-fastify.ts +0 -1
- package/src/routes/endpoints/admin.ts +45 -26
- package/src/routes/endpoints/checkpointing.ts +5 -3
- package/src/routes/endpoints/socket-route.ts +4 -11
- package/src/routes/endpoints/sync-rules.ts +18 -17
- package/src/routes/endpoints/sync-stream.ts +4 -8
- package/src/routes/router.ts +0 -2
- package/src/storage/BucketStorageFactory.ts +67 -19
- package/src/storage/PersistedSyncRulesContent.ts +82 -5
- package/src/storage/ReportStorage.ts +3 -9
- package/src/storage/StorageVersionConfig.ts +30 -0
- package/src/storage/SyncRulesBucketStorage.ts +9 -0
- package/src/storage/storage-index.ts +1 -0
- package/src/sync/BucketChecksumState.ts +10 -13
- package/src/sync/sync.ts +15 -42
- package/src/sync/util.ts +0 -25
- package/src/util/config.ts +7 -2
- package/test/src/auth.test.ts +76 -20
- package/test/src/routes/admin.test.ts +48 -0
- package/test/src/routes/mocks.ts +22 -1
- package/test/src/routes/stream.test.ts +10 -9
- package/test/src/sync/BucketChecksumState.test.ts +92 -84
- package/test/tsconfig.json +3 -6
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BucketDescription,
|
|
3
|
+
BucketParameterQuerier,
|
|
3
4
|
BucketPriority,
|
|
4
5
|
BucketSource,
|
|
5
6
|
HydratedSyncRules,
|
|
7
|
+
QuerierError,
|
|
6
8
|
RequestedStream,
|
|
7
|
-
RequestJwtPayload,
|
|
8
9
|
RequestParameters,
|
|
9
10
|
ResolvedBucket
|
|
10
11
|
} from '@powersync/service-sync-rules';
|
|
@@ -13,13 +14,13 @@ import * as storage from '../storage/storage-index.js';
|
|
|
13
14
|
import * as util from '../util/util-index.js';
|
|
14
15
|
|
|
15
16
|
import {
|
|
16
|
-
ErrorCode,
|
|
17
17
|
logger as defaultLogger,
|
|
18
|
+
ErrorCode,
|
|
18
19
|
Logger,
|
|
19
20
|
ServiceAssertionError,
|
|
20
21
|
ServiceError
|
|
21
22
|
} from '@powersync/lib-services-framework';
|
|
22
|
-
import {
|
|
23
|
+
import { JwtPayload } from '../auth/JwtPayload.js';
|
|
23
24
|
import { SyncContext } from './SyncContext.js';
|
|
24
25
|
import { getIntersection, hasIntersection } from './util.js';
|
|
25
26
|
|
|
@@ -27,7 +28,7 @@ export interface BucketChecksumStateOptions {
|
|
|
27
28
|
syncContext: SyncContext;
|
|
28
29
|
bucketStorage: BucketChecksumStateStorage;
|
|
29
30
|
syncRules: HydratedSyncRules;
|
|
30
|
-
tokenPayload:
|
|
31
|
+
tokenPayload: JwtPayload;
|
|
31
32
|
syncRequest: util.StreamingSyncRequest;
|
|
32
33
|
logger?: Logger;
|
|
33
34
|
}
|
|
@@ -79,7 +80,6 @@ export class BucketChecksumState {
|
|
|
79
80
|
private pendingBucketDownloads = new Set<string>();
|
|
80
81
|
|
|
81
82
|
private readonly logger: Logger;
|
|
82
|
-
private resync = false;
|
|
83
83
|
|
|
84
84
|
constructor(options: BucketChecksumStateOptions) {
|
|
85
85
|
this.context = options.syncContext;
|
|
@@ -113,13 +113,12 @@ export class BucketChecksumState {
|
|
|
113
113
|
*/
|
|
114
114
|
async buildNextCheckpointLine(next: storage.StorageCheckpointUpdate): Promise<CheckpointLine | null> {
|
|
115
115
|
const { writeCheckpoint, base } = next;
|
|
116
|
-
const
|
|
116
|
+
const userIdForLogs = this.parameterState.syncParams.userId;
|
|
117
117
|
|
|
118
118
|
const storage = this.bucketStorage;
|
|
119
119
|
|
|
120
120
|
const update = await this.parameterState.getCheckpointUpdate(next);
|
|
121
121
|
const { buckets: allBuckets, updatedBuckets } = update;
|
|
122
|
-
this.resync = updatedBuckets === INVALIDATE_ALL_BUCKETS;
|
|
123
122
|
|
|
124
123
|
/** Set of all buckets in this checkpoint. */
|
|
125
124
|
const bucketDescriptionMap = new Map(allBuckets.map((b) => [b.bucket, b]));
|
|
@@ -164,7 +163,6 @@ export class BucketChecksumState {
|
|
|
164
163
|
checksumMap = newChecksums;
|
|
165
164
|
} else {
|
|
166
165
|
// Re-check all buckets
|
|
167
|
-
this.resync = true;
|
|
168
166
|
const bucketList = [...bucketDescriptionMap.keys()];
|
|
169
167
|
checksumMap = await storage.getChecksums(base.checkpoint, bucketList);
|
|
170
168
|
}
|
|
@@ -189,7 +187,6 @@ export class BucketChecksumState {
|
|
|
189
187
|
diff.updatedBuckets.length == 0
|
|
190
188
|
) {
|
|
191
189
|
// No changes - don't send anything to the client
|
|
192
|
-
this.resync = false;
|
|
193
190
|
return null;
|
|
194
191
|
}
|
|
195
192
|
|
|
@@ -224,7 +221,7 @@ export class BucketChecksumState {
|
|
|
224
221
|
message += `removed: ${limitedBuckets(diff.removedBuckets, 20)}`;
|
|
225
222
|
this.logger.info(message, {
|
|
226
223
|
checkpoint: base.checkpoint,
|
|
227
|
-
user_id:
|
|
224
|
+
user_id: userIdForLogs,
|
|
228
225
|
buckets: allBuckets.length,
|
|
229
226
|
updated: diff.updatedBuckets.length,
|
|
230
227
|
removed: diff.removedBuckets.length
|
|
@@ -243,7 +240,7 @@ export class BucketChecksumState {
|
|
|
243
240
|
deferredLog = () => {
|
|
244
241
|
let message = `New checkpoint: ${base.checkpoint} | write: ${writeCheckpoint} | `;
|
|
245
242
|
message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
|
|
246
|
-
this.logger.info(message, { checkpoint: base.checkpoint, user_id:
|
|
243
|
+
this.logger.info(message, { checkpoint: base.checkpoint, user_id: userIdForLogs, buckets: allBuckets.length });
|
|
247
244
|
};
|
|
248
245
|
bucketsToFetch = allBuckets.map((b) => ({ bucket: b.bucket, priority: b.priority }));
|
|
249
246
|
|
|
@@ -398,12 +395,12 @@ export class BucketParameterState {
|
|
|
398
395
|
private cachedDynamicBucketSet: Set<string> | null = null;
|
|
399
396
|
|
|
400
397
|
private lookupsFromPreviousCheckpoint: Set<string> | null = null;
|
|
401
|
-
|
|
398
|
+
|
|
402
399
|
constructor(
|
|
403
400
|
context: SyncContext,
|
|
404
401
|
bucketStorage: BucketChecksumStateStorage,
|
|
405
402
|
syncRules: HydratedSyncRules,
|
|
406
|
-
tokenPayload:
|
|
403
|
+
tokenPayload: JwtPayload,
|
|
407
404
|
request: util.StreamingSyncRequest,
|
|
408
405
|
logger: Logger
|
|
409
406
|
) {
|
package/src/sync/sync.ts
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
|
|
2
|
-
import { BucketDescription, BucketPriority, HydratedSyncRules,
|
|
2
|
+
import { BucketDescription, BucketPriority, HydratedSyncRules, SqliteJsonValue } from '@powersync/service-sync-rules';
|
|
3
3
|
|
|
4
4
|
import { AbortError } from 'ix/aborterror.js';
|
|
5
5
|
|
|
6
6
|
import * as auth from '../auth/auth-index.js';
|
|
7
7
|
import * as storage from '../storage/storage-index.js';
|
|
8
8
|
import * as util from '../util/util-index.js';
|
|
9
|
+
|
|
9
10
|
import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
10
11
|
import { mergeAsyncIterables } from '../streams/streams-index.js';
|
|
11
12
|
import { BucketChecksumState, CheckpointLine } from './BucketChecksumState.js';
|
|
12
13
|
import { OperationsSentStats, RequestTracker, statsForBatch } from './RequestTracker.js';
|
|
13
14
|
import { SyncContext } from './SyncContext.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
parseCheckpointLineForEvent,
|
|
17
|
-
settledPromise,
|
|
18
|
-
tokenStream,
|
|
19
|
-
TokenStreamOptions
|
|
20
|
-
} from './util.js';
|
|
21
|
-
import { EventsEngine } from '../events/EventsEngine.js';
|
|
22
|
-
import { event_types } from '@powersync/service-types';
|
|
23
|
-
|
|
24
|
-
type Event = {
|
|
25
|
-
engine?: EventsEngine;
|
|
26
|
-
user_id: string;
|
|
27
|
-
client_id: string;
|
|
28
|
-
};
|
|
15
|
+
import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStream } from './util.js';
|
|
16
|
+
|
|
29
17
|
export interface SyncStreamParameters {
|
|
30
18
|
syncContext: SyncContext;
|
|
31
19
|
bucketStorage: storage.SyncRulesBucketStorage;
|
|
@@ -41,7 +29,6 @@ export interface SyncStreamParameters {
|
|
|
41
29
|
tokenStreamOptions?: Partial<TokenStreamOptions>;
|
|
42
30
|
|
|
43
31
|
tracker: RequestTracker;
|
|
44
|
-
event?: Event;
|
|
45
32
|
}
|
|
46
33
|
|
|
47
34
|
export async function* streamResponse(
|
|
@@ -56,8 +43,7 @@ export async function* streamResponse(
|
|
|
56
43
|
tokenStreamOptions,
|
|
57
44
|
tracker,
|
|
58
45
|
signal,
|
|
59
|
-
isEncodingAsBson
|
|
60
|
-
event
|
|
46
|
+
isEncodingAsBson
|
|
61
47
|
} = options;
|
|
62
48
|
const logger = options.logger ?? defaultLogger;
|
|
63
49
|
|
|
@@ -85,8 +71,7 @@ export async function* streamResponse(
|
|
|
85
71
|
tracker,
|
|
86
72
|
controller.signal,
|
|
87
73
|
logger,
|
|
88
|
-
isEncodingAsBson
|
|
89
|
-
event
|
|
74
|
+
isEncodingAsBson
|
|
90
75
|
);
|
|
91
76
|
// Merge the two streams, and abort as soon as one of the streams end.
|
|
92
77
|
const merged = mergeAsyncIterables([stream, ki], controller.signal);
|
|
@@ -111,17 +96,13 @@ async function* streamResponseInner(
|
|
|
111
96
|
bucketStorage: storage.SyncRulesBucketStorage,
|
|
112
97
|
syncRules: HydratedSyncRules,
|
|
113
98
|
params: util.StreamingSyncRequest,
|
|
114
|
-
tokenPayload:
|
|
99
|
+
tokenPayload: auth.JwtPayload,
|
|
115
100
|
tracker: RequestTracker,
|
|
116
101
|
signal: AbortSignal,
|
|
117
102
|
logger: Logger,
|
|
118
|
-
isEncodingAsBson: boolean
|
|
119
|
-
event?: Event
|
|
103
|
+
isEncodingAsBson: boolean
|
|
120
104
|
): AsyncGenerator<util.StreamingSyncLine | string | null> {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const userId = tokenPayload.sub;
|
|
124
|
-
const checkpointUserId = util.checkpointUserId(userId as string, params.client_id);
|
|
105
|
+
const checkpointUserId = util.checkpointUserId(tokenPayload.userIdString, params.client_id);
|
|
125
106
|
|
|
126
107
|
const checksumState = new BucketChecksumState({
|
|
127
108
|
syncContext,
|
|
@@ -179,15 +160,6 @@ async function* streamResponseInner(
|
|
|
179
160
|
|
|
180
161
|
// Since yielding can block, we update the state just before yielding the line.
|
|
181
162
|
line.advance();
|
|
182
|
-
parseCheckpointLineForEvent(checkpointLine);
|
|
183
|
-
|
|
184
|
-
event?.engine?.emit(event_types.EventsEngineEventType.SYNC_ANALYTICS_EVENT, {
|
|
185
|
-
user_id: event?.user_id,
|
|
186
|
-
client_id: event?.client_id,
|
|
187
|
-
request_streams: params.streams,
|
|
188
|
-
data: parseCheckpointLineForEvent(checkpointLine)
|
|
189
|
-
});
|
|
190
|
-
|
|
191
163
|
yield checkpointLine;
|
|
192
164
|
|
|
193
165
|
// Start syncing data for buckets up to the checkpoint. As soon as we have completed at least one priority and
|
|
@@ -261,7 +233,7 @@ async function* streamResponseInner(
|
|
|
261
233
|
onRowsSent: markOperationsSent,
|
|
262
234
|
abort_connection: signal,
|
|
263
235
|
abort_batch: abortCheckpointSignal,
|
|
264
|
-
|
|
236
|
+
userIdForLogs: tokenPayload.userIdJson,
|
|
265
237
|
// Passing null here will emit a full sync complete message at the end. If we pass a priority, we'll emit a partial
|
|
266
238
|
// sync complete message instead.
|
|
267
239
|
forPriority: !isLast ? priority : null,
|
|
@@ -295,7 +267,8 @@ interface BucketDataRequest {
|
|
|
295
267
|
* This signal also fires when abort_connection fires.
|
|
296
268
|
*/
|
|
297
269
|
abort_batch: AbortSignal;
|
|
298
|
-
|
|
270
|
+
/** User id for debug purposes, not for sync rules. */
|
|
271
|
+
userIdForLogs?: SqliteJsonValue;
|
|
299
272
|
forPriority: BucketPriority | null;
|
|
300
273
|
onRowsSent: (stats: OperationsSentStats) => void;
|
|
301
274
|
logger: Logger;
|
|
@@ -358,7 +331,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
358
331
|
let checkpointInvalidated = false;
|
|
359
332
|
|
|
360
333
|
if (syncContext.syncSemaphore.isLocked()) {
|
|
361
|
-
logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.
|
|
334
|
+
logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.userIdForLogs });
|
|
362
335
|
}
|
|
363
336
|
const acquired = await acquireSemaphoreAbortable(syncContext.syncSemaphore, AbortSignal.any([abort_batch]));
|
|
364
337
|
if (acquired === 'aborted') {
|
|
@@ -371,7 +344,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
371
344
|
// This can be noisy, so we only log when we get close to the
|
|
372
345
|
// concurrency limit.
|
|
373
346
|
logger.info(`Got sync lock. Slots available: ${value - 1}`, {
|
|
374
|
-
user_id: request.
|
|
347
|
+
user_id: request.userIdForLogs,
|
|
375
348
|
sync_data_slots: value - 1
|
|
376
349
|
});
|
|
377
350
|
}
|
|
@@ -454,7 +427,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
454
427
|
// This can be noisy, so we only log when we get close to the
|
|
455
428
|
// concurrency limit.
|
|
456
429
|
logger.info(`Releasing sync lock`, {
|
|
457
|
-
user_id: request.
|
|
430
|
+
user_id: request.userIdForLogs
|
|
458
431
|
});
|
|
459
432
|
}
|
|
460
433
|
release();
|
package/src/sync/util.ts
CHANGED
|
@@ -2,11 +2,8 @@ import * as timers from 'timers/promises';
|
|
|
2
2
|
|
|
3
3
|
import { SemaphoreInterface } from 'async-mutex';
|
|
4
4
|
import * as util from '../util/util-index.js';
|
|
5
|
-
import { StreamingSyncCheckpoint, StreamingSyncCheckpointDiff } from '../util/util-index.js';
|
|
6
5
|
import { RequestTracker } from './RequestTracker.js';
|
|
7
|
-
import * as bson from 'bson';
|
|
8
6
|
import { serialize } from 'bson';
|
|
9
|
-
import { SyncEventCheckpointType } from '@powersync/service-types/dist/reports.js';
|
|
10
7
|
|
|
11
8
|
export type TokenStreamOptions = {
|
|
12
9
|
/**
|
|
@@ -218,25 +215,3 @@ export function* getIntersection<T>(a: MapOrSet<T>, b: MapOrSet<T>): IterableIte
|
|
|
218
215
|
}
|
|
219
216
|
}
|
|
220
217
|
}
|
|
221
|
-
|
|
222
|
-
export function parseCheckpointLineForEvent(line: StreamingSyncCheckpoint | StreamingSyncCheckpointDiff) {
|
|
223
|
-
if ('checkpoint' in line) {
|
|
224
|
-
return {
|
|
225
|
-
id: new bson.ObjectId(),
|
|
226
|
-
type: SyncEventCheckpointType.FULL,
|
|
227
|
-
last_op_id: line.checkpoint.last_op_id,
|
|
228
|
-
buckets: line.checkpoint.buckets,
|
|
229
|
-
streams: line.checkpoint.streams,
|
|
230
|
-
date: new Date()
|
|
231
|
-
};
|
|
232
|
-
} else {
|
|
233
|
-
return {
|
|
234
|
-
id: new bson.ObjectId(),
|
|
235
|
-
type: SyncEventCheckpointType.DIFF,
|
|
236
|
-
last_op_id: line.checkpoint_diff.last_op_id,
|
|
237
|
-
updated_buckets: line.checkpoint_diff.updated_buckets,
|
|
238
|
-
removed_buckets: line.checkpoint_diff.removed_buckets,
|
|
239
|
-
date: new Date()
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
}
|
package/src/util/config.ts
CHANGED
|
@@ -8,11 +8,16 @@ import { CompoundConfigCollector } from './util-index.js';
|
|
|
8
8
|
|
|
9
9
|
export function configureLogger(config?: configFile.LoggingConfig): void {
|
|
10
10
|
const level = process.env.PS_LOG_LEVEL ?? config?.level ?? DEFAULT_LOG_LEVEL;
|
|
11
|
-
const format =
|
|
11
|
+
const format =
|
|
12
|
+
(process.env.PS_LOG_FORMAT as configFile.LoggingConfig['format']) ?? config?.format ?? DEFAULT_LOG_FORMAT;
|
|
12
13
|
const winstonFormat = format === 'json' ? LogFormat.production : LogFormat.development;
|
|
13
14
|
|
|
15
|
+
// We want the user to always be aware that a log level was configured (they might forget they set it in the config and wonder why they aren't seeing logs)
|
|
16
|
+
// We log this using the configured format, but before we configure the level.
|
|
17
|
+
logger.configure({ level: DEFAULT_LOG_LEVEL, format: winstonFormat, transports: [new winston.transports.Console()] });
|
|
18
|
+
logger.info(`Configured logger with level "${level}" and format "${format}"`);
|
|
19
|
+
|
|
14
20
|
logger.configure({ level, format: winstonFormat, transports: [new winston.transports.Console()] });
|
|
15
|
-
console.log(`[PREFLIGHT] Configured logger with level "${level}" and format "${format}"`); // we want the user to always be aware that a log level was configured (they might forget they set it in the config and wonder why they aren't seeing logs)
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
/**
|
package/test/src/auth.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ describe('JWT Auth', () => {
|
|
|
70
70
|
defaultAudiences: ['tests'],
|
|
71
71
|
maxAge: '6m'
|
|
72
72
|
});
|
|
73
|
-
expect(verified.
|
|
73
|
+
expect(verified.userIdJson).toEqual('f1');
|
|
74
74
|
await expect(
|
|
75
75
|
store.verifyJwt(signedJwt, {
|
|
76
76
|
defaultAudiences: ['other'],
|
|
@@ -126,6 +126,58 @@ describe('JWT Auth', () => {
|
|
|
126
126
|
).rejects.toThrow('[PSYNC_S2103] JWT has expired');
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test('KeyStore normalizes sub claim values', async () => {
|
|
130
|
+
const keys = await StaticKeyCollector.importKeys([sharedKey]);
|
|
131
|
+
const store = new KeyStore(keys);
|
|
132
|
+
const signKey = (await jose.importJWK(sharedKey)) as jose.KeyLike;
|
|
133
|
+
|
|
134
|
+
const signWithSub = async (sub: any) => {
|
|
135
|
+
return new jose.SignJWT({ sub })
|
|
136
|
+
.setProtectedHeader({ alg: 'HS256', kid: 'k1' })
|
|
137
|
+
.setIssuedAt()
|
|
138
|
+
.setIssuer('tester')
|
|
139
|
+
.setAudience('tests')
|
|
140
|
+
.setExpirationTime('5m')
|
|
141
|
+
.sign(signKey);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const stringToken = await signWithSub('user-1');
|
|
145
|
+
const stringVerified = await store.verifyJwt(stringToken, {
|
|
146
|
+
defaultAudiences: ['tests'],
|
|
147
|
+
maxAge: '6m'
|
|
148
|
+
});
|
|
149
|
+
expect(stringVerified.userIdJson).toEqual('user-1');
|
|
150
|
+
expect(stringVerified.userIdString).toEqual('user-1');
|
|
151
|
+
|
|
152
|
+
const booleanToken = await signWithSub(true);
|
|
153
|
+
const booleanVerified = await store.verifyJwt(booleanToken, {
|
|
154
|
+
defaultAudiences: ['tests'],
|
|
155
|
+
maxAge: '6m'
|
|
156
|
+
});
|
|
157
|
+
expect(booleanVerified.userIdJson).toEqual(1n);
|
|
158
|
+
expect(booleanVerified.userIdString).toEqual('1');
|
|
159
|
+
|
|
160
|
+
const objectSub = { id: 1, name: 'test' };
|
|
161
|
+
const objectToken = await signWithSub(objectSub);
|
|
162
|
+
const objectVerified = await store.verifyJwt(objectToken, {
|
|
163
|
+
defaultAudiences: ['tests'],
|
|
164
|
+
maxAge: '6m'
|
|
165
|
+
});
|
|
166
|
+
// The exact JSON serialization here may change in the future
|
|
167
|
+
expect(objectVerified.userIdJson).toEqual(`{"id":1.0,"name":"test"}`);
|
|
168
|
+
expect(objectVerified.userIdString).toEqual(`{"id":1.0,"name":"test"}`);
|
|
169
|
+
|
|
170
|
+
const arraySub = [1, 'a'];
|
|
171
|
+
const arrayToken = await signWithSub(arraySub);
|
|
172
|
+
const arrayVerified = await store.verifyJwt(arrayToken, {
|
|
173
|
+
defaultAudiences: ['tests'],
|
|
174
|
+
maxAge: '6m'
|
|
175
|
+
});
|
|
176
|
+
// The exact JSON serialization here may change in the future
|
|
177
|
+
expect(arrayVerified.userIdJson).toEqual(`[1.0,"a"]`);
|
|
178
|
+
expect(arrayVerified.userIdString).toEqual(`[1.0,"a"]`);
|
|
179
|
+
});
|
|
180
|
+
|
|
129
181
|
test('Algorithm validation', async () => {
|
|
130
182
|
const keys = await StaticKeyCollector.importKeys([publicKeyRSA]);
|
|
131
183
|
const store = new KeyStore(keys);
|
|
@@ -210,12 +262,14 @@ describe('JWT Auth', () => {
|
|
|
210
262
|
.setExpirationTime('5m')
|
|
211
263
|
.sign(signKey2);
|
|
212
264
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
265
|
+
expect(
|
|
266
|
+
(
|
|
267
|
+
await store.verifyJwt(signedJwt3, {
|
|
268
|
+
defaultAudiences: ['tests'],
|
|
269
|
+
maxAge: '6m'
|
|
270
|
+
})
|
|
271
|
+
).parsedPayload
|
|
272
|
+
).toMatchObject({ sub: 'f1' });
|
|
219
273
|
|
|
220
274
|
// Random kid, matches sharedKey2
|
|
221
275
|
const signedJwt4 = await new jose.SignJWT({})
|
|
@@ -227,12 +281,14 @@ describe('JWT Auth', () => {
|
|
|
227
281
|
.setExpirationTime('5m')
|
|
228
282
|
.sign(signKey2);
|
|
229
283
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
expect(
|
|
285
|
+
(
|
|
286
|
+
await store.verifyJwt(signedJwt4, {
|
|
287
|
+
defaultAudiences: ['tests'],
|
|
288
|
+
maxAge: '6m'
|
|
289
|
+
})
|
|
290
|
+
).parsedPayload
|
|
291
|
+
).toMatchObject({ sub: 'f1' });
|
|
236
292
|
});
|
|
237
293
|
|
|
238
294
|
test('KeyOptions', async () => {
|
|
@@ -258,7 +314,7 @@ describe('JWT Auth', () => {
|
|
|
258
314
|
defaultAudiences: ['tests'],
|
|
259
315
|
maxAge: '6m'
|
|
260
316
|
});
|
|
261
|
-
expect(verified.
|
|
317
|
+
expect(verified.userIdJson).toEqual('f1');
|
|
262
318
|
|
|
263
319
|
const signedJwt2 = await new jose.SignJWT({})
|
|
264
320
|
.setProtectedHeader({ alg: 'HS256', kid: 'k1' })
|
|
@@ -410,12 +466,12 @@ describe('JWT Auth', () => {
|
|
|
410
466
|
.setExpirationTime('5m')
|
|
411
467
|
.sign(signKey);
|
|
412
468
|
|
|
413
|
-
const verified =
|
|
469
|
+
const verified = await store.verifyJwt(signedJwt, {
|
|
414
470
|
defaultAudiences: ['tests'],
|
|
415
471
|
maxAge: '6m'
|
|
416
|
-
})
|
|
472
|
+
});
|
|
417
473
|
|
|
418
|
-
expect(verified.claim).toEqual('test-claim');
|
|
474
|
+
expect(verified.parsedPayload.claim).toEqual('test-claim');
|
|
419
475
|
});
|
|
420
476
|
|
|
421
477
|
test('signing with ECDSA', async () => {
|
|
@@ -432,12 +488,12 @@ describe('JWT Auth', () => {
|
|
|
432
488
|
.setExpirationTime('5m')
|
|
433
489
|
.sign(signKey);
|
|
434
490
|
|
|
435
|
-
const verified =
|
|
491
|
+
const verified = await store.verifyJwt(signedJwt, {
|
|
436
492
|
defaultAudiences: ['tests'],
|
|
437
493
|
maxAge: '6m'
|
|
438
|
-
})
|
|
494
|
+
});
|
|
439
495
|
|
|
440
|
-
expect(verified.claim).toEqual('test-claim-2');
|
|
496
|
+
expect(verified.parsedPayload.claim).toEqual('test-claim-2');
|
|
441
497
|
});
|
|
442
498
|
|
|
443
499
|
describe('debugKeyNotFound', () => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BasicRouterRequest, Context, JwtPayload } from '@/index.js';
|
|
2
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { validate } from '../../../src/routes/endpoints/admin.js';
|
|
5
|
+
import { mockServiceContext } from './mocks.js';
|
|
6
|
+
|
|
7
|
+
describe('admin routes', () => {
|
|
8
|
+
describe('validate', () => {
|
|
9
|
+
it('reports errors with source location', async () => {
|
|
10
|
+
const context: 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
|
+
};
|
|
25
|
+
|
|
26
|
+
const response = await validate.handler({
|
|
27
|
+
context,
|
|
28
|
+
params: {
|
|
29
|
+
sync_rules: `
|
|
30
|
+
bucket_definitions:
|
|
31
|
+
missing_table:
|
|
32
|
+
data:
|
|
33
|
+
- SELECT * FROM missing_table
|
|
34
|
+
`
|
|
35
|
+
},
|
|
36
|
+
request
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(response.errors).toEqual([
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
level: 'warning',
|
|
42
|
+
location: { start_offset: 70, end_offset: 83 },
|
|
43
|
+
message: 'Table public.missing_table not found'
|
|
44
|
+
})
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
package/test/src/routes/mocks.ts
CHANGED
|
@@ -41,8 +41,29 @@ export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | nu
|
|
|
41
41
|
return {
|
|
42
42
|
getParseSyncRulesOptions() {
|
|
43
43
|
return { defaultSchema: 'public' };
|
|
44
|
+
},
|
|
45
|
+
async getSourceConfig() {
|
|
46
|
+
return {
|
|
47
|
+
tag: 'test_tag',
|
|
48
|
+
id: 'test_id',
|
|
49
|
+
type: 'test_type'
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
async getConnectionSchema() {
|
|
53
|
+
return [];
|
|
54
|
+
},
|
|
55
|
+
async getConnectionStatus() {
|
|
56
|
+
return {
|
|
57
|
+
id: 'test_id',
|
|
58
|
+
uri: 'http://example.org/',
|
|
59
|
+
connected: true,
|
|
60
|
+
errors: []
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
async getDebugTablesInfo() {
|
|
64
|
+
return [];
|
|
44
65
|
}
|
|
45
|
-
}
|
|
66
|
+
} satisfies Partial<RouteAPI> as unknown as RouteAPI;
|
|
46
67
|
},
|
|
47
68
|
addStopHandler() {
|
|
48
69
|
return () => {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BasicRouterRequest, Context, SyncRulesBucketStorage } from '@/index.js';
|
|
1
|
+
import { BasicRouterRequest, Context, JwtPayload, SyncRulesBucketStorage } from '@/index.js';
|
|
2
2
|
import { RouterResponse, ServiceError, logger } from '@powersync/lib-services-framework';
|
|
3
3
|
import { SqlSyncRules } from '@powersync/service-sync-rules';
|
|
4
4
|
import { Readable, Writable } from 'stream';
|
|
@@ -8,6 +8,7 @@ import winston from 'winston';
|
|
|
8
8
|
import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
|
|
9
9
|
import { DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS, limitParamsForLogging } from '../../../src/util/param-logging.js';
|
|
10
10
|
import { mockServiceContext } from './mocks.js';
|
|
11
|
+
import { DEFAULT_HYDRATION_STATE } from '@powersync/service-sync-rules';
|
|
11
12
|
|
|
12
13
|
describe('Stream Route', () => {
|
|
13
14
|
describe('compressed stream', () => {
|
|
@@ -15,11 +16,11 @@ describe('Stream Route', () => {
|
|
|
15
16
|
const context: Context = {
|
|
16
17
|
logger: logger,
|
|
17
18
|
service_context: mockServiceContext(null),
|
|
18
|
-
token_payload: {
|
|
19
|
+
token_payload: new JwtPayload({
|
|
19
20
|
sub: '',
|
|
20
21
|
exp: 0,
|
|
21
22
|
iat: 0
|
|
22
|
-
}
|
|
23
|
+
})
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const request: BasicRouterRequest = {
|
|
@@ -45,7 +46,7 @@ describe('Stream Route', () => {
|
|
|
45
46
|
|
|
46
47
|
const storage = {
|
|
47
48
|
getParsedSyncRules() {
|
|
48
|
-
return new SqlSyncRules('bucket_definitions: {}').hydrate();
|
|
49
|
+
return new SqlSyncRules('bucket_definitions: {}').hydrate({ hydrationState: DEFAULT_HYDRATION_STATE });
|
|
49
50
|
},
|
|
50
51
|
watchCheckpointChanges: async function* (options) {
|
|
51
52
|
throw new Error('Simulated storage error');
|
|
@@ -56,11 +57,11 @@ describe('Stream Route', () => {
|
|
|
56
57
|
const context: Context = {
|
|
57
58
|
logger: logger,
|
|
58
59
|
service_context: serviceContext,
|
|
59
|
-
token_payload: {
|
|
60
|
+
token_payload: new JwtPayload({
|
|
60
61
|
exp: new Date().getTime() / 1000 + 10000,
|
|
61
62
|
iat: new Date().getTime() / 1000 - 10000,
|
|
62
63
|
sub: 'test-user'
|
|
63
|
-
}
|
|
64
|
+
})
|
|
64
65
|
};
|
|
65
66
|
|
|
66
67
|
// It may be worth eventually doing this via Fastify to test the full stack
|
|
@@ -83,7 +84,7 @@ describe('Stream Route', () => {
|
|
|
83
84
|
it('logs the application metadata', async () => {
|
|
84
85
|
const storage = {
|
|
85
86
|
getParsedSyncRules() {
|
|
86
|
-
return new SqlSyncRules('bucket_definitions: {}').hydrate();
|
|
87
|
+
return new SqlSyncRules('bucket_definitions: {}').hydrate({ hydrationState: DEFAULT_HYDRATION_STATE });
|
|
87
88
|
},
|
|
88
89
|
watchCheckpointChanges: async function* (options) {
|
|
89
90
|
throw new Error('Simulated storage error');
|
|
@@ -108,11 +109,11 @@ describe('Stream Route', () => {
|
|
|
108
109
|
const context: Context = {
|
|
109
110
|
logger: testLogger,
|
|
110
111
|
service_context: serviceContext,
|
|
111
|
-
token_payload: {
|
|
112
|
+
token_payload: new JwtPayload({
|
|
112
113
|
exp: new Date().getTime() / 1000 + 10000,
|
|
113
114
|
iat: new Date().getTime() / 1000 - 10000,
|
|
114
115
|
sub: 'test-user'
|
|
115
|
-
}
|
|
116
|
+
})
|
|
116
117
|
};
|
|
117
118
|
|
|
118
119
|
const request: BasicRouterRequest = {
|