@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.
Files changed (126) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/dist/api/RouteAPI.d.ts +1 -5
  3. package/dist/api/diagnostics.js +1 -1
  4. package/dist/api/diagnostics.js.map +1 -1
  5. package/dist/auth/CachedKeyCollector.js +2 -7
  6. package/dist/auth/CachedKeyCollector.js.map +1 -1
  7. package/dist/auth/CompoundKeyCollector.js.map +1 -1
  8. package/dist/auth/KeyCollector.d.ts +2 -2
  9. package/dist/auth/KeyStore.js +32 -14
  10. package/dist/auth/KeyStore.js.map +1 -1
  11. package/dist/auth/RemoteJWKSCollector.d.ts +1 -0
  12. package/dist/auth/RemoteJWKSCollector.js +39 -16
  13. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  14. package/dist/auth/auth-index.d.ts +1 -0
  15. package/dist/auth/auth-index.js +1 -0
  16. package/dist/auth/auth-index.js.map +1 -1
  17. package/dist/auth/utils.d.ts +6 -0
  18. package/dist/auth/utils.js +97 -0
  19. package/dist/auth/utils.js.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.d.ts +1 -1
  24. package/dist/metrics/open-telemetry/OpenTelemetryMetricsFactory.js.map +1 -1
  25. package/dist/replication/AbstractReplicationJob.d.ts +4 -0
  26. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  27. package/dist/replication/AbstractReplicator.d.ts +23 -0
  28. package/dist/replication/AbstractReplicator.js +45 -0
  29. package/dist/replication/AbstractReplicator.js.map +1 -1
  30. package/dist/replication/RelationCache.d.ts +9 -0
  31. package/dist/replication/RelationCache.js +20 -0
  32. package/dist/replication/RelationCache.js.map +1 -0
  33. package/dist/replication/replication-index.d.ts +1 -0
  34. package/dist/replication/replication-index.js +1 -0
  35. package/dist/replication/replication-index.js.map +1 -1
  36. package/dist/replication/replication-metrics.js +6 -0
  37. package/dist/replication/replication-metrics.js.map +1 -1
  38. package/dist/routes/RouterEngine.js +1 -1
  39. package/dist/routes/RouterEngine.js.map +1 -1
  40. package/dist/routes/auth.d.ts +5 -16
  41. package/dist/routes/auth.js +6 -4
  42. package/dist/routes/auth.js.map +1 -1
  43. package/dist/routes/configure-fastify.d.ts +3 -21
  44. package/dist/routes/configure-fastify.js +3 -2
  45. package/dist/routes/configure-fastify.js.map +1 -1
  46. package/dist/routes/configure-rsocket.js +28 -11
  47. package/dist/routes/configure-rsocket.js.map +1 -1
  48. package/dist/routes/endpoints/admin.js +2 -0
  49. package/dist/routes/endpoints/admin.js.map +1 -1
  50. package/dist/routes/endpoints/checkpointing.d.ts +4 -28
  51. package/dist/routes/endpoints/socket-route.js +22 -8
  52. package/dist/routes/endpoints/socket-route.js.map +1 -1
  53. package/dist/routes/endpoints/sync-rules.js +6 -6
  54. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  55. package/dist/routes/endpoints/sync-stream.d.ts +2 -14
  56. package/dist/routes/endpoints/sync-stream.js +28 -9
  57. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  58. package/dist/routes/route-register.js +10 -6
  59. package/dist/routes/route-register.js.map +1 -1
  60. package/dist/routes/router.d.ts +7 -3
  61. package/dist/routes/router.js.map +1 -1
  62. package/dist/storage/BucketStorageBatch.d.ts +17 -1
  63. package/dist/storage/BucketStorageBatch.js +2 -1
  64. package/dist/storage/BucketStorageBatch.js.map +1 -1
  65. package/dist/storage/PersistedSyncRulesContent.d.ts +5 -0
  66. package/dist/storage/SourceTable.d.ts +17 -1
  67. package/dist/storage/SourceTable.js +28 -0
  68. package/dist/storage/SourceTable.js.map +1 -1
  69. package/dist/storage/SyncRulesBucketStorage.d.ts +11 -2
  70. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  71. package/dist/storage/bson.js +4 -1
  72. package/dist/storage/bson.js.map +1 -1
  73. package/dist/sync/BucketChecksumState.d.ts +40 -18
  74. package/dist/sync/BucketChecksumState.js +122 -74
  75. package/dist/sync/BucketChecksumState.js.map +1 -1
  76. package/dist/sync/RequestTracker.d.ts +22 -1
  77. package/dist/sync/RequestTracker.js +51 -2
  78. package/dist/sync/RequestTracker.js.map +1 -1
  79. package/dist/sync/sync.d.ts +3 -5
  80. package/dist/sync/sync.js +49 -34
  81. package/dist/sync/sync.js.map +1 -1
  82. package/dist/util/config/collectors/config-collector.js +2 -5
  83. package/dist/util/config/collectors/config-collector.js.map +1 -1
  84. package/dist/util/protocol-types.d.ts +9 -9
  85. package/dist/util/protocol-types.js.map +1 -1
  86. package/dist/util/utils.d.ts +1 -1
  87. package/package.json +6 -7
  88. package/src/api/RouteAPI.ts +1 -6
  89. package/src/api/diagnostics.ts +1 -1
  90. package/src/auth/CachedKeyCollector.ts +4 -6
  91. package/src/auth/CompoundKeyCollector.ts +2 -1
  92. package/src/auth/KeyCollector.ts +2 -2
  93. package/src/auth/KeyStore.ts +45 -20
  94. package/src/auth/RemoteJWKSCollector.ts +39 -16
  95. package/src/auth/auth-index.ts +1 -0
  96. package/src/auth/utils.ts +102 -0
  97. package/src/index.ts +2 -0
  98. package/src/metrics/open-telemetry/OpenTelemetryMetricsFactory.ts +3 -3
  99. package/src/replication/AbstractReplicationJob.ts +5 -0
  100. package/src/replication/AbstractReplicator.ts +47 -0
  101. package/src/replication/RelationCache.ts +25 -0
  102. package/src/replication/replication-index.ts +1 -0
  103. package/src/replication/replication-metrics.ts +7 -0
  104. package/src/routes/RouterEngine.ts +1 -1
  105. package/src/routes/auth.ts +7 -6
  106. package/src/routes/configure-fastify.ts +6 -3
  107. package/src/routes/configure-rsocket.ts +33 -14
  108. package/src/routes/endpoints/admin.ts +2 -0
  109. package/src/routes/endpoints/socket-route.ts +24 -8
  110. package/src/routes/endpoints/sync-rules.ts +6 -6
  111. package/src/routes/endpoints/sync-stream.ts +31 -8
  112. package/src/routes/route-register.ts +10 -7
  113. package/src/routes/router.ts +9 -3
  114. package/src/storage/BucketStorageBatch.ts +22 -2
  115. package/src/storage/PersistedSyncRulesContent.ts +6 -0
  116. package/src/storage/SourceTable.ts +44 -1
  117. package/src/storage/SyncRulesBucketStorage.ts +14 -2
  118. package/src/storage/bson.ts +4 -1
  119. package/src/sync/BucketChecksumState.ts +162 -77
  120. package/src/sync/RequestTracker.ts +70 -3
  121. package/src/sync/sync.ts +72 -49
  122. package/src/util/config/collectors/config-collector.ts +3 -7
  123. package/src/util/protocol-types.ts +15 -10
  124. package/test/src/auth.test.ts +29 -11
  125. package/test/src/sync/BucketChecksumState.test.ts +32 -18
  126. 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 getKeys(): Promise<KeyResult> {
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
- const res = await fetch(this.url, {
56
- method: 'GET',
57
- headers: {
58
- Accept: 'application/json'
59
- },
60
- signal: abortController.signal,
61
- agent: this.agent
62
- });
63
-
64
- if (!res.ok) {
65
- throw new jose.errors.JWKSInvalid(`JWKS request failed with ${res.statusText}`);
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
- const data = (await res.json()) as any;
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
- clearTimeout(timeout);
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 { keys: [], errors: [new jose.errors.JWKSInvalid(`No keys in found in JWKS response`)] };
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[] = [];
@@ -8,3 +8,4 @@ export * from './LeakyBucket.js';
8
8
  export * from './RemoteJWKSCollector.js';
9
9
  export * from './StaticKeyCollector.js';
10
10
  export * from './StaticSupabaseKeyCollector.js';
11
+ export * from './utils.js';
@@ -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
@@ -41,3 +41,5 @@ export * as utils from './util/util-index.js';
41
41
 
42
42
  export * from './streams/streams-index.js';
43
43
  export * as streams from './streams/streams-index.js';
44
+
45
+ export * as bson from 'bson';
@@ -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
- Precision
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
+ }
@@ -4,3 +4,4 @@ export * from './ErrorRateLimiter.js';
4
4
  export * from './ReplicationEngine.js';
5
5
  export * from './ReplicationModule.js';
6
6
  export * from './replication-metrics.js';
7
+ export * from './RelationCache.js';
@@ -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.warn('No routes have been registered. Router Engine will not continue.');
84
+ logger.info('Router Engine will not start an HTTP server as no routes have been registered.');
85
85
  return;
86
86
  }
