@powersync/service-module-postgres-storage 0.0.0-dev-20260511080634 → 0.0.0-dev-20260601140105

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.
@@ -22,8 +22,7 @@ import {
22
22
  import { JSONBig } from '@powersync/service-jsonbig';
23
23
  import * as sync_rules from '@powersync/service-sync-rules';
24
24
  import * as timers from 'timers/promises';
25
- import * as uuid from 'uuid';
26
- import { BIGINT_MAX } from '../types/codecs.js';
25
+ import { bigint, BIGINT_MAX } from '../types/codecs.js';
27
26
  import { models, RequiredOperationBatchLimits } from '../types/types.js';
28
27
  import { replicaIdToSubkey } from '../utils/bson.js';
29
28
  import { mapOpEntry } from '../utils/bucket-data.js';
@@ -31,7 +30,7 @@ import { mapOpEntry } from '../utils/bucket-data.js';
31
30
  import * as framework from '@powersync/lib-services-framework';
32
31
  import { StatementParam } from '@powersync/service-jpgwire';
33
32
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
34
- import { SourceTableDecoded, StoredRelationId } from '../types/models/SourceTable.js';
33
+ import * as t from 'ts-codec';
35
34
  import { pick } from '../utils/ts-codec.js';
36
35
  import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
37
36
  import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
@@ -68,7 +67,7 @@ export class PostgresSyncRulesStorage
68
67
 
69
68
  // TODO we might be able to share this in an abstract class
70
69
  private parsedSyncRulesCache:
71
- | { parsed: sync_rules.HydratedSyncRules; options: storage.ParseSyncRulesOptions }
70
+ | { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
72
71
  | undefined;
73
72
  private _checksumCache: storage.ChecksumCache | undefined;
74
73
 
@@ -108,14 +107,14 @@ export class PostgresSyncRulesStorage
108
107
  }
109
108
 
110
109
  // TODO we might be able to share this in an abstract class
111
- getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncRules {
110
+ getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
112
111
  const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
113
112
  /**
114
113
  * Check if the cached sync config, if present, had the same options.
115
114
  * Parse sync config if the options are different or if there is no cached value.
116
115
  */
117
116
  if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
118
- this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncRules(), options };
117
+ this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
119
118
  }
120
119
 
121
120
  return this.parsedSyncRulesCache!.parsed;
@@ -187,168 +186,6 @@ export class PostgresSyncRulesStorage
187
186
  );
188
187
  }
189
188
 
