@powersync/service-core 1.15.7 → 1.16.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 (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/entry/commands/compact-action.js +1 -1
  3. package/dist/entry/commands/compact-action.js.map +1 -1
  4. package/dist/events/EventsEngine.d.ts +14 -0
  5. package/dist/events/EventsEngine.js +33 -0
  6. package/dist/events/EventsEngine.js.map +1 -0
  7. package/dist/routes/configure-fastify.js.map +1 -1
  8. package/dist/routes/endpoints/socket-route.js +15 -2
  9. package/dist/routes/endpoints/socket-route.js.map +1 -1
  10. package/dist/routes/endpoints/sync-stream.js +19 -2
  11. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  12. package/dist/routes/router.d.ts +3 -3
  13. package/dist/storage/BucketStorageFactory.d.ts +2 -0
  14. package/dist/storage/ReportStorage.d.ts +36 -0
  15. package/dist/storage/ReportStorage.js +2 -0
  16. package/dist/storage/ReportStorage.js.map +1 -0
  17. package/dist/storage/StorageEngine.d.ts +2 -2
  18. package/dist/storage/StorageEngine.js.map +1 -1
  19. package/dist/storage/StorageProvider.d.ts +3 -1
  20. package/dist/storage/SyncRulesBucketStorage.d.ts +12 -1
  21. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  22. package/dist/storage/storage-index.d.ts +1 -0
  23. package/dist/storage/storage-index.js +1 -0
  24. package/dist/storage/storage-index.js.map +1 -1
  25. package/dist/system/ServiceContext.d.ts +3 -0
  26. package/dist/system/ServiceContext.js +10 -1
  27. package/dist/system/ServiceContext.js.map +1 -1
  28. package/package.json +7 -7
  29. package/src/entry/commands/compact-action.ts +1 -1
  30. package/src/events/EventsEngine.ts +38 -0
  31. package/src/routes/configure-fastify.ts +0 -1
  32. package/src/routes/endpoints/socket-route.ts +16 -3
  33. package/src/routes/endpoints/sync-stream.ts +19 -2
  34. package/src/routes/router.ts +3 -3
  35. package/src/storage/BucketStorageFactory.ts +2 -0
  36. package/src/storage/ReportStorage.ts +39 -0
  37. package/src/storage/StorageEngine.ts +3 -3
  38. package/src/storage/StorageProvider.ts +3 -1
  39. package/src/storage/SyncRulesBucketStorage.ts +14 -1
  40. package/src/storage/storage-index.ts +1 -0
  41. package/src/system/ServiceContext.ts +13 -1
  42. package/test/src/routes/mocks.ts +2 -0
  43. package/test/src/routes/stream.test.ts +14 -6
  44. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,38 @@
1
+ import EventEmitter from 'node:events';
2
+ import { logger } from '@powersync/lib-services-framework';
3
+ import { event_types } from '@powersync/service-types';
4
+
5
+ export class EventsEngine {
6
+ private emitter: EventEmitter;
7
+ private events: Set<event_types.EventsEngineEventType> = new Set();
8
+ constructor() {
9
+ this.emitter = new EventEmitter({ captureRejections: true });
10
+ this.emitter.on('error', (error: Error) => {
11
+ logger.error(error.message);
12
+ });
13
+ }
14
+
15
+ /**
16
+ * All new events added need to be subscribed to be used.
17
+ * @example engine.subscribe(new MyNewEvent(storageEngine));
18
+ */
19
+ subscribe<K extends event_types.EventsEngineEventType>(event: event_types.EmitterEvent<K>): void {
20
+ if (!this.events.has(event.event)) {
21
+ this.events.add(event.event);
22
+ }
23
+ this.emitter.on(event.event, event.handler.bind(event));
24
+ }
25
+
26
+ get listEvents(): event_types.EventsEngineEventType[] {
27
+ return Array.from(this.events.values());
28
+ }
29
+
30
+ emit<K extends keyof event_types.SubscribeEvents>(event: K, data: event_types.SubscribeEvents[K]): void {
31
+ this.emitter.emit(event, data);
32
+ }
33
+
34
+ shutDown(): void {
35
+ logger.info(`Shutting down EmitterEngine and removing all listeners for ${this.listEvents.join(', ')}.`);
36
+ this.emitter.removeAllListeners();
37
+ }
38
+ }
@@ -1,5 +1,4 @@
1
1
  import type fastify from 'fastify';
2
- import * as uuid from 'uuid';
3
2
 
4
3
  import { registerFastifyNotFoundHandler, registerFastifyRoutes } from './route-register.js';
5
4
 
@@ -1,12 +1,11 @@
1
1
  import { ErrorCode, errors, schema } from '@powersync/lib-services-framework';
2
- import { RequestParameters } from '@powersync/service-sync-rules';
3
2
 
4
3
  import * as sync from '../../sync/sync-index.js';
5
4
  import * as util from '../../util/util-index.js';
6
5
  import { SocketRouteGenerator } from '../router-socket.js';
7
6
  import { SyncRoutes } from './sync-stream.js';
8
7
 
9
- import { APIMetric } from '@powersync/service-types';
8
+ import { APIMetric, event_types } from '@powersync/service-types';
10
9
 
11
10
  export const syncStreamReactive: SocketRouteGenerator = (router) =>
12
11
  router.reactiveStream<util.StreamingSyncRequest, any>(SyncRoutes.STREAM, {
@@ -14,6 +13,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
14
13
  handler: async ({ context, params, responder, observer, initialN, signal: upstreamSignal, connection }) => {
15
14
  const { service_context, logger } = context;
16
15
  const { routerEngine, metricsEngine, syncContext } = service_context;
16
+ const streamStart = Date.now();
17
17
 
18
18
  logger.defaultMeta = {
19
19
  ...logger.defaultMeta,
@@ -21,7 +21,15 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
21
21
  client_id: params.client_id,
22
22
  user_agent: context.user_agent
23
23
  };
24
- const streamStart = Date.now();
24
+
25
+ const sdkData: event_types.ConnectedUserData & event_types.ClientConnectionEventData = {
26
+ client_id: params.client_id ?? '',
27
+ user_id: context.user_id!,
28
+ user_agent: context.user_agent,
29
+ // At this point the token_payload is guaranteed to be present
30
+ jwt_exp: new Date(context.token_payload!.exp * 1000),
31
+ connected_at: new Date(streamStart)
32
+ };
25
33
 
26
34
  // Best effort guess on why the stream was closed.
27
35
  // We use the `??=` operator everywhere, so that we catch the first relevant
@@ -83,6 +91,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
83
91
  });
84
92
 
85
93
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(1);
94
+ service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_CONNECT_EVENT, sdkData);
86
95
  const tracker = new sync.RequestTracker(metricsEngine);
87
96
  if (connection.tracker.encoding) {
88
97
  // Must be set before we start the stream
@@ -174,6 +183,10 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
174
183
  close_reason: closeReason ?? 'unknown'
175
184
  });