87
87
 
@@ -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
- errors: ['Authentication required']
108
+ error: new AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required')
108
109
  };
109
110
  }
110
111
 
111
- const { context: tokenContext, errors } = await generateContext(context.service_context, token);
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
- errors
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
- errors: [err.message]
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: Buffer) => {
26
- const { token, user_agent } = RSocketContextMeta.decode(deserialize(data) as any);
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, errors: token_errors } = await generateContext(options.service_context, extracted_token);
40
+ const { context, tokenError } = await generateContext(options.service_context, extracted_token);
36
41
  if (context?.token_payload == null) {
37
- throw new errors.AuthorizationError(token_errors ?? 'Authentication required');
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
- token_errors: token_errors,
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
- throw new errors.AuthorizationError('No token provided');
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
- logger.error(ex);
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: Buffer) => {
57
- return RSocketRequestMeta.decode(deserialize(meta) as any);
63
+ metaDecoder: async (meta: TypedBuffer) => {
64
+ return RSocketRequestMeta.decode(decodeTyped(meta) as any);
58
65
  },
59
- payloadDecoder: async (rawData?: Buffer) => rawData && deserialize(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
+ }
@@ -168,6 +168,8 @@ export const validate = routeDefinition({
168
168
  // Dummy values
169
169
  id: 0,
170
170
  slot_name: '',
171
+ active: false,
172
+ last_checkpoint_lsn: '',
171
173
 
172
174
  parsed() {
173
175
  return {
@@ -1,4 +1,4 @@
1
- import { ErrorCode, errors, logger, schema } from '@powersync/lib-services-framework';
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
- user_id: syncParams.user_id,
146
- client_id: params.client_id,
147
- user_agent: context.user_agent,
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.bucket_descriptors.map((d) => {
206
- let all_parameter_queries = [...d.parameter_queries.values()].flat();
207
- let all_data_queries = [...d.data_queries.values()].flat();
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.bucket_parameters,
211
- global_parameter_queries: d.global_parameter_queries.map((q) => {
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.input_parameters
220
+ input_parameters: q.inputParameters
221
221
  };
222
222
  }),
223
223