190
- async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
191
- const { group_id, connection_id, connection_tag, entity_descriptor } = options;
192
-
193
- const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
194
-
195
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
196
- name: column.name,
197
- type: column.type,
198
- // The PGWire returns this as a BigInt. We want to store this as JSONB
199
- type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
200
- }));
201
- return this.db.transaction(async (db) => {
202
- let sourceTableRow: SourceTableDecoded | null;
203
- if (objectId != null) {
204
- sourceTableRow = await db.sql`
205
- SELECT
206
- *
207
- FROM
208
- source_tables
209
- WHERE
210
- group_id = ${{ type: 'int4', value: group_id }}
211
- AND connection_id = ${{ type: 'int4', value: connection_id }}
212
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
213
- AND schema_name = ${{ type: 'varchar', value: schema }}
214
- AND table_name = ${{ type: 'varchar', value: table }}
215
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
216
- `
217
- .decoded(models.SourceTable)
218
- .first();
219
- } else {
220
- sourceTableRow = await db.sql`
221
- SELECT
222
- *
223
- FROM
224
- source_tables
225
- WHERE
226
- group_id = ${{ type: 'int4', value: group_id }}
227
- AND connection_id = ${{ type: 'int4', value: connection_id }}
228
- AND schema_name = ${{ type: 'varchar', value: schema }}
229
- AND table_name = ${{ type: 'varchar', value: table }}
230
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
231
- `
232
- .decoded(models.SourceTable)
233
- .first();
234
- }
235
-
236
- if (sourceTableRow == null) {
237
- const row = await db.sql`
238
- INSERT INTO
239
- source_tables (
240
- id,
241
- group_id,
242
- connection_id,
243
- relation_id,
244
- schema_name,
245
- table_name,
246
- replica_id_columns
247
- )
248
- VALUES
249
- (
250
- ${{ type: 'varchar', value: uuid.v4() }},
251
- ${{ type: 'int4', value: group_id }},
252
- ${{ type: 'int4', value: connection_id }},
253
- --- The objectId can be string | number | undefined, we store it as jsonb value
254
- ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
255
- ${{ type: 'varchar', value: schema }},
256
- ${{ type: 'varchar', value: table }},
257
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
258
- )
259
- RETURNING
260
- *
261
- `
262
- .decoded(models.SourceTable)
263
- .first();
264
- sourceTableRow = row;
265
- }
266
-
267
- const sourceTable = new storage.SourceTable({
268
- id: sourceTableRow!.id,
269
- connectionTag: connection_tag,
270
- objectId: objectId,
271
- schema: schema,
272
- name: table,
273
- replicaIdColumns: replicaIdColumns,
274
- snapshotComplete: sourceTableRow!.snapshot_done ?? true
275
- });
276
- if (!sourceTable.snapshotComplete) {
277
- sourceTable.snapshotStatus = {
278
- totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
279
- replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
280
- lastKey: sourceTableRow!.snapshot_last_key
281
- };
282
- }
283
- sourceTable.syncEvent = options.sync_rules.tableTriggersEvent(sourceTable);
284
- sourceTable.syncData = options.sync_rules.tableSyncsData(sourceTable);
285
- sourceTable.syncParameters = options.sync_rules.tableSyncsParameters(sourceTable);
286
-
287
- let truncatedTables: SourceTableDecoded[] = [];
288
- if (objectId != null) {
289
- // relation_id present - check for renamed tables
290
- truncatedTables = await db.sql`
291
- SELECT
292
- *
293
- FROM
294
- source_tables
295
- WHERE
296
- group_id = ${{ type: 'int4', value: group_id }}
297
- AND connection_id = ${{ type: 'int4', value: connection_id }}
298
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
299
- AND (
300
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
301
- OR (
302
- schema_name = ${{ type: 'varchar', value: schema }}
303
- AND table_name = ${{ type: 'varchar', value: table }}
304
- )
305
- )
306
- `
307
- .decoded(models.SourceTable)
308
- .rows();
309
- } else {
310
- // relation_id not present - only check for changed replica_id_columns
311
- truncatedTables = await db.sql`
312
- SELECT
313
- *
314
- FROM
315
- source_tables
316
- WHERE
317
- group_id = ${{ type: 'int4', value: group_id }}
318
- AND connection_id = ${{ type: 'int4', value: connection_id }}
319
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
320
- AND (
321
- schema_name = ${{ type: 'varchar', value: schema }}
322
- AND table_name = ${{ type: 'varchar', value: table }}
323
- )
324
- `
325
- .decoded(models.SourceTable)
326
- .rows();
327
- }
328
-
329
- return {
330
- table: sourceTable,
331
- dropTables: truncatedTables.map(
332
- (doc) =>
333
- new storage.SourceTable({
334
- id: doc.id,
335
- connectionTag: connection_tag,
336
- objectId: doc.relation_id?.object_id ?? 0,
337
- schema: doc.schema_name,
338
- name: doc.table_name,
339
- replicaIdColumns:
340
- doc.replica_id_columns?.map((c) => ({
341
- name: c.name,
342
- typeOid: c.typeId,
343
- type: c.type
344
- })) ?? [],
345
- snapshotComplete: doc.snapshot_done ?? true
346
- })
347
- )
348
- };
349
- });
350
- }
351
-
352
189
  async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
353
190
  const syncRules = await this.db.sql`
354
191
  SELECT
@@ -369,7 +206,7 @@ export class PostgresSyncRulesStorage
369
206
  const writer = new PostgresBucketBatch({
370
207
  logger: options.logger ?? this.logger,
371
208
  db: this.db,
372
- sync_rules: this.sync_rules.parsed(options).hydratedSyncRules(),
209
+ sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
373
210
  group_id: this.group_id,
374
211
  slot_name: this.slot_name,
375
212
  last_checkpoint_lsn: checkpoint_lsn,
@@ -379,6 +216,7 @@ export class PostgresSyncRulesStorage
379
216
  skip_existing_rows: options.skipExistingRows ?? false,
380
217
  batch_limits: this.options.batchLimits,
381
218
  markRecordUnavailable: options.markRecordUnavailable,
219
+ hooks: options.hooks,
382
220
  storageConfig: this.storageConfig
383
221
  });
384
222
  this.iterateListeners((cb) => cb.batchStarted?.(writer));
@@ -402,29 +240,22 @@ export class PostgresSyncRulesStorage
402
240
  checkpoint: ReplicationCheckpoint,
403
241
  lookups: sync_rules.ScopedParameterLookup[],
404
242
  limit: number
405
- ): Promise<sync_rules.SqliteJsonRow[]> {
243
+ ): Promise<sync_rules.ParameterLookupRows[]> {
406
244
  const rows = await this.db.sql`
