@powersync/service-core 0.0.0-dev-20250507151436 → 0.0.0-dev-20250611110033
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 +47 -7
- package/dist/api/RouteAPI.d.ts +1 -5
- package/dist/api/diagnostics.js +1 -1
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/auth/CachedKeyCollector.js +2 -7
- package/dist/auth/CachedKeyCollector.js.map +1 -1
- package/dist/auth/CompoundKeyCollector.js.map +1 -1
- package/dist/auth/KeyCollector.d.ts +2 -2
- package/dist/auth/KeyStore.js +32 -14
- package/dist/auth/KeyStore.js.map +1 -1
- package/dist/auth/RemoteJWKSCollector.d.ts +1 -0
- package/dist/auth/RemoteJWKSCollector.js +39 -16
- package/dist/auth/RemoteJWKSCollector.js.map +1 -1
- package/dist/auth/auth-index.d.ts +1 -0
- package/dist/auth/auth-index.js +1 -0
- package/dist/auth/auth-index.js.map +1 -1
- package/dist/auth/utils.d.ts +6 -0
- package/dist/auth/utils.js +97 -0
- package/dist/auth/utils.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.d.ts +1 -1
- package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +4 -0
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +23 -0
- package/dist/replication/AbstractReplicator.js +45 -0
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/RelationCache.d.ts +9 -0
- package/dist/replication/RelationCache.js +20 -0
- package/dist/replication/RelationCache.js.map +1 -0
- package/dist/replication/replication-index.d.ts +1 -0
- package/dist/replication/replication-index.js +1 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/replication/replication-metrics.js +6 -0
- package/dist/replication/replication-metrics.js.map +1 -1
- package/dist/routes/RouterEngine.js +1 -1
- package/dist/routes/RouterEngine.js.map +1 -1
- package/dist/routes/auth.d.ts +5 -16
- package/dist/routes/auth.js +6 -4
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +3 -21
- package/dist/routes/configure-fastify.js +3 -2
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/configure-rsocket.js +28 -11
- package/dist/routes/configure-rsocket.js.map +1 -1
- package/dist/routes/endpoints/admin.js +2 -0
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.d.ts +4 -28
- package/dist/routes/endpoints/socket-route.js +22 -8
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +6 -6
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.d.ts +2 -14
- package/dist/routes/endpoints/sync-stream.js +28 -9
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/route-register.js +10 -6
- package/dist/routes/route-register.js.map +1 -1
- package/dist/routes/router.d.ts +7 -3
- package/dist/routes/router.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +17 -1
- package/dist/storage/BucketStorageBatch.js +2 -1
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/PersistedSyncRulesContent.d.ts +5 -0
- package/dist/storage/SourceTable.d.ts +17 -1
- package/dist/storage/SourceTable.js +28 -0
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +11 -2
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/bson.js +4 -1
- package/dist/storage/bson.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +40 -18
- package/dist/sync/BucketChecksumState.js +122 -74
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/RequestTracker.d.ts +22 -1
- package/dist/sync/RequestTracker.js +51 -2
- package/dist/sync/RequestTracker.js.map +1 -1
- package/dist/sync/sync.d.ts +3 -5
- package/dist/sync/sync.js +49 -34
- package/dist/sync/sync.js.map +1 -1
- package/dist/util/config/collectors/config-collector.js +2 -5
- package/dist/util/config/collectors/config-collector.js.map +1 -1
- package/dist/util/protocol-types.d.ts +9 -9
- package/dist/util/protocol-types.js.map +1 -1
- package/dist/util/utils.d.ts +1 -1
- package/package.json +6 -7
- package/src/api/RouteAPI.ts +1 -6
- package/src/api/diagnostics.ts +1 -1
- package/src/auth/CachedKeyCollector.ts +4 -6
- package/src/auth/CompoundKeyCollector.ts +2 -1
- package/src/auth/KeyCollector.ts +2 -2
- package/src/auth/KeyStore.ts +45 -20
- package/src/auth/RemoteJWKSCollector.ts +39 -16
- package/src/auth/auth-index.ts +1 -0
- package/src/auth/utils.ts +102 -0
- package/src/index.ts +2 -0
- package/src/metrics/open-telemetry/OpenTelemetryMetricsFactory.ts +3 -3
- package/src/replication/AbstractReplicationJob.ts +5 -0
- package/src/replication/AbstractReplicator.ts +47 -0
- package/src/replication/RelationCache.ts +25 -0
- package/src/replication/replication-index.ts +1 -0
- package/src/replication/replication-metrics.ts +7 -0
- package/src/routes/RouterEngine.ts +1 -1
- package/src/routes/auth.ts +7 -6
- package/src/routes/configure-fastify.ts +6 -3
- package/src/routes/configure-rsocket.ts +33 -14
- package/src/routes/endpoints/admin.ts +2 -0
- package/src/routes/endpoints/socket-route.ts +24 -8
- package/src/routes/endpoints/sync-rules.ts +6 -6
- package/src/routes/endpoints/sync-stream.ts +31 -8
- package/src/routes/route-register.ts +10 -7
- package/src/routes/router.ts +9 -3
- package/src/storage/BucketStorageBatch.ts +22 -2
- package/src/storage/PersistedSyncRulesContent.ts +6 -0
- package/src/storage/SourceTable.ts +44 -1
- package/src/storage/SyncRulesBucketStorage.ts +14 -2
- package/src/storage/bson.ts +4 -1
- package/src/sync/BucketChecksumState.ts +162 -77
- package/src/sync/RequestTracker.ts +70 -3
- package/src/sync/sync.ts +72 -49
- package/src/util/config/collectors/config-collector.ts +3 -7
- package/src/util/protocol-types.ts +15 -10
- package/test/src/auth.test.ts +29 -11
- package/test/src/sync/BucketChecksumState.test.ts +32 -18
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,6 +4,7 @@ import * as jose from 'jose';
|
|
|
4
4
|
import fetch from 'node-fetch';
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
AuthorizationError,
|
|
7
8
|
ErrorCode,
|
|
8
9
|
LookupOptions,
|
|
9
10
|
makeHostnameLookupFunction,
|
|
@@ -46,28 +47,43 @@ export class RemoteJWKSCollector implements KeyCollector {
|
|
|
46
47
|
this.agent = this.resolveAgent();
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
async
|
|
50
|
+
private async getJwksData(): Promise<any> {
|
|
50
51
|
const abortController = new AbortController();
|
|
51
52
|
const timeout = setTimeout(() => {
|
|
52
53
|
abortController.abort();
|
|
53
54
|
}, 30_000);
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(this.url, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: {
|
|
60
|
+
Accept: 'application/json'
|
|
61
|
+
},
|
|
62
|
+
signal: abortController.signal,
|
|
63
|
+
agent: this.agent
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed with ${res.statusText}`, {
|
|
68
|
+
configurationDetails: `JWKS URL: ${this.url}`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
return (await res.json()) as any;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed`, {
|
|
75
|
+
configurationDetails: `JWKS URL: ${this.url}`,
|
|
76
|
+
// This covers most cases of FetchError
|
|
77
|
+
// `cause: e` could lose the error message
|
|
78
|
+
cause: { message: e.message, code: e.code }
|
|
79
|
+
});
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
async getKeys(): Promise<KeyResult> {
|
|
86
|
+
const data = await this.getJwksData();
|
|
71
87
|
|
|
72
88
|
// https://github.com/panva/jose/blob/358e864a0cccf1e0f9928a959f91f18f3f06a7de/src/jwks/local.ts#L36
|
|
73
89
|
if (
|
|
@@ -75,7 +91,14 @@ export class RemoteJWKSCollector implements KeyCollector {
|
|
|
75
91
|
!Array.isArray(data.keys) ||
|
|
76
92
|
!(data.keys as any[]).every((key) => typeof key == 'object' && !Array.isArray(key))
|
|
77
93
|
) {
|
|
78
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
keys: [],
|
|
96
|
+
errors: [
|
|
97
|
+
new AuthorizationError(ErrorCode.PSYNC_S2204, `Invalid JWKS response`, {
|
|
98
|
+
configurationDetails: `JWKS URL: ${this.url}. Response:\n${JSON.stringify(data, null, 2)}`
|
|
99
|
+
})
|
|
100
|
+
]
|
|
101
|
+
};
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
let keys: KeySpec[] = [];
|
package/src/auth/auth-index.ts
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AuthorizationError, ErrorCode } from '@powersync/lib-services-framework';
|
|
2
|
+
import * as jose from 'jose';
|
|
3
|
+
|
|
4
|
+
export function mapJoseError(error: jose.errors.JOSEError, token: string): AuthorizationError {
|
|
5
|
+
const tokenDetails = tokenDebugDetails(token);
|
|
6
|
+
if (error.code === jose.errors.JWSInvalid.code || error.code === jose.errors.JWTInvalid.code) {
|
|
7
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2101, 'Token is not a well-formed JWT. Check the token format.', {
|
|
8
|
+
tokenDetails,
|
|
9
|
+
cause: error
|
|
10
|
+
});
|
|
11
|
+
} else if (error.code === jose.errors.JWTClaimValidationFailed.code) {
|
|
12
|
+
// Jose message: missing required "sub" claim
|
|
13
|
+
const claim = (error as jose.errors.JWTClaimValidationFailed).claim;
|
|
14
|
+
return new AuthorizationError(
|
|
15
|
+
ErrorCode.PSYNC_S2101,
|
|
16
|
+
`JWT payload is missing a required claim ${JSON.stringify(claim)}`,
|
|
17
|
+
{
|
|
18
|
+
cause: error,
|
|
19
|
+
tokenDetails
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
} else if (error.code == jose.errors.JWTExpired.code) {
|
|
23
|
+
// Jose message: "exp" claim timestamp check failed
|
|
24
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2103, `JWT has expired`, {
|
|
25
|
+
cause: error,
|
|
26
|
+
tokenDetails
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2101, error.message, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function mapAuthError(error: any, token: string): AuthorizationError {
|
|
33
|
+
if (error instanceof AuthorizationError) {
|
|
34
|
+
error.tokenDetails ??= tokenDebugDetails(token);
|
|
35
|
+
return error;
|
|
36
|
+
} else if (error instanceof jose.errors.JOSEError) {
|
|
37
|
+
return mapJoseError(error, token);
|
|
38
|
+
}
|
|
39
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2101, error.message, {
|
|
40
|
+
cause: error,
|
|
41
|
+
tokenDetails: tokenDebugDetails(token)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mapJoseConfigError(error: jose.errors.JOSEError): AuthorizationError {
|
|
46
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2201, error.message ?? 'Authorization error', { cause: error });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mapAuthConfigError(error: any): AuthorizationError {
|
|
50
|
+
if (error instanceof AuthorizationError) {
|
|
51
|
+
return error;
|
|
52
|
+
} else if (error instanceof jose.errors.JOSEError) {
|
|
53
|
+
return mapJoseConfigError(error);
|
|
54
|
+
}
|
|
55
|
+
return new AuthorizationError(ErrorCode.PSYNC_S2201, error.message ?? 'Auth configuration error', { cause: error });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decode token for debugging purposes.
|
|
60
|
+
*
|
|
61
|
+
* We use this to add details to our logs. We don't log the entire token, since it may for example
|
|
62
|
+
* a password incorrectly used as a token.
|
|
63
|
+
*/
|
|
64
|
+
function tokenDebugDetails(token: string): string {
|
|
65
|
+
try {
|
|
66
|
+
// For valid tokens, we return the header and payload
|
|
67
|
+
const header = jose.decodeProtectedHeader(token);
|
|
68
|
+
const payload = jose.decodeJwt(token);
|
|
69
|
+
return `<header: ${JSON.stringify(header)} payload: ${JSON.stringify(payload)}>`;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// Token fails to parse. Return some details.
|
|
72
|
+
return invalidTokenDetails(token);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function invalidTokenDetails(token: string): string {
|
|
77
|
+
const parts = token.split('.');
|
|
78
|
+
if (parts.length !== 3) {
|
|
79
|
+
return `<token with ${parts.length} parts (needs 3), length=${token.length}>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return `<token with unparsable header>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return `<token with unparsable payload>`;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
Buffer.from(signatureB64, 'base64url');
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return `<token with unparsable signature>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return `<invalid JWT, length=${token.length}>`;
|
|
102
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Meter, ValueType } from '@opentelemetry/api';
|
|
2
2
|
import {
|
|
3
3
|
Counter,
|
|
4
|
-
ObservableGauge,
|
|
5
|
-
UpDownCounter,
|
|
6
4
|
MetricMetadata,
|
|
7
5
|
MetricsFactory,
|
|
8
|
-
|
|
6
|
+
ObservableGauge,
|
|
7
|
+
Precision,
|
|
8
|
+
UpDownCounter
|
|
9
9
|
} from '../metrics-interfaces.js';
|
|
10
10
|
|
|
11
11
|
export class OpenTelemetryMetricsFactory implements MetricsFactory {
|
|
@@ -78,4 +78,9 @@ export abstract class AbstractReplicationJob {
|
|
|
78
78
|
public get isStopped(): boolean {
|
|
79
79
|
return this.abortController.signal.aborted;
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get replication lag for this job in ms.
|
|
84
|
+
*/
|
|
85
|
+
abstract getReplicationLagMillis(): Promise<number | undefined>;
|
|
81
86
|
}
|
|
@@ -8,6 +8,7 @@ import { AbstractReplicationJob } from './AbstractReplicationJob.js';
|
|
|
8
8
|
import { ErrorRateLimiter } from './ErrorRateLimiter.js';
|
|
9
9
|
import { ConnectionTestResult } from './ReplicationModule.js';
|
|
10
10
|
import { MetricsEngine } from '../metrics/MetricsEngine.js';
|
|
11
|
+
import { ReplicationMetric } from '@powersync/service-types';
|
|
11
12
|
|
|
12
13
|
// 5 minutes
|
|
13
14
|
const PING_INTERVAL = 1_000_000_000n * 300n;
|
|
@@ -42,6 +43,11 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
42
43
|
* @private
|
|
43
44
|
*/
|
|
44
45
|
private replicationJobs = new Map<number, T>();
|
|
46
|
+
/**
|
|
47
|
+
* Used for replication lag computation.
|
|
48
|
+
*/
|
|
49
|
+
private activeReplicationJob: T | undefined = undefined;
|
|
50
|
+
|
|
45
51
|
private stopped = false;
|
|
46
52
|
|
|
47
53
|
// First ping is only after 5 minutes, not when starting
|
|
@@ -87,6 +93,17 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
87
93
|
process.exit(1);
|
|
88
94
|
}, 1000);
|
|
89
95
|
});
|
|
96
|
+
this.metrics.getObservableGauge(ReplicationMetric.REPLICATION_LAG_SECONDS).setValueProvider(async () => {
|
|
97
|
+
const lag = await this.getReplicationLagMillis().catch((e) => {
|
|
98
|
+
this.logger.error('Failed to get replication lag', e);
|
|
99
|
+
return undefined;
|
|
100
|
+
});
|
|
101
|
+
if (lag == null) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// ms to seconds
|
|
105
|
+
return Math.round(lag / 1000);
|
|
106
|
+
});
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
public async stop(): Promise<void> {
|
|
@@ -161,8 +178,12 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
161
178
|
const existingJobs = new Map<number, T>(this.replicationJobs.entries());
|
|
162
179
|
const replicatingSyncRules = await this.storage.getReplicatingSyncRules();
|
|
163
180
|
const newJobs = new Map<number, T>();
|
|
181
|
+
let activeJob: T | undefined = undefined;
|
|
164
182
|
for (let syncRules of replicatingSyncRules) {
|
|
165
183
|
const existingJob = existingJobs.get(syncRules.id);
|
|
184
|
+
if (syncRules.active && activeJob == null) {
|
|
185
|
+
activeJob = existingJob;
|
|
186
|
+
}
|
|
166
187
|
if (existingJob && !existingJob.isStopped) {
|
|
167
188
|
// No change
|
|
168
189
|
existingJobs.delete(syncRules.id);
|
|
@@ -188,6 +209,9 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
188
209
|
|
|
189
210
|
newJobs.set(syncRules.id, newJob);
|
|
190
211
|
newJob.start();
|
|
212
|
+
if (syncRules.active) {
|
|
213
|
+
activeJob = newJob;
|
|
214
|
+
}
|
|
191
215
|
} catch (e) {
|
|
192
216
|
// Could be a sync rules parse error,
|
|
193
217
|
// for example from stricter validation that was added.
|
|
@@ -199,6 +223,7 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
199
223
|
}
|
|
200
224
|
|
|
201
225
|
this.replicationJobs = newJobs;
|
|
226
|
+
this.activeReplicationJob = activeJob;
|
|
202
227
|
|
|
203
228
|
// Stop any orphaned jobs that no longer have sync rules.
|
|
204
229
|
// Termination happens below
|
|
@@ -236,4 +261,26 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
236
261
|
}
|
|
237
262
|
|
|
238
263
|
abstract testConnection(): Promise<ConnectionTestResult>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Measure replication lag in milliseconds.
|
|
267
|
+
*
|
|
268
|
+
* In general, this is the difference between now() and the time the oldest record, that we haven't committed yet,
|
|
269
|
+
* has been written (committed) to the source database.
|
|
270
|
+
*
|
|
271
|
+
* This is roughly a measure of the _average_ amount of time we're behind.
|
|
272
|
+
* If we get a new change as soon as each previous one has finished processing, and each change takes 1000ms
|
|
273
|
+
* to process, the average replication lag will be 500ms, not 1000ms.
|
|
274
|
+
*
|
|
275
|
+
* 1. When we are actively replicating, this is the difference between now and when the time the change was
|
|
276
|
+
* written to the source database.
|
|
277
|
+
* 2. When the replication stream is idle, this is either 0, or the delay for keepalive messages to make it to us.
|
|
278
|
+
* 3. When the active replication stream is an error state, this is the time since the last successful commit.
|
|
279
|
+
* 4. If there is no active replication stream, this is undefined.
|
|
280
|
+
*
|
|
281
|
+
* "processing" replication streams are not taken into account for this metric.
|
|
282
|
+
*/
|
|
283
|
+
async getReplicationLagMillis(): Promise<number | undefined> {
|
|
284
|
+
return this.activeReplicationJob?.getReplicationLagMillis();
|
|
285
|
+
}
|
|
239
286
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SourceTable } from '../storage/SourceTable.js';
|
|
2
|
+
|
|
3
|
+
export class RelationCache<T> {
|
|
4
|
+
private cache = new Map<string | number, SourceTable>();
|
|
5
|
+
private idFunction: (item: T | SourceTable) => string | number;
|
|
6
|
+
|
|
7
|
+
constructor(idFunction: (item: T | SourceTable) => string | number) {
|
|
8
|
+
this.idFunction = idFunction;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
update(table: SourceTable) {
|
|
12
|
+
const id = this.idFunction(table);
|
|
13
|
+
this.cache.set(id, table);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(source: T | SourceTable): SourceTable | undefined {
|
|
17
|
+
const id = this.idFunction(source);
|
|
18
|
+
return this.cache.get(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
delete(source: T | SourceTable): boolean {
|
|
22
|
+
const id = this.idFunction(source);
|
|
23
|
+
return this.cache.delete(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -26,6 +26,11 @@ export function createCoreReplicationMetrics(engine: MetricsEngine): void {
|
|
|
26
26
|
name: ReplicationMetric.CHUNKS_REPLICATED,
|
|
27
27
|
description: 'Total number of replication chunks'
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
engine.createObservableGauge({
|
|
31
|
+
name: ReplicationMetric.REPLICATION_LAG_SECONDS,
|
|
32
|
+
description: 'Replication lag between the source database and PowerSync instance'
|
|
33
|
+
});
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -42,4 +47,6 @@ export function initializeCoreReplicationMetrics(engine: MetricsEngine): void {
|
|
|
42
47
|
rows_replicated_total.add(0);
|
|
43
48
|
transactions_replicated_total.add(0);
|
|
44
49
|
chunks_replicated_total.add(0);
|
|
50
|
+
// REPLICATION_LAG_SECONDS is not explicitly initialized - the value remains "unknown" until the first value
|
|
51
|
+
// is reported.
|
|
45
52
|
}
|
|
@@ -81,7 +81,7 @@ export class RouterEngine {
|
|
|
81
81
|
logger.info('Starting Router Engine...');
|
|
82
82
|
|
|
83
83
|
if (!this.hasRoutes) {
|
|
84
|
-
logger.
|
|
84
|
+
logger.info('Router Engine will not start an HTTP server as no routes have been registered.');
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
|
package/src/routes/auth.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as auth from '../auth/auth-index.js';
|
|
|
4
4
|
import { ServiceContext } from '../system/ServiceContext.js';
|
|
5
5
|
import * as util from '../util/util-index.js';
|
|
6
6
|
import { BasicRouterRequest, Context, RequestEndpointHandlerPayload } from './router.js';
|
|
7
|
+
import { AuthorizationError, AuthorizationResponse, ErrorCode, ServiceError } from '@powersync/lib-services-framework';
|
|
7
8
|
|
|
8
9
|
export function endpoint(req: BasicRouterRequest) {
|
|
9
10
|
const protocol = req.headers['x-forwarded-proto'] ?? req.protocol;
|
|
@@ -95,25 +96,25 @@ export function getTokenFromHeader(authHeader: string = ''): string | null {
|
|
|
95
96
|
return token ?? null;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
export const authUser = async (payload: RequestEndpointHandlerPayload) => {
|
|
99
|
+
export const authUser = async (payload: RequestEndpointHandlerPayload): Promise<AuthorizationResponse> => {
|
|
99
100
|
return authorizeUser(payload.context, payload.request.headers.authorization as string);
|
|
100
101
|
};
|
|
101
102
|
|
|
102
|
-
export async function authorizeUser(context: Context, authHeader: string = '') {
|
|
103
|
+
export async function authorizeUser(context: Context, authHeader: string = ''): Promise<AuthorizationResponse> {
|
|
103
104
|
const token = getTokenFromHeader(authHeader);
|
|
104
105
|
if (token == null) {
|
|
105
106
|
return {
|
|
106
107
|
authorized: false,
|
|
107
|
-
|
|
108
|
+
error: new AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required')
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
const { context: tokenContext,
|
|
112
|
+
const { context: tokenContext, tokenError } = await generateContext(context.service_context, token);
|
|
112
113
|
|
|
113
114
|
if (!tokenContext) {
|
|
114
115
|
return {
|
|
115
116
|
authorized: false,
|
|
116
|
-
|
|
117
|
+
error: tokenError
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -140,7 +141,7 @@ export async function generateContext(serviceContext: ServiceContext, token: str
|
|
|
140
141
|
} catch (err) {
|
|
141
142
|
return {
|
|
142
143
|
context: null,
|
|
143
|
-
|
|
144
|
+
tokenError: auth.mapAuthError(err, token)
|
|
144
145
|
};
|
|
145
146
|
}
|
|
146
147
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type fastify from 'fastify';
|
|
2
|
+
import * as uuid from 'uuid';
|
|
3
|
+
|
|
2
4
|
import { registerFastifyRoutes } from './route-register.js';
|
|
3
5
|
|
|
4
6
|
import * as system from '../system/system-index.js';
|
|
@@ -9,7 +11,7 @@ import { PROBES_ROUTES } from './endpoints/probes.js';
|
|
|
9
11
|
import { SYNC_RULES_ROUTES } from './endpoints/sync-rules.js';
|
|
10
12
|
import { SYNC_STREAM_ROUTES } from './endpoints/sync-stream.js';
|
|
11
13
|
import { createRequestQueueHook, CreateRequestQueueParams } from './hooks.js';
|
|
12
|
-
import { RouteDefinition } from './router.js';
|
|
14
|
+
import { ContextProvider, RouteDefinition } from './router.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* A list of route definitions to be registered as endpoints.
|
|
@@ -58,10 +60,11 @@ export const DEFAULT_ROUTE_OPTIONS = {
|
|
|
58
60
|
export function configureFastifyServer(server: fastify.FastifyInstance, options: FastifyServerConfig) {
|
|
59
61
|
const { service_context, routes = DEFAULT_ROUTE_OPTIONS } = options;
|
|
60
62
|
|
|
61
|
-
const generateContext = async () => {
|
|
63
|
+
const generateContext: ContextProvider = async (request, options) => {
|
|
62
64
|
return {
|
|
63
65
|
user_id: undefined,
|
|
64
|
-
service_context: service_context
|
|
66
|
+
service_context: service_context,
|
|
67
|
+
logger: options.logger
|
|
65
68
|
};
|
|
66
69
|
};
|
|
67
70
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { deserialize } from 'bson';
|
|
2
2
|
import * as http from 'http';
|
|
3
|
+
import * as uuid from 'uuid';
|
|
3
4
|
|
|
4
|
-
import { errors, logger } from '@powersync/lib-services-framework';
|
|
5
|
-
import { ReactiveSocketRouter, RSocketRequestMeta } from '@powersync/service-rsocket-router';
|
|
5
|
+
import { ErrorCode, errors, logger } from '@powersync/lib-services-framework';
|
|
6
|
+
import { ReactiveSocketRouter, RSocketRequestMeta, TypedBuffer } from '@powersync/service-rsocket-router';
|
|
6
7
|
|
|
7
8
|
import { ServiceContext } from '../system/ServiceContext.js';
|
|
8
9
|
import { generateContext, getTokenFromHeader } from './auth.js';
|
|
@@ -22,40 +23,58 @@ export function configureRSocket(router: ReactiveSocketRouter<Context>, options:
|
|
|
22
23
|
const { route_generators = DEFAULT_SOCKET_ROUTES, server, service_context } = options;
|
|
23
24
|
|
|
24
25
|
router.applyWebSocketEndpoints(server, {
|
|
25
|
-
contextProvider: async (data:
|
|
26
|
-
const
|
|
26
|
+
contextProvider: async (data: TypedBuffer): Promise<Context & { token: string }> => {
|
|
27
|
+
const connectionLogger = logger.child({
|
|
28
|
+
// timestamp-based uuid - useful for requests
|
|
29
|
+
rid: `s/${uuid.v7()}`
|
|
30
|
+
});
|
|
31
|
+
const { token, user_agent } = RSocketContextMeta.decode(decodeTyped(data) as any);
|
|
27
32
|
|
|
28
33
|
if (!token) {
|
|
29
|
-
throw new errors.AuthorizationError('No token provided');
|
|
34
|
+
throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'No token provided');
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
try {
|
|
33
38
|
const extracted_token = getTokenFromHeader(token);
|
|
34
39
|
if (extracted_token != null) {
|
|
35
|
-
const { context,
|
|
40
|
+
const { context, tokenError } = await generateContext(options.service_context, extracted_token);
|
|
36
41
|
if (context?.token_payload == null) {
|
|
37
|
-
throw new errors.AuthorizationError(
|
|
42
|
+
throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required');
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
return {
|
|
41
46
|
token,
|
|
42
47
|
user_agent,
|
|
43
48
|
...context,
|
|
44
|
-
|
|
45
|
-
service_context: service_context as RouterServiceContext
|
|
49
|
+
token_error: tokenError,
|
|
50
|
+
service_context: service_context as RouterServiceContext,
|
|
51
|
+
logger: connectionLogger
|
|
46
52
|
};
|
|
47
53
|
} else {
|
|
48
|
-
|
|
54
|
+
// Token field is present, but did not contain a token.
|
|
55
|
+
throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'No valid token provided');
|
|
49
56
|
}
|
|
50
57
|
} catch (ex) {
|
|
51
|
-
|
|
58
|
+
connectionLogger.error(ex);
|
|
52
59
|
throw ex;
|
|
53
60
|
}
|
|
54
61
|
},
|
|
55
62
|
endpoints: route_generators.map((generator) => generator(router)),
|
|
56
|
-
metaDecoder: async (meta:
|
|
57
|
-
return RSocketRequestMeta.decode(
|
|
63
|
+
metaDecoder: async (meta: TypedBuffer) => {
|
|
64
|
+
return RSocketRequestMeta.decode(decodeTyped(meta) as any);
|
|
58
65
|
},
|
|
59
|
-
payloadDecoder: async (rawData?:
|
|
66
|
+
payloadDecoder: async (rawData?: TypedBuffer) => rawData && decodeTyped(rawData)
|
|
60
67
|
});
|
|
61
68
|
}
|
|
69
|
+
|
|
70
|
+
function decodeTyped(data: TypedBuffer) {
|
|
71
|
+
switch (data.mimeType) {
|
|
72
|
+
case 'application/json':
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
return JSON.parse(decoder.decode(data.contents));
|
|
75
|
+
case 'application/bson':
|
|
76
|
+
return deserialize(data.contents);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new errors.UnsupportedMediaType(`Expected JSON or BSON request, got ${data.mimeType}`);
|
|
80
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ErrorCode, errors,
|
|
1
|
+
import { ErrorCode, errors, schema } from '@powersync/lib-services-framework';
|
|
2
2
|
import { RequestParameters } from '@powersync/service-sync-rules';
|
|
3
3
|
import { serialize } from 'bson';
|
|
4
4
|
|
|
@@ -13,12 +13,26 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
13
13
|
router.reactiveStream<util.StreamingSyncRequest, any>(SyncRoutes.STREAM, {
|
|
14
14
|
validator: schema.createTsCodecValidator(util.StreamingSyncRequest, { allowAdditional: true }),
|
|
15
15
|
handler: async ({ context, params, responder, observer, initialN, signal: upstreamSignal }) => {
|
|
16
|
-
const { service_context } = context;
|
|
16
|
+
const { service_context, logger } = context;
|
|
17
17
|
const { routerEngine, metricsEngine, syncContext } = service_context;
|
|
18
18
|
|
|
19
|
+
logger.defaultMeta = {
|
|
20
|
+
...logger.defaultMeta,
|
|
21
|
+
user_id: context.token_payload?.sub,
|
|
22
|
+
client_id: params.client_id,
|
|
23
|
+
user_agent: context.user_agent
|
|
24
|
+
};
|
|
25
|
+
const streamStart = Date.now();
|
|
26
|
+
|
|
27
|
+
// Best effort guess on why the stream was closed.
|
|
28
|
+
// We use the `??=` operator everywhere, so that we catch the first relevant
|
|
29
|
+
// event, which is usually the most specific.
|
|
30
|
+
let closeReason: string | undefined = undefined;
|
|
31
|
+
|
|
19
32
|
// Create our own controller that we can abort directly
|
|
20
33
|
const controller = new AbortController();
|
|
21
34
|
upstreamSignal.addEventListener('abort', () => {
|
|
35
|
+
closeReason ??= 'client closing stream';
|
|
22
36
|
controller.abort();
|
|
23
37
|
});
|
|
24
38
|
if (upstreamSignal.aborted) {
|
|
@@ -67,6 +81,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
67
81
|
const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
|
|
68
82
|
|
|
69
83
|
const removeStopHandler = routerEngine.addStopHandler(() => {
|
|
84
|
+
closeReason ??= 'process shutdown';
|
|
70
85
|
controller.abort();
|
|
71
86
|
});
|
|
72
87
|
|
|
@@ -88,7 +103,8 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
88
103
|
keep_alive: false
|
|
89
104
|
},
|
|
90
105
|
tracker,
|
|
91
|
-
signal
|
|
106
|
+
signal,
|
|
107
|
+
logger
|
|
92
108
|
})) {
|
|
93
109
|
if (signal.aborted) {
|
|
94
110
|
break;
|
|
@@ -131,22 +147,22 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
131
147
|
});
|
|
132
148
|
}
|
|
133
149
|
}
|
|
150
|
+
closeReason ??= 'service closing stream';
|
|
134
151
|
} catch (ex) {
|
|
135
152
|
// Convert to our standard form before responding.
|
|
136
153
|
// This ensures the error can be serialized.
|
|
137
154
|
const error = new errors.InternalServerError(ex);
|
|
138
155
|
logger.error('Sync stream error', error);
|
|
156
|
+
closeReason ??= 'stream error';
|
|
139
157
|
responder.onError(error);
|
|
140
158
|
} finally {
|
|
141
159
|
responder.onComplete();
|
|
142
160
|
removeStopHandler();
|
|
143
161
|
disposer();
|
|
144
162
|
logger.info(`Sync stream complete`, {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
operations_synced: tracker.operationsSynced,
|
|
149
|
-
data_synced_bytes: tracker.dataSyncedBytes
|
|
163
|
+
...tracker.getLogMeta(),
|
|
164
|
+
stream_ms: Date.now() - streamStart,
|
|
165
|
+
close_reason: closeReason ?? 'unknown'
|
|
150
166
|
});
|
|
151
167
|
metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
|
|
152
168
|
}
|
|
@@ -202,13 +202,13 @@ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
|
|
|
202
202
|
|
|
203
203
|
return {
|
|
204
204
|
valid: true,
|
|
205
|
-
bucket_definitions: rules.
|
|
206
|
-
let all_parameter_queries = [...d.
|
|
207
|
-
let all_data_queries = [...d.
|
|
205
|
+
bucket_definitions: rules.bucketDescriptors.map((d) => {
|
|
206
|
+
let all_parameter_queries = [...d.parameterQueries.values()].flat();
|
|
207
|
+
let all_data_queries = [...d.dataQueries.values()].flat();
|
|
208
208
|
return {
|
|
209
209
|
name: d.name,
|
|
210
|
-
bucket_parameters: d.
|
|
211
|
-
global_parameter_queries: d.
|
|
210
|
+
bucket_parameters: d.bucketParameters,
|
|
211
|
+
global_parameter_queries: d.globalParameterQueries.map((q) => {
|
|
212
212
|
return {
|
|
213
213
|
sql: q.sql
|
|
214
214
|
};
|
|
@@ -217,7 +217,7 @@ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
|
|
|
217
217
|
return {
|
|
218
218
|
sql: q.sql,
|
|
219
219
|
table: q.sourceTable,
|
|
220
|
-
input_parameters: q.
|
|
220
|
+
input_parameters: q.inputParameters
|
|
221
221
|
};
|
|
222
222
|
}),
|
|
223
223
|
|