@powersync/service-module-postgres-storage 0.13.4 → 0.15.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 (27) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/storage/PostgresSyncRulesStorage.d.ts +4 -4
  4. package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +12 -3
  5. package/dist/migrations/scripts/1771424826685-current-data-pending-deletes.js +1 -1
  6. package/dist/storage/PostgresBucketStorageFactory.js +5 -5
  7. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  8. package/dist/storage/PostgresSyncRulesStorage.js +78 -197
  9. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  10. package/dist/storage/batch/PostgresBucketBatch.js +265 -15
  11. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  12. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +1 -1
  13. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  14. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js +3 -3
  15. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js.map +1 -1
  16. package/package.json +11 -11
  17. package/src/migrations/scripts/1771424826685-current-data-pending-deletes.ts +1 -1
  18. package/src/storage/PostgresBucketStorageFactory.ts +6 -5
  19. package/src/storage/PostgresSyncRulesStorage.ts +90 -209
  20. package/src/storage/batch/PostgresBucketBatch.ts +308 -26
  21. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +3 -1
  22. package/src/storage/sync-rules/PostgresPersistedSyncRulesContent.ts +3 -6
  23. package/test/tsconfig.json +0 -1
  24. package/dist/@types/storage/current-data-table.d.ts +0 -9
  25. package/dist/storage/current-data-table.js +0 -22
  26. package/dist/storage/current-data-table.js.map +0 -1
  27. package/src/storage/current-data-table.ts +0 -26
