@powersync/service-core 1.11.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/auth/CachedKeyCollector.js +2 -7
  3. package/dist/auth/CachedKeyCollector.js.map +1 -1
  4. package/dist/auth/CompoundKeyCollector.js.map +1 -1
  5. package/dist/auth/KeyCollector.d.ts +2 -2
  6. package/dist/auth/KeyStore.js +32 -14
  7. package/dist/auth/KeyStore.js.map +1 -1
  8. package/dist/auth/RemoteJWKSCollector.d.ts +1 -0
  9. package/dist/auth/RemoteJWKSCollector.js +39 -16
  10. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  11. package/dist/auth/auth-index.d.ts +1 -0
  12. package/dist/auth/auth-index.js +1 -0
  13. package/dist/auth/auth-index.js.map +1 -1
  14. package/dist/auth/utils.d.ts +6 -0
  15. package/dist/auth/utils.js +97 -0
  16. package/dist/auth/utils.js.map +1 -0
  17. package/dist/entry/commands/compact-action.js +4 -1
  18. package/dist/entry/commands/compact-action.js.map +1 -1
  19. package/dist/entry/commands/migrate-action.js +4 -1
  20. package/dist/entry/commands/migrate-action.js.map +1 -1
  21. package/dist/entry/commands/test-connection-action.js +4 -1
  22. package/dist/entry/commands/test-connection-action.js.map +1 -1
  23. package/dist/routes/RouterEngine.d.ts +2 -0
  24. package/dist/routes/RouterEngine.js +15 -10
  25. package/dist/routes/RouterEngine.js.map +1 -1
  26. package/dist/routes/auth.d.ts +5 -16
  27. package/dist/routes/auth.js +6 -4
  28. package/dist/routes/auth.js.map +1 -1
  29. package/dist/routes/configure-fastify.d.ts +3 -21
  30. package/dist/routes/configure-fastify.js +3 -6
  31. package/dist/routes/configure-fastify.js.map +1 -1
  32. package/dist/routes/configure-rsocket.js +28 -14
  33. package/dist/routes/configure-rsocket.js.map +1 -1
  34. package/dist/routes/endpoints/admin.js.map +1 -1
  35. package/dist/routes/endpoints/checkpointing.d.ts +4 -28
  36. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  37. package/dist/routes/endpoints/route-endpoints-index.d.ts +1 -0
  38. package/dist/routes/endpoints/route-endpoints-index.js +1 -0
  39. package/dist/routes/endpoints/route-endpoints-index.js.map +1 -1
  40. package/dist/routes/endpoints/socket-route.js +22 -8
  41. package/dist/routes/endpoints/socket-route.js.map +1 -1
  42. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  43. package/dist/routes/endpoints/sync-stream.d.ts +2 -14
  44. package/dist/routes/endpoints/sync-stream.js +28 -9
  45. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  46. package/dist/routes/route-register.js +10 -6
  47. package/dist/routes/route-register.js.map +1 -1
  48. package/dist/routes/router.d.ts +8 -7
  49. package/dist/routes/router.js.map +1 -1
  50. package/dist/runner/teardown.js +4 -1
  51. package/dist/runner/teardown.js.map +1 -1
  52. package/dist/sync/BucketChecksumState.d.ts +40 -18
  53. package/dist/sync/BucketChecksumState.js +120 -72
  54. package/dist/sync/BucketChecksumState.js.map +1 -1
  55. package/dist/sync/RequestTracker.d.ts +22 -1
  56. package/dist/sync/RequestTracker.js +51 -2
  57. package/dist/sync/RequestTracker.js.map +1 -1
  58. package/dist/sync/sync.d.ts +3 -5
  59. package/dist/sync/sync.js +47 -32
  60. package/dist/sync/sync.js.map +1 -1
  61. package/dist/system/ServiceContext.d.ts +19 -4
  62. package/dist/system/ServiceContext.js +20 -8
  63. package/dist/system/ServiceContext.js.map +1 -1
  64. package/dist/util/config/collectors/config-collector.js +4 -33
  65. package/dist/util/config/collectors/config-collector.js.map +1 -1
  66. package/dist/util/config/collectors/impl/yaml-env.d.ts +7 -0
  67. package/dist/util/config/collectors/impl/yaml-env.js +59 -0
  68. package/dist/util/config/collectors/impl/yaml-env.js.map +1 -0
  69. package/dist/util/config/compound-config-collector.js +18 -1
  70. package/dist/util/config/compound-config-collector.js.map +1 -1
  71. package/dist/util/config/types.d.ts +11 -0
  72. package/package.json +6 -7
  73. package/src/auth/CachedKeyCollector.ts +4 -6
  74. package/src/auth/CompoundKeyCollector.ts +2 -1
  75. package/src/auth/KeyCollector.ts +2 -2
  76. package/src/auth/KeyStore.ts +45 -20
  77. package/src/auth/RemoteJWKSCollector.ts +39 -16
  78. package/src/auth/auth-index.ts +1 -0
  79. package/src/auth/utils.ts +102 -0
  80. package/src/entry/commands/compact-action.ts +4 -1
  81. package/src/entry/commands/migrate-action.ts +4 -1
  82. package/src/entry/commands/test-connection-action.ts +4 -1
  83. package/src/routes/RouterEngine.ts +21 -11
  84. package/src/routes/auth.ts +7 -6
  85. package/src/routes/configure-fastify.ts +6 -8
  86. package/src/routes/configure-rsocket.ts +33 -18
  87. package/src/routes/endpoints/admin.ts +5 -5
  88. package/src/routes/endpoints/checkpointing.ts +2 -2
  89. package/src/routes/endpoints/route-endpoints-index.ts +1 -0
  90. package/src/routes/endpoints/socket-route.ts +27 -11
  91. package/src/routes/endpoints/sync-rules.ts +4 -4
  92. package/src/routes/endpoints/sync-stream.ts +34 -11
  93. package/src/routes/route-register.ts +10 -7
  94. package/src/routes/router.ts +11 -4
  95. package/src/runner/teardown.ts +5 -1
  96. package/src/sync/BucketChecksumState.ts +160 -75
  97. package/src/sync/RequestTracker.ts +70 -3
  98. package/src/sync/sync.ts +69 -46
  99. package/src/system/ServiceContext.ts +31 -12
  100. package/src/util/config/collectors/config-collector.ts +4 -40
  101. package/src/util/config/collectors/impl/yaml-env.ts +67 -0
  102. package/src/util/config/compound-config-collector.ts +22 -5
  103. package/src/util/config/types.ts +13 -0
  104. package/test/src/auth.test.ts +29 -11
  105. package/test/src/config.test.ts +72 -0
  106. package/test/src/sync/BucketChecksumState.test.ts +32 -18
  107. package/tsconfig.tsbuildinfo +1 -1
