@powersync/service-module-postgres-storage 0.10.12 → 0.10.14

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.
@@ -9,6 +9,7 @@ import { toInteger } from 'ix/util/tointeger.js';
9
9
  import { logger } from '@powersync/lib-services-framework';
10
10
  import { getStorageApplicationName } from '../utils/application-name.js';
11
11
  import { STORAGE_SCHEMA_NAME } from '../utils/db.js';
12
+ import { ClientConnectionResponse } from '@powersync/service-types/dist/reports.js';
12
13
 
13
14
  export type PostgresReportStorageOptions = {
14
15
  config: NormalizedPostgresStorageConfig;
@@ -29,10 +30,10 @@ export class PostgresReportStorage implements storage.ReportStorage {
29
30
  }
30
31
 
31
32
  private parseJsDate(date: Date) {
32
- const year = date.getFullYear();
33
- const month = date.getMonth();
34
- const today = date.getDate();
35
- const day = date.getDay();
33
+ const year = date.getUTCFullYear();
34
+ const month = date.getUTCMonth();
35
+ const today = date.getUTCDate();
36
+ const day = date.getUTCDay();
36
37
  return {
37
38
  year,
38
39
  month,
@@ -106,8 +107,70 @@ export class PostgresReportStorage implements storage.ReportStorage {
106
107
  const { year, month, today } = this.parseJsDate(new Date());
107
108
  const nextDay = today + 1;
108
109
  return {
109
- gte: new Date(year, month, today).toISOString(),
110
- lt: new Date(year, month, nextDay).toISOString()
110
+ gte: new Date(Date.UTC(year, month, today)).toISOString(),
111
+ lt: new Date(Date.UTC(year, month, nextDay)).toISOString()
112
+ };
113
+ }
114
+
115
+ private clientsConnectionPagination(params: event_types.ClientConnectionAnalyticsRequest): {
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 != null || 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 != null) {
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
+
164
+ return {
165
+ mainQuery: {
166
+ statement: query,
167
+ params: queryParams,
168
+ limit: queryLimit
169
+ },
170
+ countQuery: {
171
+ statement: countQuery,
172
+ params: queryParams
173
+ }
111
174
  };
112
175
  }
113
176
 
@@ -225,6 +288,34 @@ export class PostgresReportStorage implements storage.ReportStorage {
225
288
  .first();
226
289
  return this.mapListCurrentConnectionsResponse(result);
227
290
  }
291
+
292
+ async getGeneralClientConnectionAnalytics(
293
+ data: event_types.ClientConnectionAnalyticsRequest
294
+ ): Promise<event_types.PaginatedResponse<event_types.ClientConnection>> {
295
+ const limit = data.limit || 100;
296
+ const statement = this.clientsConnectionPagination(data);
297
+
298
+ const result = await this.db.queryRows<ClientConnectionResponse>(statement.mainQuery);
299
+ const items = result.map((item) => ({
300
+ ...item,
301
+ /** JS Date conversion to match document schema used for Mongo storage */
302
+ connected_at: new Date(item.connected_at),
303
+ disconnected_at: item.disconnected_at ? new Date(item.disconnected_at) : undefined,
304
+ jwt_exp: item.jwt_exp ? new Date(item.jwt_exp) : undefined
305
+ }));
306
+ const count = items.length;
307
+ /** The returned total has been defaulted to 0 due to the overhead using documentCount from the mogo driver this is just to keep consistency with Mongo implementation.
308
+ * cursor.count has been deprecated.
309
+ * */
310
+ return {
311
+ items,
312
+ /** Setting the cursor to the connected at date of the last item in the list */
313
+ cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined,
314
+ count,
315
+ more: !(count !== limit)
316
+ };
317
+ }
318
+
228
319
  async deleteOldConnectionData(data: event_types.DeleteOldConnectionData): Promise<void> {
229
320
  const { date } = data;
230
321
  const result = await this.db.sql`
@@ -77,6 +77,7 @@ export class PostgresBucketBatch
77
77
  private lastWaitingLogThrottled = 0;
78
78
  private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
79
79
  private needsActivation = true;
80
+ private clearedError = false;
80
81
 
81
82
  constructor(protected options: PostgresBucketBatchOptions) {
82
83
  super();
@@ -100,6 +101,10 @@ export class PostgresBucketBatch
100
101
  return this.last_checkpoint_lsn;
101
102
  }
102
103
 
104
+ get noCheckpointBeforeLsn() {
105
+ return this.no_checkpoint_before_lsn;
106
+ }
107
+
103
108
  async [Symbol.asyncDispose]() {
104
109
  super.clearListeners();
105
110
  }
@@ -213,7 +218,10 @@ export class PostgresBucketBatch
213
218
  });
214
219
  }
215
220
  }
216
- await persistedBatch.flush(db);
221
+ const { flushedAny } = await persistedBatch.flush(db);
222
+ if (flushedAny) {
223
+ await this.clearError(db);
224
+ }
217
225
  });
218
226
  }
219
227
  if (processedCount == 0) {
@@ -570,6 +578,7 @@ export class PostgresBucketBatch
570
578
 
571
579
  // If set, we need to start a new transaction with this batch.
572
580
  let resumeBatch: OperationBatch | null = null;
581
+ let didFlush = false;
573
582
 
574
583
  // Now batch according to the sizes
575
584
  // This is a single batch if storeCurrentData == false
@@ -654,7 +663,8 @@ export class PostgresBucketBatch
654
663
  }
655
664
 
656
665
  if (persistedBatch!.shouldFlushTransaction()) {
657
- await persistedBatch!.flush(db);
666
+ const { flushedAny } = await persistedBatch!.flush(db);
667
+ didFlush ||= flushedAny;
658
668
  // The operations stored in this batch will be processed in the `resumeBatch`
659
669
  persistedBatch = null;
660
670
  // Return the remaining entries for the next resume transaction
@@ -667,10 +677,15 @@ export class PostgresBucketBatch
667
677
  * The operations were less than the max size if here. Flush now.
668
678
  * `persistedBatch` will be `null` if the operations should be flushed in a new transaction.
669
679
  */
670
- await persistedBatch.flush(db);
680
+ const { flushedAny } = await persistedBatch.flush(db);
681
+ didFlush ||= flushedAny;
671
682
  }
672
683
  }
673
684
 
685
+ if (didFlush) {
686
+ await this.clearError(db);
687
+ }
688
+
674
689
  // Don't return empty batches
675
690
  if (resumeBatch?.batch.length) {
676
691
  return resumeBatch;
@@ -1006,6 +1021,24 @@ export class PostgresBucketBatch
1006
1021
  }
1007
1022
  }
1008
1023
 
1024
+ protected async clearError(
1025
+ db: lib_postgres.AbstractPostgresConnection | lib_postgres.DatabaseClient = this.db
1026
+ ): Promise<void> {
1027
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
1028
+ if (this.clearedError) {
1029
+ return;
1030
+ }
1031
+
1032
+ await db.sql`
1033
+ UPDATE sync_rules
1034
+ SET
1035
+ last_fatal_error = ${{ type: 'varchar', value: null }}
1036
+ WHERE
1037
+ id = ${{ type: 'int4', value: this.group_id }}
1038
+ `.execute();
1039
+ this.clearedError = true;
1040
+ }
1041
+
1009
1042
  private async getLastOpIdSequence(db: lib_postgres.AbstractPostgresConnection) {
1010
1043
  // When no op_id has been generated, last_value = 1 and nextval() will be 1.
1011
1044
  // To cater for this case, we check is_called, and default to 0 if no value has been generated.
@@ -236,6 +236,13 @@ export class PostgresPersistedBatch {
236
236
  }
237
237
 
238
238
  async flush(db: lib_postgres.WrappedConnection) {
239
+ const stats = {
240
+ bucketDataCount: this.bucketDataInserts.length,
241
+ parameterDataCount: this.parameterDataInserts.length,
242
+ currentDataCount: this.currentDataInserts.size + this.currentDataDeletes.length
243
+ };
244
+ const flushedAny = stats.bucketDataCount > 0 || stats.parameterDataCount > 0 || stats.currentDataCount > 0;
245
+
239
246
  logger.info(
240
247
  `powersync_${this.group_id} Flushed ${this.bucketDataInserts.length} + ${this.parameterDataInserts.length} + ${
241
248
  this.currentDataInserts.size + this.currentDataDeletes.length
@@ -251,6 +258,11 @@ export class PostgresPersistedBatch {
251
258
  this.currentDataDeletes = [];
252
259
  this.currentDataInserts = new Map();
253
260
  this.currentSize = 0;
261
+
262
+ return {
263
+ ...stats,
264
+ flushedAny
265
+ };
254
266
  }
255
267
 
256
268
  protected async flushBucketData(db: lib_postgres.WrappedConnection) {
@@ -3,13 +3,13 @@
3
3
  exports[`Connection report storage > Should create a connection event if its after a day 1`] = `
4
4
  [
5
5
  {
6
- "client_id": "client_week",
6
+ "client_id": "client_one",
7
7
  "sdk": "powersync-js/1.24.5",
8
8
  "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
9
9
  "user_id": "user_week",
10
10
  },
11
11
  {
12
- "client_id": "client_week",
12
+ "client_id": "client_one",
13
13
  "sdk": "powersync-js/1.24.5",
14
14
  "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
15
15
  "user_id": "user_week",
@@ -213,3 +213,265 @@ exports[`Report storage tests > Should show currently connected users 1`] = `
213
213
  "users": 2,
214
214
  }
215
215
  `;
216
+
217
+ exports[`Report storage tests > Should show paginated response of all connections of specified client_id 1`] = `
218
+ {
219
+ "count": 1,
220
+ "cursor": undefined,
221
+ "items": [
222
+ {
223
+ "client_id": "client_two",
224
+ "id": "2",
225
+ "sdk": "powersync-js/1.21.1",
226
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
227
+ "user_id": "user_two",
228
+ },
229
+ ],
230
+ "more": false,
231
+ }
232
+ `;
233
+
234
+ exports[`Report storage tests > Should show paginated response of all connections with a limit 1`] = `
235
+ {
236
+ "count": 4,
237
+ "cursor": "<removed-for-snapshot>",
238
+ "items": [
239
+ {
240
+ "client_id": "client_one",
241
+ "id": "1",
242
+ "sdk": "powersync-dart/1.6.4",
243
+ "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
244
+ "user_id": "user_one",
245
+ },
246
+ {
247
+ "client_id": "client_four",
248
+ "id": "4",
249
+ "sdk": "powersync-js/1.21.4",
250
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
251
+ "user_id": "user_four",
252
+ },
253
+ {
254
+ "client_id": "client_two",
255
+ "id": "2",
256
+ "sdk": "powersync-js/1.21.1",
257
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
258
+ "user_id": "user_two",
259
+ },
260
+ {
261
+ "client_id": "",
262
+ "id": "5",
263
+ "sdk": "unknown",
264
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
265
+ "user_id": "user_one",
266
+ },
267
+ ],
268
+ "more": true,
269
+ }
270
+ `;
271
+
272
+ exports[`Report storage tests > Should show paginated response of all connections with a limit 2`] = `
273
+ {
274
+ "count": 4,
275
+ "cursor": undefined,
276
+ "items": [
277
+ {
278
+ "client_id": "client_three",
279
+ "id": "3",
280
+ "sdk": "powersync-js/1.21.2",
281
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
282
+ "user_id": "user_three",
283
+ },
284
+ {
285
+ "client_id": "client_one",
286
+ "id": "week",
287
+ "sdk": "powersync-js/1.24.5",
288
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
289
+ "user_id": "user_week",
290
+ },
291
+ {
292
+ "client_id": "client_month",
293
+ "id": "month",
294
+ "sdk": "powersync-js/1.23.6",
295
+ "user_agent": "powersync-js/1.23.0 powersync-web Firefox/141 linux",
296
+ "user_id": "user_month",
297
+ },
298
+ {
299
+ "client_id": "client_expired",
300
+ "id": "expired",
301
+ "sdk": "powersync-js/1.23.7",
302
+ "user_agent": "powersync-js/1.23.0 powersync-web Firefox/141 linux",
303
+ "user_id": "user_expired",
304
+ },
305
+ ],
306
+ "more": false,
307
+ }
308
+ `;
309
+
310
+ exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 1`] = `
311
+ {
312
+ "count": 4,
313
+ "cursor": "<removed-for-snapshot>",
314
+ "items": [
315
+ {
316
+ "client_id": "client_two",
317
+ "id": "2",
318
+ "sdk": "powersync-js/1.21.1",
319
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
320
+ "user_id": "user_two",
321
+ },
322
+ {
323
+ "client_id": "",
324
+ "id": "5",
325
+ "sdk": "unknown",
326
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
327
+ "user_id": "user_one",
328
+ },
329
+ {
330
+ "client_id": "client_three",
331
+ "id": "3",
332
+ "sdk": "powersync-js/1.21.2",
333
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
334
+ "user_id": "user_three",
335
+ },
336
+ {
337
+ "client_id": "client_one",
338
+ "id": "week",
339
+ "sdk": "powersync-js/1.24.5",
340
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
341
+ "user_id": "user_week",
342
+ },
343
+ ],
344
+ "more": true,
345
+ }
346
+ `;
347
+
348
+ exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 2`] = `
349
+ {
350
+ "count": 2,
351
+ "cursor": undefined,
352
+ "items": [
353
+ {
354
+ "client_id": "client_two",
355
+ "id": "2",
356
+ "sdk": "powersync-js/1.21.1",
357
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
358
+ "user_id": "user_two",
359
+ },
360
+ {
361
+ "client_id": "",
362
+ "id": "5",
363
+ "sdk": "unknown",
364
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
365
+ "user_id": "user_one",
366
+ },
367
+ {
368
+ "client_id": "client_three",
369
+ "id": "3",
370
+ "sdk": "powersync-js/1.21.2",
371
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
372
+ "user_id": "user_three",
373
+ },
374
+ {
375
+ "client_id": "client_one",
376
+ "id": "week",
377
+ "sdk": "powersync-js/1.24.5",
378
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
379
+ "user_id": "user_week",
380
+ },
381
+ ],
382
+ "more": false,
383
+ }
384
+ `;
385
+
386
+ exports[`Report storage tests > Should show paginated response of connections of specified user_id 1`] = `
387
+ {
388
+ "count": 2,
389
+ "cursor": undefined,
390
+ "items": [
391
+ {
392
+ "client_id": "client_one",
393
+ "id": "1",
394
+ "sdk": "powersync-dart/1.6.4",
395
+ "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
396
+ "user_id": "user_one",
397
+ },
398
+ {
399
+ "client_id": "",
400
+ "id": "5",
401
+ "sdk": "unknown",
402
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
403
+ "user_id": "user_one",
404
+ },
405
+ ],
406
+ "more": false,
407
+ }
408
+ `;
409
+
410
+ exports[`Report storage tests > Should show paginated response of connections over a date range 1`] = `
411
+ {
412
+ "count": 6,
413
+ "cursor": undefined,
414
+ "items": [
415
+ {
416
+ "client_id": "client_one",
417
+ "id": "1",
418
+ "sdk": "powersync-dart/1.6.4",
419
+ "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
420
+ "user_id": "user_one",
421
+ },
422
+ {
423
+ "client_id": "client_four",
424
+ "id": "4",
425
+ "sdk": "powersync-js/1.21.4",
426
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
427
+ "user_id": "user_four",
428
+ },
429
+ {
430
+ "client_id": "client_two",
431
+ "id": "2",
432
+ "sdk": "powersync-js/1.21.1",
433
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
434
+ "user_id": "user_two",
435
+ },
436
+ {
437
+ "client_id": "",
438
+ "id": "5",
439
+ "sdk": "unknown",
440
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
441
+ "user_id": "user_one",
442
+ },
443
+ {
444
+ "client_id": "client_three",
445
+ "id": "3",
446
+ "sdk": "powersync-js/1.21.2",
447
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
448
+ "user_id": "user_three",
449
+ },
450
+ {
451
+ "client_id": "client_one",
452
+ "id": "week",
453
+ "sdk": "powersync-js/1.24.5",
454
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
455
+ "user_id": "user_week",
456
+ },
457
+ ],
458
+ "more": false,
459
+ }
460
+ `;
461
+
462
+ exports[`Report storage tests > Should show paginated response of connections over a date range of specified client_id and user_id 1`] = `
463
+ {
464
+ "count": 1,
465
+ "cursor": undefined,
466
+ "items": [
467
+ {
468
+ "client_id": "client_one",
469
+ "id": "1",
470
+ "sdk": "powersync-dart/1.6.4",
471
+ "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
472
+ "user_id": "user_one",
473
+ },
474
+ ],
475
+ "more": false,
476
+ }
477
+ `;
@@ -185,7 +185,6 @@ describe('Connection report storage', async () => {
185
185
  const sdk = await factory.db
186
186
  .sql`SELECT * FROM connection_report_events WHERE user_id = ${{ type: 'varchar', value: userData.user_three.user_id }}`.rows<event_types.ClientConnection>();
187
187
  expect(sdk).toHaveLength(1);
188
- console.log(sdk[0]);
189
188
  expect(new Date((sdk[0].disconnected_at! as unknown as DateTimeValue).iso8601Representation).toISOString()).toEqual(
190
189
  disconnectAt.toISOString()
191
190
  );