@@ -9,6 +9,7 @@ import {
9
9
  internalToExternalOpId,
10
10
  LastValueSink,
11
11
  maxLsn,
12
+ ParameterSetLimitExceededError,
12
13
  PartialChecksum,
13
14
  PopulateChecksumCacheOptions,
14
15
  PopulateChecksumCacheResults,
@@ -21,8 +22,7 @@ import {
21
22
  import { JSONBig } from '@powersync/service-jsonbig';
22
23
  import * as sync_rules from '@powersync/service-sync-rules';
23
24
  import * as timers from 'timers/promises';
24
- import * as uuid from 'uuid';
25
- import { BIGINT_MAX } from '../types/codecs.js';
25
+ import { bigint, BIGINT_MAX } from '../types/codecs.js';
26
26
  import { models, RequiredOperationBatchLimits } from '../types/types.js';
27
27
  import { replicaIdToSubkey } from '../utils/bson.js';
28
28
  import { mapOpEntry } from '../utils/bucket-data.js';
@@ -30,7 +30,7 @@ import { mapOpEntry } from '../utils/bucket-data.js';
30
30
  import * as framework from '@powersync/lib-services-framework';
31
31
  import { StatementParam } from '@powersync/service-jpgwire';
32
32
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
33
- import { SourceTableDecoded, StoredRelationId } from '../types/models/SourceTable.js';
33
+ import * as t from 'ts-codec';
34
34
  import { pick } from '../utils/ts-codec.js';
35
35
  import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
36
36
  import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
@@ -57,6 +57,7 @@ export class PostgresSyncRulesStorage
57
57
  public readonly slot_name: string;
58
58
  public readonly factory: PostgresBucketStorageFactory;
59
59
  public readonly storageConfig: StorageVersionConfig;
60
+ public readonly logger: framework.Logger;
60
61
 
61
62
  private sharedIterator = new BroadcastIterable((signal) => this.watchActiveCheckpoint(signal));
62
63
 
@@ -66,7 +67,7 @@ export class PostgresSyncRulesStorage
66
67
 
67
68
  // TODO we might be able to share this in an abstract class
68
69
  private parsedSyncRulesCache:
69
- | { parsed: sync_rules.HydratedSyncRules; options: storage.ParseSyncRulesOptions }
70
+ | { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
70
71
  | undefined;
71
72
  private _checksumCache: storage.ChecksumCache | undefined;
72
73
 
@@ -79,6 +80,7 @@ export class PostgresSyncRulesStorage
79
80
  this.factory = options.factory;
80
81
  this.storageConfig = options.sync_rules.getStorageConfig();
81
82
  this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
83
+ this.logger = options.sync_rules.logger;
82
84
 
83
85
  this.writeCheckpointAPI = new PostgresWriteCheckpointAPI({
84
86
  db: this.db,
@@ -105,14 +107,14 @@ export class PostgresSyncRulesStorage
105
107
  }
106
108
 
107
109
  // TODO we might be able to share this in an abstract class
108
- getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncRules {
110
+ getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
109
111
  const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
110
112
  /**
111
- * Check if the cached sync rules, if present, had the same options.
112
- * Parse sync rules if the options are different or if there is no cached value.
113
+ * Check if the cached sync config, if present, had the same options.
114
+ * Parse sync config if the options are different or if there is no cached value.
113
115
  */
114
116
  if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
115
- this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncRules(), options };
117
+ this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
116
118
  }
117
119
 
118
120
  return this.parsedSyncRulesCache!.parsed;
@@ -139,11 +141,12 @@ export class PostgresSyncRulesStorage
139
141
 
140
142
  return new PostgresCompactor(this.db, this.group_id, {
141
143
  ...options,
142
- maxOpId
144
+ maxOpId,
145
+ logger: this.logger
143
146
  }).compact();
144
147
  }
145
148
 
146
- async populatePersistentChecksumCache(options: PopulateChecksumCacheOptions): Promise<PopulateChecksumCacheResults> {
149
+ async populatePersistentChecksumCache(_options: PopulateChecksumCacheOptions): Promise<PopulateChecksumCacheResults> {
147
150
  // no-op - checksum cache is not implemented for Postgres yet
148
151
  return { buckets: 0 };
149
152
  }
@@ -183,168 +186,6 @@ export class PostgresSyncRulesStorage
183
186
  );
184
187
  }
185
188
 
186
- async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
187
- const { group_id, connection_id, connection_tag, entity_descriptor } = options;
188
-
189
- const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
190
-
191
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
192
- name: column.name,
193
- type: column.type,
194
- // The PGWire returns this as a BigInt. We want to store this as JSONB
195
- type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
196
- }));
197
- return this.db.transaction(async (db) => {
198
- let sourceTableRow: SourceTableDecoded | null;
199
- if (objectId != null) {
200
- sourceTableRow = await db.sql`
201
- SELECT
202
- *
203
- FROM
204
- source_tables
205
- WHERE
206
- group_id = ${{ type: 'int4', value: group_id }}
207
- AND connection_id = ${{ type: 'int4', value: connection_id }}
208
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
209
- AND schema_name = ${{ type: 'varchar', value: schema }}
210
- AND table_name = ${{ type: 'varchar', value: table }}
211
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
212
- `
213
- .decoded(models.SourceTable)
214
- .first();
215
- } else {
216
- sourceTableRow = await db.sql`
217
- SELECT
218
- *
219
- FROM
220
- source_tables
221
- WHERE
222
- group_id = ${{ type: 'int4', value: group_id }}
223
- AND connection_id = ${{ type: 'int4', value: connection_id }}
224
- AND schema_name = ${{ type: 'varchar', value: schema }}
225
- AND table_name = ${{ type: 'varchar', value: table }}
226
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
227
- `
228
- .decoded(models.SourceTable)
229
- .first();
230
- }
231
-
232
- if (sourceTableRow == null) {
233
- const row = await db.sql`
234
- INSERT INTO
235
- source_tables (
236
- id,
237
- group_id,
238
- connection_id,
239
- relation_id,
240
- schema_name,
241
- table_name,
242
- replica_id_columns
243
- )
244
- VALUES
245
- (
246
- ${{ type: 'varchar', value: uuid.v4() }},
247
- ${{ type: 'int4', value: group_id }},
248
- ${{ type: 'int4', value: connection_id }},
249
- --- The objectId can be string | number | undefined, we store it as jsonb value
250
- ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
251
- ${{ type: 'varchar', value: schema }},
252
- ${{ type: 'varchar', value: table }},
253
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
254
- )
255
- RETURNING
256
- *
257
- `
258
- .decoded(models.SourceTable)
259
- .first();
260
- sourceTableRow = row;
261
- }
262
-
263
- const sourceTable = new storage.SourceTable({
264
- id: sourceTableRow!.id,
265
- connectionTag: connection_tag,
266
- objectId: objectId,
267
- schema: schema,
268
- name: table,
269
- replicaIdColumns: replicaIdColumns,
270
- snapshotComplete: sourceTableRow!.snapshot_done ?? true
271
- });
272
- if (!sourceTable.snapshotComplete) {
273
- sourceTable.snapshotStatus = {
274
- totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
275
- replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
276
- lastKey: sourceTableRow!.snapshot_last_key
277
- };
278
- }
279
- sourceTable.syncEvent = options.sync_rules.tableTriggersEvent(sourceTable);
280
- sourceTable.syncData = options.sync_rules.tableSyncsData(sourceTable);
281
- sourceTable.syncParameters = options.sync_rules.tableSyncsParameters(sourceTable);
282
-
283
- let truncatedTables: SourceTableDecoded[] = [];
284
- if (objectId != null) {
285
- // relation_id present - check for renamed tables
286
- truncatedTables = await db.sql`
287
- SELECT
288
- *
289
- FROM
290
- source_tables
291
- WHERE
292
- group_id = ${{ type: 'int4', value: group_id }}
293
- AND connection_id = ${{ type: 'int4', value: connection_id }}
294
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
295
- AND (
296
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
297
- OR (
298
- schema_name = ${{ type: 'varchar', value: schema }}
299
- AND table_name = ${{ type: 'varchar', value: table }}
300
- )
301
- )
302
- `
303
- .decoded(models.SourceTable)
304
- .rows();
305
- } else {
306
- // relation_id not present - only check for changed replica_id_columns
307
- truncatedTables = await db.sql`
308
- SELECT
309
- *
310
- FROM
311
- source_tables
312
- WHERE
313
- group_id = ${{ type: 'int4', value: group_id }}
314
- AND connection_id = ${{ type: 'int4', value: connection_id }}
315
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
316
- AND (
317
- schema_name = ${{ type: 'varchar', value: schema }}
318
- AND table_name = ${{ type: 'varchar', value: table }}
319
- )
320
- `
321
- .decoded(models.SourceTable)
322
- .rows();
323
- }
324
-
325
- return {
326
- table: sourceTable,
327
- dropTables: truncatedTables.map(
328
- (doc) =>
329
- new storage.SourceTable({
330
- id: doc.id,
331
- connectionTag: connection_tag,
332
- objectId: doc.relation_id?.object_id ?? 0,
333
- schema: doc.schema_name,
334
- name: doc.table_name,
335
- replicaIdColumns:
336
- doc.replica_id_columns?.map((c) => ({
337
- name: c.name,
338
- typeOid: c.typeId,
339
- type: c.type
340
- })) ?? [],
341
- snapshotComplete: doc.snapshot_done ?? true
342
- })
343
- )
344
- };
345
- });
346
- }
347
-
348
189
  async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