@@ -2,15 +2,8 @@ import { logger } from '@powersync/lib-services-framework';
2
2
 
3
3
  import * as api from '../api/api-index.js';
4
4
 
5
- import { ADMIN_ROUTES } from './endpoints/admin.js';
6
- import { CHECKPOINT_ROUTES } from './endpoints/checkpointing.js';
7
- import { PROBES_ROUTES } from './endpoints/probes.js';
8
- import { syncStreamReactive } from './endpoints/socket-route.js';
9
- import { SYNC_RULES_ROUTES } from './endpoints/sync-rules.js';
10
- import { SYNC_STREAM_ROUTES } from './endpoints/sync-stream.js';
11
5
  import { SocketRouteGenerator } from './router-socket.js';
12
6
  import { RouteDefinition } from './router.js';
13
- import { SyncContext } from '../sync/SyncContext.js';
14
7
 
15
8
  export type RouterSetupResponse = {
16
9
  onShutdown: () => Promise<void>;
@@ -47,14 +40,25 @@ export class RouterEngine {
47
40
  this.cleanupHandler = null;
48
41
  this.closed = false;
49
42
 
50
- // Default routes
51
43
  this.routes = {
52
- api_routes: [...ADMIN_ROUTES, ...CHECKPOINT_ROUTES, ...SYNC_RULES_ROUTES, ...PROBES_ROUTES],
53
- stream_routes: [...SYNC_STREAM_ROUTES],
54
- socket_routes: [syncStreamReactive]
44
+ api_routes: [],
45
+ stream_routes: [],
46
+ socket_routes: []
55
47
  };
56
48
  }
57
49
 
50
+ public registerRoutes(routes: Partial<RouterEngineRoutes>) {
51
+ this.routes.api_routes.push(...(routes.api_routes ?? []));
52
+ this.routes.stream_routes.push(...(routes.stream_routes ?? []));
53
+ this.routes.socket_routes.push(...(routes.socket_routes ?? []));
54
+ }
55
+
56
+ public get hasRoutes() {
57
+ return (
58
+ this.routes.api_routes.length > 0 || this.routes.stream_routes.length > 0 || this.routes.socket_routes.length > 0
59
+ );
60
+ }
61
+
58
62
  public registerAPI(api: api.RouteAPI) {
59
63
  if (this.api) {
60
64
  logger.warn('A RouteAPI has already been registered. Overriding existing implementation');
@@ -75,6 +79,12 @@ export class RouterEngine {
75
79
  */
76
80
  async start(setup: RouterSetup) {
77
81
  logger.info('Starting Router Engine...');
82
+
83
+ if (!this.hasRoutes) {
84
+ logger.info('Router Engine will not start an HTTP server as no routes have been registered.');
85
+ return;
86
+ }
87
+
78
88
  const { onShutdown } = await setup(this.routes);
79
89
  this.cleanupHandler = onShutdown;
80
90
  logger.info('Successfully started Router Engine.');
@@ -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, RouterServiceContext } 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,15 +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 () => {
62
- const { routerEngine } = service_context;
63
- if (!routerEngine) {
64
- throw new Error(`RouterEngine has not been registered`);
65
- }
66
-
63
+ const generateContext: ContextProvider = async (request, options) => {
67
64
  return {
68
65
  user_id: undefined,
69
- service_context: service_context as RouterServiceContext
66
+ service_context: service_context,
67
+ logger: options.logger
70
68
  };
71
69
  };
72
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,44 +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');
38
- }
39
-
40
- if (!service_context.routerEngine) {
41
- throw new Error(`RouterEngine has not been registered`);
42
+ throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required');
42
43
  }
43
44
 
44
45
  return {
45
46
  token,
46
47
  user_agent,
47
48
  ...context,
48
- token_errors: token_errors,
49
- service_context: service_context as RouterServiceContext
49
+ token_error: tokenError,
50
+ service_context: service_context as RouterServiceContext,
51
+ logger: connectionLogger
50
52
  };
51
53
  } else {
52
- 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');
53
56
  }
54
57
  } catch (ex) {
55
- logger.error(ex);
58
+ connectionLogger.error(ex);
56
59
  throw ex;
57
60
  }
58
61
  },
59
62
  endpoints: route_generators.map((generator) => generator(router)),
60
- metaDecoder: async (meta: Buffer) => {
61
- return RSocketRequestMeta.decode(deserialize(meta) as any);
63
+ metaDecoder: async (meta: TypedBuffer) => {
64
+ return RSocketRequestMeta.decode(decodeTyped(meta) as any);
62
65
  },
63
- payloadDecoder: async (rawData?: Buffer) => rawData && deserialize(rawData)
66
+ payloadDecoder: async (rawData?: TypedBuffer) => rawData && decodeTyped(rawData)
64
67
  });