176
185
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
186
+ service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
187
+ ...sdkData,
188
+ disconnected_at: new Date()
189
+ });
177
190
  }
178
191
  }
179
192
  });
@@ -7,8 +7,8 @@ import * as util from '../../util/util-index.js';
7
7
 
8
8
  import { authUser } from '../auth.js';
9
9
  import { routeDefinition } from '../router.js';
10
+ import { APIMetric, event_types } from '@powersync/service-types';
10
11
 
11
- import { APIMetric } from '@powersync/service-types';
12
12
  import { maybeCompressResponseStream } from '../compression.js';
13
13
 
14
14
  export enum SyncRoutes {
@@ -25,7 +25,7 @@ export const syncStreamed = routeDefinition({
25
25
  authorize: authUser,
26
26
  validator: schema.createTsCodecValidator(util.StreamingSyncRequest, { allowAdditional: true }),
27
27
  handler: async (payload) => {
28
- const { service_context, logger } = payload.context;
28
+ const { service_context, logger, token_payload } = payload.context;
29
29
  const { routerEngine, storageEngine, metricsEngine, syncContext } = service_context;
30
30
  const headers = payload.request.headers;
31
31
  const userAgent = headers['x-user-agent'] ?? headers['user-agent'];
@@ -44,6 +44,14 @@ export const syncStreamed = routeDefinition({
44
44
  user_id: payload.context.user_id,
45
45
  bson: useBson
46
46
  };
47
+ const sdkData: event_types.ConnectedUserData & event_types.ClientConnectionEventData = {
48
+ client_id: clientId ?? '',
49
+ user_id: payload.context.user_id!,
50
+ user_agent: userAgent as string,
51
+ // At this point the token_payload is guaranteed to be present
52
+ jwt_exp: new Date(token_payload!.exp * 1000),
53
+ connected_at: new Date(streamStart)
54
+ };
47
55
 
48
56
  if (routerEngine.closed) {
49
57
  throw new errors.ServiceError({
@@ -69,6 +77,7 @@ export const syncStreamed = routeDefinition({
69
77
  const tracker = new sync.RequestTracker(metricsEngine);
70
78
  try {
71
79
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(1);
80
+ service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_CONNECT_EVENT, sdkData);
72
81
  const syncLines = sync.streamResponse({
73
82
  syncContext: syncContext,
74
83
  bucketStorage,
@@ -134,6 +143,10 @@ export const syncStreamed = routeDefinition({
134
143
  }
135
144
  controller.abort();
136
145
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
146
+ service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
147
+ ...sdkData,
148
+ disconnected_at: new Date()
149
+ });
137
150
  logger.info(`Sync stream complete`, {
138
151
  ...tracker.getLogMeta(),
139
152
  stream_ms: Date.now() - streamStart,
@@ -144,6 +157,10 @@ export const syncStreamed = routeDefinition({
144
157
  } catch (ex) {
145
158
  controller.abort();
146
159
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
160
+ service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
161
+ ...sdkData,
162
+ disconnected_at: new Date()
163
+ });
147
164
  }
148
165
  }
149
166
  });
@@ -1,4 +1,4 @@
1
- import { router, ServiceError, Logger } from '@powersync/lib-services-framework';
1
+ import { Logger, router } 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';
@@ -31,11 +31,11 @@ export type BasicRouterRequest = {
31
31
  hostname: string;
32
32
  };
33
33
 
34
- export type ConextProviderOptions = {
34
+ export type ContextProviderOptions = {
35
35
  logger: Logger;
36
36
  };
37
37
 
38
- export type ContextProvider = (request: BasicRouterRequest, options: ConextProviderOptions) => Promise<Context>;
38
+ export type ContextProvider = (request: BasicRouterRequest, options: ContextProviderOptions) => Promise<Context>;
39
39
 
40
40
  export type RequestEndpoint<
41
41
  I,
@@ -3,6 +3,7 @@ import { ParseSyncRulesOptions, PersistedSyncRules, PersistedSyncRulesContent }
3
3
  import { ReplicationEventPayload } from './ReplicationEventPayload.js';
4
4
  import { ReplicationLock } from './ReplicationLock.js';
5
5
  import { SyncRulesBucketStorage } from './SyncRulesBucketStorage.js';
6
+ import { ReportStorage } from './ReportStorage.js';
6
7
 
7
8
  /**
8
9
  * Represents a configured storage provider.
@@ -164,3 +165,4 @@ export interface TestStorageOptions {
164
165
  doNotClear?: boolean;
165
166
  }
166
167
  export type TestStorageFactory = (options?: TestStorageOptions) => Promise<BucketStorageFactory>;
168
+ export type TestReportStorageFactory = (options?: TestStorageOptions) => Promise<ReportStorage>;
@@ -0,0 +1,39 @@
1
+ import { event_types } from '@powersync/service-types';
2
+
3
+ /**
4
+ * Represents a configured report storage.
5
+ *
6
+ * Report storage is used for storing localized data for the instance.
7
+ * Data can then be used for reporting purposes.
8
+ *
9
+ */
10
+ export interface ReportStorage extends AsyncDisposable {
11
+ /**
12
+ * Report a client connection.
13
+ */
14
+ reportClientConnection(data: event_types.ClientConnectionBucketData): Promise<void>;
15
+ /**
16
+ * Report a client disconnection.
17
+ */
18
+ reportClientDisconnection(data: event_types.ClientDisconnectionEventData): Promise<void>;
19
+ /**
20
+ * Get currently connected clients.
21
+ * This will return any short or long term connected clients.
22
+ * Clients that have no disconnected_at timestamp and that have a valid jwt_exp timestamp are considered connected.
23
+ */
24
+ getConnectedClients(): Promise<event_types.ClientConnectionReportResponse>;
25
+ /**
26
+ * Get a report of client connections over a day, week or month.
27
+ * This is internally used to generate reports over it always returns the previous day, week or month.
28
+ * Usually this is call on the start of the new day, week or month. It will return all unique completed connections
29
+ * as well as uniques currently connected clients.
30
+ */
31
+ getClientConnectionReports(
32
+ data: event_types.ClientConnectionReportRequest
33
+ ): Promise<event_types.ClientConnectionReportResponse>;
34
+ /**
35
+ * Delete old connection data based on a specific date.
36
+ * This is used to clean up old connection data that is no longer needed.
37
+ */
38
+ deleteOldConnectionData(data: event_types.DeleteOldConnectionData): Promise<void>;
39
+ }
@@ -1,7 +1,7 @@
1
1
  import { BaseObserver, logger, ServiceError } from '@powersync/lib-services-framework';
2
2
  import { ResolvedPowerSyncConfig } from '../util/util-index.js';
3
3
  import { BucketStorageFactory } from './BucketStorageFactory.js';
4
- import { ActiveStorage, BucketStorageProvider } from './StorageProvider.js';
4
+ import { ActiveStorage, StorageProvider } from './StorageProvider.js';
5
5
 
6
6
  export type StorageEngineOptions = {
7
7
  configuration: ResolvedPowerSyncConfig;
@@ -14,7 +14,7 @@ export interface StorageEngineListener {
14
14
 
15
15
  export class StorageEngine extends BaseObserver<StorageEngineListener> {
16
16
  // TODO: This will need to revisited when we actually support multiple storage providers.
17
- private storageProviders: Map<string, BucketStorageProvider> = new Map();
17
+ private storageProviders: Map<string, StorageProvider> = new Map();
18
18
  private currentActiveStorage: ActiveStorage | null = null;
19
19
 
20
20
  constructor(private options: StorageEngineOptions) {
@@ -37,7 +37,7 @@ export class StorageEngine extends BaseObserver<StorageEngineListener> {
37
37
  * Register a provider which generates a {@link BucketStorageFactory}
38
38
  * given the matching config specified in the loaded {@link ResolvedPowerSyncConfig}
39
39
  */
40
- registerProvider(provider: BucketStorageProvider) {
40
+ registerProvider(provider: StorageProvider) {
41
41
  this.storageProviders.set(provider.type, provider);
42
42
  }
43
43
 
@@ -1,9 +1,11 @@
1
1
  import { ServiceError } from '@powersync/lib-services-framework';
2
2
  import * as util from '../util/util-index.js';
3
3
  import { BucketStorageFactory } from './BucketStorageFactory.js';
4
+ import { ReportStorage } from './ReportStorage.js';
4
5
 
5
6
  export interface ActiveStorage {
6
7
  storage: BucketStorageFactory;
8
+ reportStorage: ReportStorage;
7
9
  shutDown(): Promise<void>;
8
10
 
9
11
  /**
@@ -22,7 +24,7 @@ export interface GetStorageOptions {
22
24
  /**
23
25
  * Represents a provider that can create a storage instance for a specific storage type from configuration.
24
26
  */
25
- export interface BucketStorageProvider {
27
+ export interface StorageProvider {
26
28
  /**
27
29
  * The storage type that this provider provides.
28
30
  * The type should match the `type` field in the config.
@@ -65,7 +65,7 @@ export interface SyncRulesBucketStorage
65
65
  /**
66
66
  * Lightweight "compact" process to populate the checksum cache, if any.
67
67
  */
68
- populatePersistentChecksumCache(options?: Pick<CompactOptions, 'signal' | 'maxOpId'>): Promise<void>;
68
+ populatePersistentChecksumCache(options: PopulateChecksumCacheOptions): Promise<PopulateChecksumCacheResults>;
69
69
 
70
70
  // ## Read operations
71
71
 
@@ -225,6 +225,19 @@ export interface CompactOptions {
225
225
  signal?: AbortSignal;
226
226
  }
227
227
 
228
+ export interface PopulateChecksumCacheOptions {
229
+ maxOpId: util.InternalOpId;
230
+ minBucketChanges?: number;
231
+ signal?: AbortSignal;
232
+ }
233
+
234
+ export interface PopulateChecksumCacheResults {
235
+ /**
236
+ * Number of buckets we have calculated checksums for.
237
+ */
238
+ buckets: number;
239
+ }
240
+
228
241
  export interface ClearStorageOptions {
229
242
  signal?: AbortSignal;
230
243
  }
@@ -13,3 +13,4 @@ export * from './BucketStorageBatch.js';
13
13
  export * from './SyncRulesBucketStorage.js';
14
14
  export * from './PersistedSyncRulesContent.js';
15
15
  export * from './ReplicationLock.js';
16
+ export * from './ReportStorage.js';
@@ -1,4 +1,4 @@
1
- import { LifeCycledSystem, MigrationManager, ServiceIdentifier, container } from '@powersync/lib-services-framework';
1
+ import { container, LifeCycledSystem, MigrationManager, ServiceIdentifier } from '@powersync/lib-services-framework';
2
2
 
3
3
  import { framework } from '../index.js';
4
4
  import * as metrics from '../metrics/MetricsEngine.js';
@@ -8,6 +8,7 @@ import * as routes from '../routes/routes-index.js';
8
8
  import * as storage from '../storage/storage-index.js';
9
9
  import { SyncContext } from '../sync/SyncContext.js';
10
10
  import * as utils from '../util/util-index.js';
11
+ import { EventsEngine } from '../events/EventsEngine.js';
11
12
 
12
13
  export interface ServiceContext {
13
14
  configuration: utils.ResolvedPowerSyncConfig;
@@ -19,6 +20,7 @@ export interface ServiceContext {
19
20
  migrations: PowerSyncMigrationManager;
20
21
  syncContext: SyncContext;
21
22
  serviceMode: ServiceContextMode;
23
+ eventsEngine: EventsEngine;
22
24
  }
23
25
 
24
26
  export enum ServiceContextMode {
@@ -45,6 +47,7 @@ export class ServiceContextContainer implements ServiceContext {
45
47
  configuration: utils.ResolvedPowerSyncConfig;
46
48
  lifeCycleEngine: LifeCycledSystem;
47
49
  storageEngine: storage.StorageEngine;
50
+ eventsEngine: EventsEngine;
48
51
  syncContext: SyncContext;
49
52
  routerEngine: routes.RouterEngine;
50
53
  serviceMode: ServiceContextMode;
@@ -66,6 +69,11 @@ export class ServiceContextContainer implements ServiceContext {
66
69
  }
67
70
  });
68
71
 
72
+ this.eventsEngine = new EventsEngine();
73
+ this.lifeCycleEngine.withLifecycle(this.eventsEngine, {
74
+ stop: (emitterEngine) => emitterEngine.shutDown()
75
+ });
76
+
69
77
  this.lifeCycleEngine.withLifecycle(this.storageEngine, {
70
78
  start: (storageEngine) => storageEngine.start(),
71
79
  stop: (storageEngine) => storageEngine.shutDown()
@@ -89,6 +97,10 @@ export class ServiceContextContainer implements ServiceContext {
89
97
  // Migrations should be executed before the system starts
90
98
  start: () => migrationManager[Symbol.asyncDispose]()
91
99
  });
100
+
101
+ this.lifeCycleEngine.withLifecycle(this.eventsEngine, {
102
+ stop: (emitterEngine) => emitterEngine.shutDown()
103
+ });
92
104
  }
93
105
 
94
106
  get replicationEngine(): replication.ReplicationEngine | null {
@@ -11,6 +11,7 @@ import {
11
11
  SyncRulesBucketStorage
12
12
  } from '@/index.js';
13
13
  import { MeterProvider } from '@opentelemetry/sdk-metrics';
14
+ import { EventsEngine } from '@/events/EventsEngine.js';
14
15
 
15
16
  export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | null) {
16
17
  // This is very incomplete - just enough to get the current tests passing.
@@ -34,6 +35,7 @@ export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | nu
34
35
  createCoreAPIMetrics(metricsEngine);
35
36
  const service_context: Partial<ServiceContext> = {
36
37
  syncContext: new SyncContext({ maxBuckets: 1, maxDataFetchConcurrency: 1, maxParameterQueryResults: 1 }),
38
+ eventsEngine: new EventsEngine(),
37
39
  routerEngine: {
38
40
  getAPI() {
39
41
  return {
@@ -3,7 +3,7 @@ import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-fr
3
3
  import { SqlSyncRules } from '@powersync/service-sync-rules';
4
4
  import { Readable, Writable } from 'stream';
5
5
  import { pipeline } from 'stream/promises';
6
- import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { describe, expect, it } from 'vitest';
7
7
  import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
8
8
  import { mockServiceContext } from './mocks.js';
9
9
 
@@ -12,7 +12,12 @@ describe('Stream Route', () => {
12
12
  it('handles missing sync rules', async () => {
13
13
  const context: Context = {
14
14
  logger: logger,
15
- service_context: mockServiceContext(null)
15
+ service_context: mockServiceContext(null),
16
+ token_payload: {
17
+ sub: '',
18
+ exp: 0,
19
+ iat: 0
20
+ }
16
21
  };
17
22
 
18
23
  const request: BasicRouterRequest = {
@@ -21,10 +26,13 @@ describe('Stream Route', () => {
21
26
  protocol: 'http'
22
27
  };
23
28
 
24
- const error = (await (syncStreamed.handler({ context, params: {}, request }) as Promise<RouterResponse>).catch(
25
- (e) => e
26
- )) as ServiceError;
27
-
29
+ const error = (await (
30
+ syncStreamed.handler({
31
+ context,
32
+ params: {},
33
+ request
34
+ }) as Promise<RouterResponse>
35
+ ).catch((e) => e)) as ServiceError;
28
36
  expect(error.errorData.status).toEqual(500);
29
37
  expect(error.errorData.code).toEqual('PSYNC_S2302');
30
38
  });