407
245
  WITH
408
246
  rows AS (
409
247
  SELECT DISTINCT
410
- ON (lookup, source_table, source_key) lookup,
411
- source_table,
412
- source_key,
413
- id,
248
+ ON (lookup, source_table, source_key) requested.index - 1 AS index,
414
249
  bucket_parameters
415
250
  FROM
416
- bucket_parameters
417
- WHERE
418
- group_id = ${{ type: 'int4', value: this.group_id }}
419
- AND lookup = ANY (
420
- SELECT
421
- decode((FILTER ->> 0)::text, 'hex') -- Decode the hex string to bytea
422
- FROM
423
- jsonb_array_elements(${{
251
+ bucket_parameters,
252
+ jsonb_array_elements(${{
424
253
  type: 'jsonb',
425
254
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
426
- }}) AS FILTER
427
- )
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
428
259
  AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
429
260
  ORDER BY
430
261
  lookup,
@@ -433,6 +264,7 @@ export class PostgresSyncRulesStorage
433
264
  id DESC
434
265
  )
435
266
  SELECT
267
+ index,
436
268
  bucket_parameters
437
269
  FROM
438
270
  rows
@@ -441,22 +273,34 @@ export class PostgresSyncRulesStorage
441
273
  LIMIT
442
274
  ${{ type: 'int4', value: limit + 1 }}
443
275
  `
444
- .decoded(pick(models.BucketParameters, ['bucket_parameters']))
276
+ .decoded(parameterSetsRow)
445
277
  .rows();
446
278
 
447
- const parameters = rows
448
- .map((row) => {
449
- return JSONBig.parse(row.bucket_parameters) as sync_rules.SqliteJsonRow[];
450
- })
451
- .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
+ }
452
293
 
453
- if (parameters.length > limit) {
294
+ if (totalRows > limit) {
454
295
  // Note that the LIMIT in the query allows more rows than parameters (because each row stores an array of
455
296
  // parameter results). That array is very small though, and it doesn't allow fewer rows (due to the != []), so
456
297
  // the SQL limit is good enough.
457
298
  throw new ParameterSetLimitExceededError(limit);
458
299
  }
459
- return parameters;
300
+
301
+ const results: sync_rules.ParameterLookupRows[] = [];
302
+ resultsByLookup.forEach((rows, lookup) => results.push({ lookup, rows }));
303
+ return results;
460
304
  }
461
305
 
462
306
  async *getBucketDataBatch(
@@ -663,13 +507,16 @@ export class PostgresSyncRulesStorage
663
507
  snapshot_done,
664
508
  snapshot_lsn,
665
509
  last_checkpoint_lsn,
666
- state
510
+ state,
511
+ keepalive_op
667
512
  FROM
668
513
  sync_rules
669
514
  WHERE
670
515
  id = ${{ type: 'int4', value: this.group_id }}
671
516
  `
672
- .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
+ )
673
520
  .first();
674
521
 
675
522
  if (syncRulesRow == null) {
@@ -680,7 +527,8 @@ export class PostgresSyncRulesStorage
680
527
  snapshot_done: syncRulesRow.snapshot_done,
681
528
  active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
682
529
  checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
683
- snapshot_lsn: syncRulesRow.snapshot_lsn ?? null
530
+ snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
531
+ keepalive_op: syncRulesRow.keepalive_op ?? null
684
532
  };
685
533
  }
686
534
 
@@ -923,7 +771,15 @@ class PostgresReplicationCheckpoint implements storage.ReplicationCheckpoint {
923
771
  public readonly lsn: string | null
924
772
  ) {}
925
773
 
926
- getParameterSets(lookups: sync_rules.ScopedParameterLookup[], limit: number): Promise<sync_rules.SqliteJsonRow[]> {
774
+ getParameterSets(
775
+ lookups: sync_rules.ScopedParameterLookup[],
776
+ limit: number
777
+ ): Promise<sync_rules.ParameterLookupRows[]> {
927
778
  return this.storage.getParameterSets(this, lookups, limit);
928
779
  }
929
780
  }
781
+
782
+ const parameterSetsRow = t.object({
783
+ index: bigint,
784
+ bucket_parameters: t.string
785
+ });