65
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
+ }
@@ -19,7 +19,7 @@ export const executeSql = routeDefinition({
19
19
  }
20
20
  } = payload;
21
21
 
22
- const apiHandler = payload.context.service_context.routerEngine!.getAPI();
22
+ const apiHandler = payload.context.service_context.routerEngine.getAPI();
23
23
 
24
24
  const sourceConfig = await apiHandler.getSourceConfig();
25
25
  if (!sourceConfig.debug_api) {
@@ -47,7 +47,7 @@ export const diagnostics = routeDefinition({
47
47
  const { service_context } = context;
48
48
  const include_content = payload.params.sync_rules_content ?? false;
49
49
 
50
- const apiHandler = service_context.routerEngine!.getAPI();
50
+ const apiHandler = service_context.routerEngine.getAPI();
51
51
 
52
52
  const status = await apiHandler.getConnectionStatus();
53
53
  if (!status) {
@@ -94,7 +94,7 @@ export const getSchema = routeDefinition({
94
94
  authorize: authApi,
95
95
  validator: schema.createTsCodecValidator(internal_routes.GetSchemaRequest, { allowAdditional: true }),
96
96
  handler: async (payload) => {
97
- const apiHandler = payload.context.service_context.routerEngine!.getAPI();
97
+ const apiHandler = payload.context.service_context.routerEngine.getAPI();
98
98
 
99
99
  return internal_routes.GetSchemaResponse.encode(await api.getConnectionsSchema(apiHandler));
100
100
  }
@@ -112,7 +112,7 @@ export const reprocess = routeDefinition({
112
112
  const {
113
113
  storageEngine: { activeBucketStorage }
114
114
  } = service_context;
115
- const apiHandler = service_context.routerEngine!.getAPI();
115
+ const apiHandler = service_context.routerEngine.getAPI();
116
116
  const next = await activeBucketStorage.getNextSyncRules(apiHandler.getParseSyncRulesOptions());
117
117
  if (next != null) {
118
118
  throw new Error(`Busy processing sync rules - cannot reprocess`);
@@ -159,7 +159,7 @@ export const validate = routeDefinition({
159
159
  context: { service_context }
160
160
  } = payload;
161
161
  const content = payload.params.sync_rules;
162
- const apiHandler = service_context.routerEngine!.getAPI();
162
+ const apiHandler = service_context.routerEngine.getAPI();
163
163
 
164
164
  const schemaData = await api.getConnectionsSchema(apiHandler);
165
165
  const schema = new StaticSchema(schemaData.connections);
@@ -18,7 +18,7 @@ export const writeCheckpoint = routeDefinition({
18
18
  const {
19
19
  context: { service_context }
20
20
  } = payload;
21
- const apiHandler = service_context.routerEngine!.getAPI();
21
+ const apiHandler = service_context.routerEngine.getAPI();
22
22
 
23
23
  // This old API needs a persisted checkpoint id.
24
24
  // Since we don't use LSNs anymore, the only way to get that is to wait.
@@ -54,7 +54,7 @@ export const writeCheckpoint2 = routeDefinition({
54
54
  handler: async (payload) => {
55
55
  const { user_id, service_context } = payload.context;
56
56
 
57
- const apiHandler = service_context.routerEngine!.getAPI();
57
+ const apiHandler = service_context.routerEngine.getAPI();
58
58
 
59
59
  const { replicationHead, writeCheckpoint } = await util.createWriteCheckpoint({
60
60
  userId: user_id,
@@ -1,5 +1,6 @@
1
1
  export * from './admin.js';
2
2
  export * from './checkpointing.js';
3
+ export * from './probes.js';
3
4
  export * from './socket-route.js';
4
5
  export * from './sync-rules.js';
5
6
  export * from './sync-stream.js';
@@ -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) {
@@ -33,7 +47,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
33
47
  }
34
48
  });
35
49
 
36
- if (routerEngine!.closed) {
50
+ if (routerEngine.closed) {
37
51
  responder.onError(
38
52
  new errors.ServiceError({
39
53
  status: 503,
@@ -64,9 +78,10 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
64
78
  return;
65
79
  }
66
80
 
67
- const syncRules = bucketStorage.getParsedSyncRules(routerEngine!.getAPI().getParseSyncRulesOptions());
81
+ const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
68
82
 
69
- const removeStopHandler = routerEngine!.addStopHandler(() => {
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
  }
@@ -53,7 +53,7 @@ export const deploySyncRules = routeDefinition({
53
53
  const content = payload.params.content;
54
54
 
55
55
  try {
56
- const apiHandler = service_context.routerEngine!.getAPI();
56
+ const apiHandler = service_context.routerEngine.getAPI();
57
57
  SqlSyncRules.fromYaml(payload.params.content, {
58
58
  ...apiHandler.getParseSyncRulesOptions(),
59
59
  // We don't do any schema-level validation at this point
@@ -94,7 +94,7 @@ export const validateSyncRules = routeDefinition({
94
94
  handler: async (payload) => {
95
95
  const content = payload.params.content;
96
96
  const { service_context } = payload.context;
97
- const apiHandler = service_context.routerEngine!.getAPI();
97
+ const apiHandler = service_context.routerEngine.getAPI();
98
98
 
99
99
  const info = await debugSyncRules(apiHandler, content);
100
100
 
@@ -121,7 +121,7 @@ export const currentSyncRules = routeDefinition({
121
121
  });
122
122
  }
123
123
 
124
- const apiHandler = service_context.routerEngine!.getAPI();
124
+ const apiHandler = service_context.routerEngine.getAPI();
125
125
  const info = await debugSyncRules(apiHandler, sync_rules.sync_rules_content);
126
126
  const next = await activeBucketStorage.getNextSyncRulesContent();
127
127
 
@@ -158,7 +158,7 @@ export const reprocessSyncRules = routeDefinition({
158
158
  const {
159
159
  storageEngine: { activeBucketStorage }
160
160
  } = payload.context.service_context;
161
- const apiHandler = payload.context.service_context.routerEngine!.getAPI();
161
+ const apiHandler = payload.context.service_context.routerEngine.getAPI();
162
162
  const sync_rules = await activeBucketStorage.getActiveSyncRules(apiHandler.getParseSyncRulesOptions());
163
163
  if (sync_rules == null) {
164
164
  throw new errors.ServiceError({
@@ -20,13 +20,21 @@ export const syncStreamed = routeDefinition({
20
20
  authorize: authUser,
21
21
  validator: schema.createTsCodecValidator(util.StreamingSyncRequest, { allowAdditional: true }),
22
22
  handler: async (payload) => {
23
- const { service_context } = payload.context;
23
+ const { service_context, logger } = payload.context;
24
24
  const { routerEngine, storageEngine, metricsEngine, syncContext } = service_context;
25
25
  const headers = payload.request.headers;
26
26
  const userAgent = headers['x-user-agent'] ?? headers['user-agent'];
27
27
  const clientId = payload.params.client_id;
28
+ const streamStart = Date.now();
28
29
 
29
- if (routerEngine!.closed) {
30
+ logger.defaultMeta = {
31
+ ...logger.defaultMeta,
32
+ user_agent: userAgent,
33
+ client_id: clientId,
34
+ user_id: payload.context.user_id
35
+ };
36
+
37
+ if (routerEngine.closed) {
30
38
  throw new errors.ServiceError({
31
39
  status: 503,
32
40
  code: ErrorCode.PSYNC_S2003,
@@ -47,7 +55,7 @@ export const syncStreamed = routeDefinition({
47
55
  });
48
56
  }
49
57
 
50
- const syncRules = bucketStorage.getParsedSyncRules(routerEngine!.getAPI().getParseSyncRulesOptions());
58
+ const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
51
59
 
52
60
  const controller = new AbortController();
53
61
  const tracker = new sync.RequestTracker(metricsEngine);
@@ -64,7 +72,8 @@ export const syncStreamed = routeDefinition({
64
72
  syncParams,
65
73
  token: payload.context.token_payload!,
66
74
  tracker,
67
- signal: controller.signal
75
+ signal: controller.signal,
76
+ logger
68
77
  })
69
78
  ),
70
79
  tracker
@@ -72,16 +81,29 @@ export const syncStreamed = routeDefinition({
72
81
  { objectMode: false, highWaterMark: 16 * 1024 }
73
82
  );
74
83
 
75
- const deregister = routerEngine!.addStopHandler(() => {
84
+ // Best effort guess on why the stream was closed.
85
+ // We use the `??=` operator everywhere, so that we catch the first relevant
86
+ // event, which is usually the most specific.
87
+ let closeReason: string | undefined = undefined;
88
+
89
+ const deregister = routerEngine.addStopHandler(() => {
76
90
  // This error is not currently propagated to the client
77
91
  controller.abort();
92
+ closeReason ??= 'process shutdown';
78
93
  stream.destroy(new Error('Shutting down system'));
79
94
  });
95
+
96
+ stream.on('end', () => {
97
+ // Auth failure or switch to new sync rules
98
+ closeReason ??= 'service closing stream';
99
+ });
100
+
80
101
  stream.on('close', () => {
81
102
  deregister();
82
103
  });
83
104
 
84
105
  stream.on('error', (error) => {
106
+ closeReason ??= 'stream error';
85
107
  controller.abort();
86
108
  // Note: This appears as a 200 response in the logs.
87
109
  if (error.message != 'Shutting down system') {
@@ -95,15 +117,16 @@ export const syncStreamed = routeDefinition({
95
117
  'Content-Type': 'application/x-ndjson'
96
118
  },
97
119
  data: stream,
98
- afterSend: async () => {
120
+ afterSend: async (details) => {
121
+ if (details.clientClosed) {
122
+ closeReason ??= 'client closing stream';
123
+ }
99
124
  controller.abort();
100
125
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
101
126
  logger.info(`Sync stream complete`, {
102
- user_id: syncParams.user_id,
103
- client_id: clientId,
104
- user_agent: userAgent,
105
- operations_synced: tracker.operationsSynced,
106
- data_synced_bytes: tracker.dataSyncedBytes
127
+ ...tracker.getLogMeta(),
128
+ stream_ms: Date.now() - streamStart,
129
+ close_reason: closeReason ?? 'unknown'
107
130
  });
108
131
  }
109
132
  });
@@ -1,4 +1,5 @@
1
1
  import type fastify from 'fastify';
2
+ import * as uuid from 'uuid';
2
3
 
3
4
  import { errors, HTTPMethod, logger, router } from '@powersync/lib-services-framework';
4
5
  import { Context, ContextProvider, RequestEndpoint, RequestEndpointHandlerPayload } from './router.js';
@@ -25,9 +26,12 @@ export function registerFastifyRoutes(
25
26
  handler: async (request, reply) => {
26
27
  const startTime = new Date();
27
28
  let response: router.RouterResponse;
29
+ const requestLogger = logger.child({
30
+ route: e.path,
31
+ rid: `h/${uuid.v7()}`
32
+ });
28
33
  try {
29
- const context = await contextProvider(request);
30
-
34
+ const context = await contextProvider(request, { logger: requestLogger });
31
35
  let combined = {
32
36
  ...(request.params as any),
33
37
  ...(request.query as any)
@@ -63,7 +67,7 @@ export function registerFastifyRoutes(
63
67
  }
64
68
  } catch (ex) {
65
69
  const serviceError = errors.asServiceError(ex);
66
- logger.error(`Request failed`, serviceError);
70
+ requestLogger.error(`Request failed`, serviceError);
67
71
 
68
72
  response = new router.RouterResponse({
69
73
  status: serviceError.errorData.status || 500,
@@ -83,13 +87,12 @@ export function registerFastifyRoutes(
83
87
  try {
84
88
  await reply.send(response.data);
85
89
  } finally {
86
- await response.afterSend?.();
87
- logger.info(`${e.method} ${request.url}`, {
90
+ await response.afterSend?.({ clientClosed: request.socket.closed });
91
+ requestLogger.info(`${e.method} ${request.url}`, {
88
92
  duration_ms: Math.round(new Date().valueOf() - startTime.valueOf() + Number.EPSILON),
89
93
  status: response.status,
90
94
  method: e.method,
91
- path: request.url,
92
- route: e.path
95
+ path: request.url
93
96
  });
94
97
  }
95
98
  }
@@ -1,4 +1,4 @@
1
- import { router } from '@powersync/lib-services-framework';
1
+ import { router, ServiceError, Logger } from '@powersync/lib-services-framework';
2
2
  import type { JwtPayload } from '../auth/auth-index.js';
3
3
  import { ServiceContext } from '../system/ServiceContext.js';
4
4
  import { RouterEngine } from './RouterEngine.js';
@@ -6,7 +6,8 @@ import { RouterEngine } from './RouterEngine.js';
6
6
  /**
7
7
  * The {@link RouterEngine} must be provided for these routes
8
8
  */
9
- export type RouterServiceContext = ServiceContext & { routerEngine: RouterEngine };
9
+ export type RouterServiceContext = ServiceContext;
10
+
10
11
  /**
11
12
  * Common context for routes
12
13
  */
@@ -16,11 +17,13 @@ export type Context = {
16
17
  service_context: RouterServiceContext;
17
18
 
18
19
  token_payload?: JwtPayload;
19
- token_errors?: string[];
20
+ token_error?: ServiceError;
20
21
  /**
21
22
  * Only on websocket endpoints.
22
23
  */
23
24
  user_agent?: string;
25
+
26
+ logger: Logger;
24
27
  };
25
28
 
26
29
  export type BasicRouterRequest = {
@@ -29,7 +32,11 @@ export type BasicRouterRequest = {
29
32
  hostname: string;
30
33
  };
31
34
 
32
- export type ContextProvider = (request: BasicRouterRequest) => Promise<Context>;
35
+ export type ConextProviderOptions = {
36
+ logger: Logger;
37
+ };
38
+
39
+ export type ContextProvider = (request: BasicRouterRequest, options: ConextProviderOptions) => Promise<Context>;
33
40
 
34
41
  export type RequestEndpoint<
35
42
  I,
@@ -14,7 +14,11 @@ export async function teardown(runnerConfig: utils.RunnerConfig) {
14
14
  try {
15
15
  logger.info(`Tearing down PowerSync instance...`);
16
16
  const config = await utils.loadConfig(runnerConfig);
17
- const serviceContext = new system.ServiceContextContainer(config);
17
+ const serviceContext = new system.ServiceContextContainer({
18
+ serviceMode: system.ServiceContextMode.TEARDOWN,
19
+ configuration: config
20
+ });
21
+
18
22
  const moduleManager = container.getImplementation(modules.ModuleManager);
19
23
  await moduleManager.initialize(serviceContext);
20
24
  // This is mostly done to ensure that the storage is ready