@powersync/service-module-postgres-storage 0.0.0-dev-20251030082344 → 0.0.0-dev-20251110113516

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 (39) hide show
  1. package/CHANGELOG.md +21 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/migrations/scripts/1756282360128-connection-reporting.d.ts +3 -0
  4. package/dist/@types/storage/PostgresReportStorage.d.ts +26 -0
  5. package/dist/@types/storage/PostgresStorageProvider.d.ts +1 -1
  6. package/dist/@types/storage/storage-index.d.ts +0 -1
  7. package/dist/@types/types/models/SdkReporting.d.ts +21 -0
  8. package/dist/@types/types/models/models-index.d.ts +1 -0
  9. package/dist/@types/{storage/PostgresTestStorageFactoryGenerator.d.ts → utils/test-utils.d.ts} +5 -3
  10. package/dist/@types/utils/utils-index.d.ts +1 -0
  11. package/dist/migrations/scripts/1756282360128-connection-reporting.js +107 -0
  12. package/dist/migrations/scripts/1756282360128-connection-reporting.js.map +1 -0
  13. package/dist/storage/PostgresReportStorage.js +315 -0
  14. package/dist/storage/PostgresReportStorage.js.map +1 -0
  15. package/dist/storage/PostgresStorageProvider.js +10 -1
  16. package/dist/storage/PostgresStorageProvider.js.map +1 -1
  17. package/dist/storage/storage-index.js +0 -1
  18. package/dist/storage/storage-index.js.map +1 -1
  19. package/dist/types/models/SdkReporting.js +17 -0
  20. package/dist/types/models/SdkReporting.js.map +1 -0
  21. package/dist/types/models/models-index.js +1 -0
  22. package/dist/types/models/models-index.js.map +1 -1
  23. package/dist/{storage/PostgresTestStorageFactoryGenerator.js → utils/test-utils.js} +22 -6
  24. package/dist/utils/test-utils.js.map +1 -0
  25. package/dist/utils/utils-index.js +1 -0
  26. package/dist/utils/utils-index.js.map +1 -1
  27. package/package.json +13 -13
  28. package/src/migrations/scripts/1756282360128-connection-reporting.ts +41 -0
  29. package/src/storage/PostgresReportStorage.ts +348 -0
  30. package/src/storage/PostgresStorageProvider.ts +13 -2
  31. package/src/storage/storage-index.ts +0 -1
  32. package/src/types/models/SdkReporting.ts +23 -0
  33. package/src/types/models/models-index.ts +1 -0
  34. package/src/{storage/PostgresTestStorageFactoryGenerator.ts → utils/test-utils.ts} +21 -5
  35. package/src/utils/utils-index.ts +1 -0
  36. package/test/src/__snapshots__/connection-report-storage.test.ts.snap +389 -0
  37. package/test/src/connection-report-storage.test.ts +232 -0
  38. package/test/src/util.ts +3 -6
  39. package/dist/storage/PostgresTestStorageFactoryGenerator.js.map +0 -1
@@ -53,8 +53,9 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
53
53
  import { framework } from '@powersync/service-core';
54
54
  import { PostgresMigrationAgent } from '../migrations/PostgresMigrationAgent.js';
55
55
  import { normalizePostgresStorageConfig } from '../types/types.js';