349
190
  const syncRules = await this.db.sql`
350
191
  SELECT
@@ -363,9 +204,9 @@ export class PostgresSyncRulesStorage
363
204
  const checkpoint_lsn = syncRules?.last_checkpoint_lsn ?? null;
364
205
 
365
206
  const writer = new PostgresBucketBatch({
366
- logger: options.logger ?? framework.logger,
207
+ logger: options.logger ?? this.logger,
367
208
  db: this.db,
368
- sync_rules: this.sync_rules.parsed(options).hydratedSyncRules(),
209
+ sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
369
210
  group_id: this.group_id,
370
211
  slot_name: this.slot_name,
371
212
  last_checkpoint_lsn: checkpoint_lsn,
@@ -375,6 +216,7 @@ export class PostgresSyncRulesStorage
375
216
  skip_existing_rows: options.skipExistingRows ?? false,
376
217
  batch_limits: this.options.batchLimits,
377
218
  markRecordUnavailable: options.markRecordUnavailable,
219
+ hooks: options.hooks,
378
220
  storageConfig: this.storageConfig
379
221
  });
380
222
  this.iterateListeners((cb) => cb.batchStarted?.(writer));
@@ -396,42 +238,69 @@ export class PostgresSyncRulesStorage
396
238
 
397
239
  async getParameterSets(
398
240
  checkpoint: ReplicationCheckpoint,
399
- lookups: sync_rules.ScopedParameterLookup[]
400
- ): Promise<sync_rules.SqliteJsonRow[]> {
241
+ lookups: sync_rules.ScopedParameterLookup[],
242
+ limit: number
243
+ ): Promise<sync_rules.ParameterLookupRows[]> {
401
244
  const rows = await this.db.sql`
402
- SELECT DISTINCT
403
- ON (lookup, source_table, source_key) lookup,
404
- source_table,
405
- source_key,
406
- id,
407
- bucket_parameters
408
- FROM
409
- bucket_parameters
410
- WHERE
411
- group_id = ${{ type: 'int4', value: this.group_id }}
412
- AND lookup = ANY (
413
- SELECT
414
- decode((FILTER ->> 0)::text, 'hex') -- Decode the hex string to bytea
245
+ WITH
246
+ rows AS (
247
+ SELECT DISTINCT
248
+ ON (lookup, source_table, source_key) requested.index - 1 AS index,
249
+ bucket_parameters
415
250
  FROM
251
+ bucket_parameters,
416
252
  jsonb_array_elements(${{
417
253
  type: 'jsonb',
418
254
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
419
- }}) AS FILTER
255
+ }}) WITH ORDINALITY AS requested (value, index)
256
+ WHERE
257
+ group_id = ${{ type: 'int4', value: this.group_id }}
258
+ AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
259
+ AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
260
+ ORDER BY
261
+ lookup,
262
+ source_table,
263
+ source_key,
264
+ id DESC
420
265
  )
421
- AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
422
- ORDER BY
423
- lookup,
424
- source_table,
425
- source_key,
426
- id DESC
266
+ SELECT
267
+ index,
268
+ bucket_parameters
269
+ FROM
270
+ rows
271
+ WHERE
272
+ bucket_parameters != '[]'
273
+ LIMIT
274
+ ${{ type: 'int4', value: limit + 1 }}
427
275
  `
428
- .decoded(pick(models.BucketParameters, ['bucket_parameters']))
276
+ .decoded(parameterSetsRow)
429
277
  .rows();
430
278
 
431
- const groupedParameters = rows.map((row) => {
432
- return JSONBig.parse(row.bucket_parameters) as sync_rules.SqliteJsonRow;
433
- });
434
- return groupedParameters.flat();
279
+ let totalRows = 0;
280
+ const resultsByLookup = new Map<sync_rules.ScopedParameterLookup, sync_rules.SqliteJsonRow[]>();
281
+ for (const row of rows) {
282
+ const parameterRows = JSONBig.parse(row.bucket_parameters) as sync_rules.SqliteJsonRow[];
283
+ const lookup = lookups[Number(row.index)];
284
+ totalRows += parameterRows.length;
285
+
286
+ const existingResults = resultsByLookup.get(lookup);
287
+ if (existingResults != null) {
288
+ existingResults.push(...parameterRows);
289
+ } else {
290
+ resultsByLookup.set(lookup, parameterRows);
291
+ }
292
+ }
293
+
294
+ if (totalRows > limit) {
295
+ // Note that the LIMIT in the query allows more rows than parameters (because each row stores an array of
296
+ // parameter results). That array is very small though, and it doesn't allow fewer rows (due to the != []), so
297
+ // the SQL limit is good enough.
298
+ throw new ParameterSetLimitExceededError(limit);
299
+ }
300
+
301
+ const results: sync_rules.ParameterLookupRows[] = [];
302
+ resultsByLookup.forEach((rows, lookup) => results.push({ lookup, rows }));
303
+ return results;
435
304
  }
436
305
 
437
306
  async *getBucketDataBatch(
@@ -638,24 +507,28 @@ export class PostgresSyncRulesStorage
638
507
  snapshot_done,
639
508
  snapshot_lsn,
640
509
  last_checkpoint_lsn,
641
- state
510
+ state,
511
+ keepalive_op
642
512
  FROM
643
513
  sync_rules
644
514
  WHERE
645
515
  id = ${{ type: 'int4', value: this.group_id }}
646
516
  `
647
- .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn']))
517
+ .decoded(
518
+ pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
519
+ )
648
520
  .first();
649
521
 
650
522
  if (syncRulesRow == null) {
651
- throw new Error('Cannot find sync rules status');
523
+ throw new Error('Cannot find replication stream status');
652
524
  }
653
525
 
654
526
  return {
655
527
  snapshot_done: syncRulesRow.snapshot_done,
656
528
  active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
657
529
  checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
658
- snapshot_lsn: syncRulesRow.snapshot_lsn ?? null
530
+ snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
531
+ keepalive_op: syncRulesRow.keepalive_op ?? null
659
532
  };
660
533
  }
661
534
 
@@ -841,7 +714,7 @@ export class PostgresSyncRulesStorage
841
714
 
842
715
  if (doc == null) {
843
716
  // Abort the connections - clients will have to retry later.
844
- throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active sync rules available');
717
+ throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
845
718
  }
846
719
 
847
720
  const sink = new LastValueSink<string>(undefined);
@@ -868,7 +741,7 @@ export class PostgresSyncRulesStorage
868
741
  continue;
869
742
  }
870
743
  if (Number(notification.active_checkpoint.id) != doc.id) {
871
- // Active sync rules changed - abort and restart the stream
744
+ // Active replication stream changed - abort and restart the stream
872
745
  break;
873
746
  }
874
747
 
@@ -898,7 +771,15 @@ class PostgresReplicationCheckpoint implements storage.ReplicationCheckpoint {
898
771
  public readonly lsn: string | null
899
772
  ) {}
900
773
 
901
- getParameterSets(lookups: sync_rules.ScopedParameterLookup[]): Promise<sync_rules.SqliteJsonRow[]> {
902
- return this.storage.getParameterSets(this, lookups);
774
+ getParameterSets(
775
+ lookups: sync_rules.ScopedParameterLookup[],
776
+ limit: number
777
+ ): Promise<sync_rules.ParameterLookupRows[]> {
778
+ return this.storage.getParameterSets(this, lookups, limit);
903
779
  }
904
780
  }
781
+
782
+ const parameterSetsRow = t.object({
783
+ index: bigint,
784
+ bucket_parameters: t.string
785
+ });