56
- import { PostgresBucketStorageFactory } from './PostgresBucketStorageFactory.js';
57
- export const postgresTestSetup = (factoryOptions) => {
56
+ import { PostgresReportStorage } from '../storage/PostgresReportStorage.js';
57
+ import { PostgresBucketStorageFactory } from '../storage/PostgresBucketStorageFactory.js';
58
+ export function postgresTestSetup(factoryOptions) {
58
59
  const BASE_CONFIG = {
59
60
  type: 'postgresql',
60
61
  uri: factoryOptions.url,
@@ -96,6 +97,21 @@ export const postgresTestSetup = (factoryOptions) => {
96
97
  }
97
98
  };
98
99
  return {
100
+ reportFactory: async (options) => {
101
+ try {
102
+ if (!options?.doNotClear) {
103
+ await migrate(framework.migrations.Direction.Up);
104
+ }
105
+ return new PostgresReportStorage({
106
+ config: TEST_CONNECTION_OPTIONS
107
+ });
108
+ }
109
+ catch (ex) {
110
+ // Vitest does not display these errors nicely when using the `await using` syntx
111
+ console.error(ex, ex.cause);
112
+ throw ex;
113
+ }
114
+ },
99
115
  factory: async (options) => {
100
116
  try {
101
117
  if (!options?.doNotClear) {
@@ -114,8 +130,8 @@ export const postgresTestSetup = (factoryOptions) => {
114
130
  },
115
131
  migrate
116
132
  };
117
- };
118
- export const PostgresTestStorageFactoryGenerator = (factoryOptions) => {
133
+ }
134
+ export function postgresTestStorageFactoryGenerator(factoryOptions) {
119
135
  return postgresTestSetup(factoryOptions).factory;
120
- };
121
- //# sourceMappingURL=PostgresTestStorageFactoryGenerator.js.map
136
+ }
137
+ //# sourceMappingURL=test-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-utils.js","sourceRoot":"","sources":["../../src/utils/test-utils.ts"],"names":[],"mappings":"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"}
@@ -2,4 +2,5 @@ export * from './bson.js';
2
2
  export * from './bucket-data.js';
3
3
  export * from './db.js';
4
4
  export * from './ts-codec.js';
5
+ export * as test_utils from './test-utils.js';
5
6
  //# sourceMappingURL=utils-index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils-index.js","sourceRoot":"","sources":["../../src/utils/utils-index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,kBAAkB,CAAC;AACjC,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC"}
1
+ {"version":3,"file":"utils-index.js","sourceRoot":"","sources":["../../src/utils/utils-index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,kBAAkB,CAAC;AACjC,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC;AAC9B,OAAO,KAAK,UAAU,MAAM,iBAAiB,CAAC"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@powersync/service-module-postgres-storage",
3
3
  "repository": "https://github.com/powersync-ja/powersync-service",
4
4
  "types": "dist/@types/index.d.ts",
5
- "version": "0.0.0-dev-20251030082344",
5
+ "version": "0.0.0-dev-20251110113516",
6
6
  "main": "dist/index.js",
7
7
  "license": "FSL-1.1-ALv2",
8
8
  "type": "module",
@@ -11,16 +11,16 @@
11
11
  },
12
12
  "exports": {
13
13
  ".": {
14
+ "types": "./dist/@types/index.d.ts",
14
15
  "import": "./dist/index.js",
15
16
  "require": "./dist/index.js",
16
- "default": "./dist/index.js",
17
- "types": "./dist/@types/index.d.ts"
17
+ "default": "./dist/index.js"
18
18
  },
19
19
  "./types": {
20
+ "types": "./dist/@types/index.d.ts",
20
21
  "import": "./dist/types/types.js",
21
22
  "require": "./dist/types/types.js",
22
- "default": "./dist/types/types.js",
23
- "types": "./dist/@types/index.d.ts"
23
+ "default": "./dist/types/types.js"
24
24
  }
25
25
  },
26
26
  "dependencies": {
@@ -29,17 +29,17 @@
29
29
  "p-defer": "^4.0.1",
30
30
  "ts-codec": "^1.3.0",
31
31
  "uuid": "^11.1.0",
32
- "@powersync/lib-service-postgres": "0.0.0-dev-20251030082344",
33
- "@powersync/lib-services-framework": "0.0.0-dev-20251030082344",
34
- "@powersync/service-core": "0.0.0-dev-20251030082344",
35
- "@powersync/service-jpgwire": "0.0.0-dev-20251030082344",
36
- "@powersync/service-jsonbig": "0.0.0-dev-20251030082344",
37
- "@powersync/service-sync-rules": "0.0.0-dev-20251030082344",
38
- "@powersync/service-types": "0.0.0-dev-20251030082344"
32
+ "@powersync/lib-service-postgres": "0.0.0-dev-20251110113516",
33
+ "@powersync/lib-services-framework": "0.7.9",
34
+ "@powersync/service-core": "0.0.0-dev-20251110113516",
35
+ "@powersync/service-types": "0.0.0-dev-20251110113516",
36
+ "@powersync/service-jpgwire": "0.21.5",
37
+ "@powersync/service-jsonbig": "0.17.12",
38
+ "@powersync/service-sync-rules": "0.29.6"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.7.3",
42
- "@powersync/service-core-tests": "0.0.0-dev-20251030082344"
42
+ "@powersync/service-core-tests": "0.0.0-dev-20251110113516"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsc -b",
@@ -0,0 +1,41 @@
1
+ import { migrations } from '@powersync/service-core';
2
+ import { openMigrationDB } from '../migration-utils.js';
3
+
4
+ export const up: migrations.PowerSyncMigrationFunction = async (context) => {
5
+ const {
6
+ service_context: { configuration }
7
+ } = context;
8
+ await using client = openMigrationDB(configuration.storage);
9
+ await client.transaction(async (db) => {
10
+ await db.sql`
11
+ CREATE TABLE IF NOT EXISTS connection_report_events (
12
+ id TEXT PRIMARY KEY,
13
+ user_agent TEXT NOT NULL,
14
+ client_id TEXT NOT NULL,
15
+ user_id TEXT NOT NULL,
16
+ sdk TEXT NOT NULL,
17
+ jwt_exp TIMESTAMP WITH TIME ZONE,
18
+ connected_at TIMESTAMP WITH TIME ZONE NOT NULL,
19
+ disconnected_at TIMESTAMP WITH TIME ZONE
20
+ )
21
+ `.execute();
22
+
23
+ await db.sql`
24
+ CREATE INDEX IF NOT EXISTS sdk_list_index ON connection_report_events (connected_at, jwt_exp, disconnected_at)
25
+ `.execute();
26
+
27
+ await db.sql`CREATE INDEX IF NOT EXISTS sdk_user_id_index ON connection_report_events (user_id)`.execute();
28
+
29
+ await db.sql`CREATE INDEX IF NOT EXISTS sdk_client_id_index ON connection_report_events (client_id)`.execute();
30
+
31
+ await db.sql`CREATE INDEX IF NOT EXISTS sdk_index ON connection_report_events (sdk)`.execute();
32
+ });
33
+ };
34
+
35
+ export const down: migrations.PowerSyncMigrationFunction = async (context) => {
36
+ const {
37
+ service_context: { configuration }
38
+ } = context;
39
+ await using client = openMigrationDB(configuration.storage);
40
+ await client.sql`DROP TABLE IF EXISTS connection_report_events`.execute();
41
+ };
@@ -0,0 +1,348 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import * as pg_wire from '@powersync/service-jpgwire';
3
+ import { event_types } from '@powersync/service-types';
4
+ import { v4 } from 'uuid';
5
+ import * as lib_postgres from '@powersync/lib-service-postgres';
6
+ import { NormalizedPostgresStorageConfig } from '../types/types.js';
7
+ import { SdkReporting, SdkReportingDecoded } from '../types/models/SdkReporting.js';
8
+ import { toInteger } from 'ix/util/tointeger.js';
9
+ import { logger } from '@powersync/lib-services-framework';
10
+ import { getStorageApplicationName } from '../utils/application-name.js';
11
+ import { STORAGE_SCHEMA_NAME } from '../utils/db.js';
12
+ import { ClientConnectionResponse } from '@powersync/service-types/dist/reports.js';
13
+
14
+ export type PostgresReportStorageOptions = {
15
+ config: NormalizedPostgresStorageConfig;
16
+ };
17
+
18
+ export class PostgresReportStorage implements storage.ReportStorage {
19
+ readonly db: lib_postgres.DatabaseClient;
20
+ constructor(protected options: PostgresReportStorageOptions) {
21
+ this.db = new lib_postgres.DatabaseClient({
22
+ config: options.config,
23
+ schema: STORAGE_SCHEMA_NAME,
24
+ applicationName: getStorageApplicationName()
25
+ });
26
+
27
+ this.db.registerListener({
28
+ connectionCreated: async (connection) => this.prepareStatements(connection)
29
+ });
30
+ }
31
+
32
+ private parseJsDate(date: Date) {
33
+ const year = date.getFullYear();
34
+ const month = date.getMonth();
35
+ const today = date.getDate();
36
+ const day = date.getDay();
37
+ return {
38
+ year,
39
+ month,
40
+ today,
41
+ day,
42
+ parsedDate: date
43
+ };
44
+ }
45
+
46
+ private mapListCurrentConnectionsResponse(
47
+ result: SdkReportingDecoded | null
48
+ ): event_types.ClientConnectionReportResponse {
49
+ if (!result) {
50
+ return {
51
+ users: 0,
52
+ sdks: []
53
+ };
54
+ }
55
+ return {
56
+ users: Number(result.users),
57
+ sdks: result.sdks?.data || []
58
+ };
59
+ }
60
+ private async listConnectionsQuery() {
61
+ return await this.db.sql`
62
+ WITH
63
+ filtered AS (
64
+ SELECT
65
+ *
66
+ FROM
67
+ connection_report_events
68
+ WHERE
69
+ disconnected_at IS NULL
70
+ AND jwt_exp > NOW()
71
+ ),
72
+ unique_users AS (
73
+ SELECT
74
+ COUNT(DISTINCT user_id) AS count
75
+ FROM
76
+ filtered
77
+ ),
78
+ sdk_versions_array AS (
79
+ SELECT
80
+ sdk,
81
+ COUNT(DISTINCT client_id) AS clients,
82
+ COUNT(DISTINCT user_id) AS users
83
+ FROM
84
+ filtered
85
+ GROUP BY
86
+ sdk
87
+ )
88
+ SELECT
89
+ (
90
+ SELECT
91
+ COALESCE(count, 0)
92
+ FROM
93
+ unique_users
94
+ ) AS users,
95
+ (
96
+ SELECT
97
+ JSON_AGG(ROW_TO_JSON(s))
98
+ FROM
99
+ sdk_versions_array s
100
+ ) AS sdks;
101
+ `
102
+ .decoded(SdkReporting)
103
+ .first();
104
+ }
105
+
106
+ private updateTableFilter() {
107
+ const { year, month, today } = this.parseJsDate(new Date());
108
+ const nextDay = today + 1;
109
+ return {
110
+ gte: new Date(year, month, today).toISOString(),
111
+ lt: new Date(year, month, nextDay).toISOString()
112
+ };
113
+ }
114
+
115
+ private clientsConnectionPagination(params: event_types.ClientConnectionsRequest): {
116
+ mainQuery: pg_wire.Statement;
117
+ countQuery: pg_wire.Statement;
118
+ } {
119
+ const { cursor, limit, client_id, user_id, date_range } = params;
120
+ const queryLimit = limit || 100;
121
+ const queryParams: pg_wire.StatementParam[] = [];
122
+ let countQuery = `SELECT COUNT(*) AS total FROM connection_report_events`;
123
+ let query = `SELECT id, user_id, client_id, user_agent, sdk, jwt_exp::text AS jwt_exp, disconnected_at, connected_at::text AS connected_at, disconnected_at::text AS disconnected_at FROM connection_report_events`;
124
+ let intermediateQuery = '';
125
+ /** Create a user_id/ client_id filter is they exist */
126
+ if (client_id || user_id) {
127
+ if (client_id && !user_id) {
128
+ intermediateQuery += ` WHERE client_id = $1`;
129
+ queryParams.push({ type: 'varchar', value: client_id });
130
+ } else if (!client_id && user_id) {
131
+ intermediateQuery += ` WHERE user_id = $1`;
132
+ queryParams.push({ type: 'varchar', value: user_id });
133
+ } else {
134
+ intermediateQuery += ' WHERE client_id = $1 AND user_id = $2';
135
+ queryParams.push({ type: 'varchar', value: client_id! });
136
+ queryParams.push({ type: 'varchar', value: user_id! });
137
+ }
138
+ }
139
+
140
+ /** Create a date range filter if it exists */
141
+ if (date_range) {
142
+ const { start, end } = date_range;
143
+ intermediateQuery +=
144
+ queryParams.length === 0
145
+ ? ` WHERE connected_at >= $1 AND connected_at <= $2`
146
+ : ` AND connected_at >= $${queryParams.length + 1} AND connected_at <= $${queryParams.length + 2}`;
147
+ queryParams.push({ type: 1184, value: start.toISOString() });
148
+ queryParams.push({ type: 1184, value: end.toISOString() });
149
+ }
150
+
151
+ countQuery += intermediateQuery;
152
+
153
+ /** Create a cursor filter if it exists. The cursor in postgres is the last item connection date, the id is an uuid so we cant use the same logic as in MongoReportStorage.ts */
154
+ if (cursor) {
155
+ intermediateQuery +=
156
+ queryParams.length === 0 ? ` WHERE connected_at < $1` : ` AND connected_at < $${queryParams.length + 1}`;
157
+ queryParams.push({ type: 1184, value: new Date(cursor).toISOString() });
158
+ }
159
+
160
+ /** Order in descending connected at range to match Mongo sort=-1*/
161
+ intermediateQuery += ` ORDER BY connected_at DESC`;
162
+ query += intermediateQuery;
163
+ return {
164
+ mainQuery: {
165
+ statement: query,
166
+ params: queryParams,
167
+ limit: queryLimit
168
+ },
169
+ countQuery: {
170
+ statement: countQuery,
171
+ params: queryParams
172
+ }
173
+ };
174
+ }
175
+
176
+ async reportClientConnection(data: event_types.ClientConnectionBucketData): Promise<void> {
177
+ const { sdk, connected_at, user_id, user_agent, jwt_exp, client_id } = data;
178
+ const connectIsoString = connected_at.toISOString();
179
+ const jwtExpIsoString = jwt_exp.toISOString();
180
+ const { gte, lt } = this.updateTableFilter();
181
+ const uuid = v4();
182
+ const result = await this.db.sql`
183
+ UPDATE connection_report_events
184
+ SET
185
+ connected_at = ${{ type: 1184, value: connectIsoString }},
186
+ sdk = ${{ type: 'varchar', value: sdk }},
187
+ user_agent = ${{ type: 'varchar', value: user_agent }},
188
+ jwt_exp = ${{ type: 1184, value: jwtExpIsoString }},
189
+ disconnected_at = NULL
190
+ WHERE
191
+ user_id = ${{ type: 'varchar', value: user_id }}
192
+ AND client_id = ${{ type: 'varchar', value: client_id }}
193
+ AND connected_at >= ${{ type: 1184, value: gte }}
194
+ AND connected_at < ${{ type: 1184, value: lt }};
195
+ `.execute();
196
+ if (result.results[1].status === 'UPDATE 0') {
197
+ await this.db.sql`
198
+ INSERT INTO
199
+ connection_report_events (
200
+ user_id,
201
+ client_id,
202
+ connected_at,
203
+ sdk,
204
+ user_agent,
205
+ jwt_exp,
206
+ id
207
+ )
208
+ VALUES
209
+ (
210
+ ${{ type: 'varchar', value: user_id }},
211
+ ${{ type: 'varchar', value: client_id }},
212
+ ${{ type: 1184, value: connectIsoString }},
213
+ ${{ type: 'varchar', value: sdk }},
214
+ ${{ type: 'varchar', value: user_agent }},
215
+ ${{ type: 1184, value: jwtExpIsoString }},
216
+ ${{ type: 'varchar', value: uuid }}
217
+ )
218
+ `.execute();
219
+ }
220
+ }
221
+ async reportClientDisconnection(data: event_types.ClientDisconnectionEventData): Promise<void> {
222
+ const { user_id, client_id, disconnected_at, connected_at } = data;
223
+ const disconnectIsoString = disconnected_at.toISOString();
224
+ const connectIsoString = connected_at.toISOString();
225
+ await this.db.sql`
226
+ UPDATE connection_report_events
227
+ SET
228
+ disconnected_at = ${{ type: 1184, value: disconnectIsoString }},
229
+ jwt_exp = NULL
230
+ WHERE
231
+ user_id = ${{ type: 'varchar', value: user_id }}
232
+ AND client_id = ${{ type: 'varchar', value: client_id }}
233
+ AND connected_at = ${{ type: 1184, value: connectIsoString }}
234
+ `.execute();
235
+ }
236
+ async getConnectedClients(): Promise<event_types.ClientConnectionReportResponse> {
237
+ const result = await this.listConnectionsQuery();
238
+ return this.mapListCurrentConnectionsResponse(result);
239
+ }
240
+
241
+ async getClientConnectionReports(
242
+ data: event_types.ClientConnectionReportRequest
243
+ ): Promise<event_types.ClientConnectionReportResponse> {
244
+ const { start, end } = data;
245
+ const result = await this.db.sql`
246
+ WITH
247
+ filtered AS (
248
+ SELECT
249
+ *
250
+ FROM
251
+ connection_report_events
252
+ WHERE
253
+ connected_at >= ${{ type: 1184, value: start.toISOString() }}
254
+ AND connected_at <= ${{ type: 1184, value: end.toISOString() }}
255
+ ),
256
+ unique_users AS (
257
+ SELECT
258
+ COUNT(DISTINCT user_id) AS count
259
+ FROM
260
+ filtered
261
+ ),
262
+ sdk_versions_array AS (
263
+ SELECT
264
+ sdk,
265
+ COUNT(DISTINCT client_id) AS clients,
266
+ COUNT(DISTINCT user_id) AS users
267
+ FROM
268
+ filtered
269
+ GROUP BY
270
+ sdk
271
+ )
272
+ SELECT
273
+ (
274
+ SELECT
275
+ COALESCE(count, 0)
276
+ FROM
277
+ unique_users
278
+ ) AS users,
279
+ (
280
+ SELECT
281
+ JSON_AGG(ROW_TO_JSON(s))
282
+ FROM
283
+ sdk_versions_array s
284
+ ) AS sdks;
285
+ `
286
+ .decoded(SdkReporting)
287
+ .first();
288
+ return this.mapListCurrentConnectionsResponse(result);
289
+ }
290
+
291
+ async getClientConnections(
292
+ data: event_types.ClientConnectionsRequest
293
+ ): Promise<event_types.PaginatedResponse<event_types.ClientConnection>> {
294
+ const limit = data.limit || 100;
295
+ const statement = this.clientsConnectionPagination(data);
296
+
297
+ const countResult = await this.db.queryRows<{ total: number }>(statement.countQuery);
298
+ const total = Number(countResult[0].total);
299
+
300
+ const result = await this.db.queryRows<ClientConnectionResponse>(statement.mainQuery);
301
+ const items = result.map((item) => ({
302
+ ...item,
303
+ connected_at: new Date(item.connected_at),
304
+ disconnected_at: item.disconnected_at ? new Date(item.disconnected_at) : undefined,
305
+ jwt_exp: item.jwt_exp ? new Date(item.jwt_exp) : undefined
306
+ }));
307
+ const count = items.length;
308
+ return {
309
+ /** Setting the cursor to the connected at date of the last item in the list */
310
+ cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined,
311
+ count,
312
+ items,
313
+ more: count < total,
314
+ total
315
+ };
316
+ }
317
+
318
+ async deleteOldConnectionData(data: event_types.DeleteOldConnectionData): Promise<void> {
319
+ const { date } = data;
320
+ const result = await this.db.sql`
321
+ DELETE FROM connection_report_events
322
+ WHERE
323
+ connected_at < ${{ type: 1184, value: date.toISOString() }}
324
+ AND (
325
+ disconnected_at IS NOT NULL
326
+ OR (
327
+ jwt_exp < NOW()
328
+ AND disconnected_at IS NULL
329
+ )
330
+ );
331
+ `.execute();
332
+ const deletedRows = toInteger(result.results[1].status.split(' ')[1] || '0');
333
+ if (deletedRows > 0) {
334
+ logger.info(
335
+ `TTL from ${date.toISOString()}: ${deletedRows} PostgresSQL rows have been removed from connection_report_events.`
336
+ );
337
+ }
338
+ }
339
+
340
+ async [Symbol.asyncDispose]() {
341
+ await this.db[Symbol.asyncDispose]();
342
+ }
343
+
344
+ async prepareStatements(connection: pg_wire.PgConnection) {
345
+ // It should be possible to prepare statements for some common operations here.
346
+ // This has not been implemented yet.
347
+ }
348
+ }
@@ -5,8 +5,9 @@ import { storage } from '@powersync/service-core';
5
5
  import { isPostgresStorageConfig, normalizePostgresStorageConfig, PostgresStorageConfig } from '../types/types.js';
6
6
  import { dropTables } from '../utils/db.js';
7
7
  import { PostgresBucketStorageFactory } from './PostgresBucketStorageFactory.js';
8
+ import { PostgresReportStorage } from './PostgresReportStorage.js';
8
9
 
9
- export class PostgresStorageProvider implements storage.BucketStorageProvider {
10
+ export class PostgresStorageProvider implements storage.StorageProvider {
10
11
  get type() {
11
12
  return lib_postgres.POSTGRES_CONNECTION_TYPE;
12
13
  }
@@ -28,13 +29,23 @@ export class PostgresStorageProvider implements storage.BucketStorageProvider {
28
29
  config: normalizedConfig,
29
30
  slot_name_prefix: options.resolvedConfig.slot_name_prefix
30
31
  });
32
+
33
+ const reportStorageFactory = new PostgresReportStorage({
34
+ config: normalizedConfig
35
+ });
36
+
31
37
  return {
38
+ reportStorage: reportStorageFactory,
32
39
  storage: storageFactory,
33
- shutDown: async () => storageFactory.db[Symbol.asyncDispose](),
40
+ shutDown: async () => {
41
+ await storageFactory.db[Symbol.asyncDispose]();
42
+ await reportStorageFactory.db[Symbol.asyncDispose]();
43
+ },
34
44
  tearDown: async () => {
35
45
  logger.info(`Tearing down Postgres storage: ${normalizedConfig.database}...`);
36
46
  await dropTables(storageFactory.db);
37
47
  await storageFactory.db[Symbol.asyncDispose]();
48
+ await reportStorageFactory.db[Symbol.asyncDispose]();
38
49
  return true;
39
50
  }
40
51
  } satisfies storage.ActiveStorage;
@@ -2,4 +2,3 @@ export * from './PostgresBucketStorageFactory.js';
2
2
  export * from './PostgresCompactor.js';
3
3
  export * from './PostgresStorageProvider.js';
4
4
  export * from './PostgresSyncRulesStorage.js';
5
- export * from './PostgresTestStorageFactoryGenerator.js';
@@ -0,0 +1,23 @@
1
+ import * as t from 'ts-codec';
2
+ import { bigint, jsonb } from '../codecs.js';
3
+
4
+ export const Sdks = t.object({
5
+ sdk: t.string,
6
+ clients: t.number,
7
+ users: t.number
8
+ });
9
+
10
+ export type Sdks = t.Encoded<typeof Sdks>;
11
+
12
+ export const SdkReporting = t.object({
13
+ users: bigint,
14
+ sdks: t
15
+ .object({
16
+ data: jsonb<Sdks[]>(t.array(Sdks))
17
+ })
18
+ .optional()
19
+ .or(t.Null)
20
+ });
21
+
22
+ export type SdkReporting = t.Encoded<typeof SdkReporting>;
23
+ export type SdkReportingDecoded = t.Decoded<typeof SdkReporting>;
@@ -8,3 +8,4 @@ export * from './Migration.js';
8
8
  export * from './SourceTable.js';
9
9
  export * from './SyncRules.js';
10
10
  export * from './WriteCheckpoint.js';
11
+ export * from './SdkReporting.js';
@@ -1,7 +1,8 @@
1
1
  import { framework, PowerSyncMigrationManager, ServiceContext, TestStorageOptions } from '@powersync/service-core';
2
2
  import { PostgresMigrationAgent } from '../migrations/PostgresMigrationAgent.js';
3
3
  import { normalizePostgresStorageConfig, PostgresStorageConfigDecoded } from '../types/types.js';
4
- import { PostgresBucketStorageFactory } from './PostgresBucketStorageFactory.js';
4
+ import { PostgresReportStorage } from '../storage/PostgresReportStorage.js';
5
+ import { PostgresBucketStorageFactory } from '../storage/PostgresBucketStorageFactory.js';
5
6
 
6
7
  export type PostgresTestStorageOptions = {
7
8
  url: string;
@@ -12,7 +13,7 @@ export type PostgresTestStorageOptions = {
12
13
  migrationAgent?: (config: PostgresStorageConfigDecoded) => PostgresMigrationAgent;
13
14
  };
14
15
 
15
- export const postgresTestSetup = (factoryOptions: PostgresTestStorageOptions) => {
16
+ export function postgresTestSetup(factoryOptions: PostgresTestStorageOptions) {
16
17
  const BASE_CONFIG = {
17
18
  type: 'postgresql' as const,
18
19
  uri: factoryOptions.url,
@@ -48,6 +49,21 @@ export const postgresTestSetup = (factoryOptions: PostgresTestStorageOptions) =>
48
49
  };
49
50
 
50
51
  return {
52
+ reportFactory: async (options?: TestStorageOptions) => {
53
+ try {
54
+ if (!options?.doNotClear) {
55
+ await migrate(framework.migrations.Direction.Up);
56
+ }
57
+
58
+ return new PostgresReportStorage({
59
+ config: TEST_CONNECTION_OPTIONS
60
+ });
61
+ } catch (ex) {
62
+ // Vitest does not display these errors nicely when using the `await using` syntx
63
+ console.error(ex, ex.cause);
64
+ throw ex;
65
+ }
66
+ },
51
67
  factory: async (options?: TestStorageOptions) => {
52
68
  try {
53
69
  if (!options?.doNotClear) {
@@ -66,8 +82,8 @@ export const postgresTestSetup = (factoryOptions: PostgresTestStorageOptions) =>
66
82
  },
67
83
  migrate
68
84
  };
69
- };
85
+ }
70
86
 
71
- export const PostgresTestStorageFactoryGenerator = (factoryOptions: PostgresTestStorageOptions) => {
87
+ export function postgresTestStorageFactoryGenerator(factoryOptions: PostgresTestStorageOptions) {
72
88
  return postgresTestSetup(factoryOptions).factory;
73
- };
89
+ }
@@ -2,3 +2,4 @@ export * from './bson.js';
2
2
  export * from './bucket-data.js';
3
3
  export * from './db.js';
4
4
  export * from './ts-codec.js';
5
+ export * as test_utils from './test-utils.js';