@powersync/service-module-postgres-storage 0.8.4 → 0.9.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.
@@ -7,6 +7,8 @@ import {
7
7
  InternalOpId,
8
8
  internalToExternalOpId,
9
9
  LastValueSink,
10
+ maxLsn,
11
+ ReplicationCheckpoint,
10
12
  storage,
11
13
  utils,
12
14
  WatchWriteCheckpointOptions
@@ -135,18 +137,19 @@ export class PostgresSyncRulesStorage
135
137
  .decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
136
138
  .first();
137
139
 
138
- return {
139
- checkpoint: checkpointRow?.last_checkpoint ?? 0n,
140
- lsn: checkpointRow?.last_checkpoint_lsn ?? null
141
- };
140
+ return new PostgresReplicationCheckpoint(
141
+ this,
142
+ checkpointRow?.last_checkpoint ?? 0n,
143
+ checkpointRow?.last_checkpoint_lsn ?? null
144
+ );
142
145
  }
143
146
 
144
147
  async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
145
148
  const { group_id, connection_id, connection_tag, entity_descriptor } = options;
146
149
 
147
- const { schema, name: table, objectId, replicationColumns } = entity_descriptor;
150
+ const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
148
151
 
149
- const columns = replicationColumns.map((column) => ({
152
+ const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
150
153
  name: column.name,
151
154
  type: column.type,
152
155
  // The PGWire returns this as a BigInt. We want to store this as JSONB
@@ -166,7 +169,7 @@ export class PostgresSyncRulesStorage
166
169
  AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
167
170
  AND schema_name = ${{ type: 'varchar', value: schema }}
168
171
  AND table_name = ${{ type: 'varchar', value: table }}
169
- AND replica_id_columns = ${{ type: 'jsonb', value: columns }}
172
+ AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
170
173
  `
171
174
  .decoded(models.SourceTable)
172
175
  .first();
@@ -181,7 +184,7 @@ export class PostgresSyncRulesStorage
181
184
  AND connection_id = ${{ type: 'int4', value: connection_id }}
182
185
  AND schema_name = ${{ type: 'varchar', value: schema }}
183
186
  AND table_name = ${{ type: 'varchar', value: table }}
184
- AND replica_id_columns = ${{ type: 'jsonb', value: columns }}
187
+ AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
185
188
  `
186
189
  .decoded(models.SourceTable)
187
190
  .first();
@@ -208,7 +211,7 @@ export class PostgresSyncRulesStorage
208
211
  ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
209
212
  ${{ type: 'varchar', value: schema }},
210
213
  ${{ type: 'varchar', value: table }},
211
- ${{ type: 'jsonb', value: columns }}
214
+ ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
212
215
  )
213
216
  RETURNING
214
217
  *
@@ -218,15 +221,15 @@ export class PostgresSyncRulesStorage
218
221
  sourceTableRow = row;
219
222
  }
220
223
 
221
- const sourceTable = new storage.SourceTable(
222
- sourceTableRow!.id,
223
- connection_tag,
224
- objectId,
225
- schema,
226
- table,
227
- replicationColumns,
228
- sourceTableRow!.snapshot_done ?? true
229
- );
224
+ const sourceTable = new storage.SourceTable({
225
+ id: sourceTableRow!.id,
226
+ connectionTag: connection_tag,
227
+ objectId: objectId,
228
+ schema: schema,
229
+ name: table,
230
+ replicaIdColumns: replicaIdColumns,
231
+ snapshotComplete: sourceTableRow!.snapshot_done ?? true
232
+ });
230
233
  if (!sourceTable.snapshotComplete) {
231
234
  sourceTable.snapshotStatus = {
232
235
  totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
@@ -284,19 +287,20 @@ export class PostgresSyncRulesStorage
284
287
  table: sourceTable,
285
288
  dropTables: truncatedTables.map(
286
289
  (doc) =>
287
- new storage.SourceTable(
288
- doc.id,
289
- connection_tag,
290
- doc.relation_id?.object_id ?? 0,
291
- doc.schema_name,
292
- doc.table_name,
293
- doc.replica_id_columns?.map((c) => ({
294
- name: c.name,
295
- typeOid: c.typeId,
296
- type: c.type
297
- })) ?? [],
298
- doc.snapshot_done ?? true
299
- )
290
+ new storage.SourceTable({
291
+ id: doc.id,
292
+ connectionTag: connection_tag,
293
+ objectId: doc.relation_id?.object_id ?? 0,
294
+ schema: doc.schema_name,
295
+ name: doc.table_name,
296
+ replicaIdColumns:
297
+ doc.replica_id_columns?.map((c) => ({
298
+ name: c.name,
299
+ typeOid: c.typeId,
300
+ type: c.type
301
+ })) ?? [],
302
+ snapshotComplete: doc.snapshot_done ?? true
303
+ })
300
304
  )
301
305
  };
302
306
  });
@@ -310,13 +314,14 @@ export class PostgresSyncRulesStorage
310
314
  SELECT
311
315
  last_checkpoint_lsn,
312
316
  no_checkpoint_before,
313
- keepalive_op
317
+ keepalive_op,
318
+ snapshot_lsn
314
319
  FROM
315
320
  sync_rules
316
321
  WHERE
317
322
  id = ${{ type: 'int4', value: this.group_id }}
318
323
  `
319
- .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op']))
324
+ .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
320
325
  .first();
321
326
 
322
327
  const checkpoint_lsn = syncRules?.last_checkpoint_lsn ?? null;
@@ -330,6 +335,7 @@ export class PostgresSyncRulesStorage
330
335
  last_checkpoint_lsn: checkpoint_lsn,
331
336
  keep_alive_op: syncRules?.keepalive_op,
332
337
  no_checkpoint_before_lsn: syncRules?.no_checkpoint_before ?? options.zeroLSN,
338
+ resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
333
339
  store_current_data: options.storeCurrentData,
334
340
  skip_existing_rows: options.skipExistingRows ?? false,
335
341
  batch_limits: this.options.batchLimits,
@@ -347,7 +353,7 @@ export class PostgresSyncRulesStorage
347
353
  }
348
354
 
349
355
  async getParameterSets(
350
- checkpoint: utils.InternalOpId,
356
+ checkpoint: ReplicationCheckpoint,
351
357
  lookups: sync_rules.ParameterLookup[]
352
358
  ): Promise<sync_rules.SqliteJsonRow[]> {
353
359
  const rows = await this.db.sql`
@@ -370,7 +376,7 @@ export class PostgresSyncRulesStorage
370
376
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
371
377
  }}) AS FILTER
372
378
  )
373
- AND id <= ${{ type: 'int8', value: checkpoint }}
379
+ AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
374
380
  ORDER BY
375
381
  lookup,
376
382
  source_table,
@@ -644,43 +650,6 @@ export class PostgresSyncRulesStorage
644
650
  `.execute();
645
651
  }
646
652
 
647
- async autoActivate(): Promise<void> {
648
- await this.db.transaction(async (db) => {
649
- const syncRulesRow = await db.sql`
650
- SELECT
651
- state
652
- FROM
653
- sync_rules
654
- WHERE
655
- id = ${{ type: 'int4', value: this.group_id }}
656
- `
657
- .decoded(pick(models.SyncRules, ['state']))
658
- .first();
659
-
660
- if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
661
- await db.sql`
662
- UPDATE sync_rules
663
- SET
664
- state = ${{ type: 'varchar', value: storage.SyncRuleState.ACTIVE }}
665
- WHERE
666
- id = ${{ type: 'int4', value: this.group_id }}
667
- `.execute();
668
- }
669
-
670
- await db.sql`
671
- UPDATE sync_rules
672
- SET
673
- state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
674
- WHERE
675
- (
676
- state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
677
- OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
678
- )
679
- AND id != ${{ type: 'int4', value: this.group_id }}
680
- `.execute();
681
- });
682
- }
683
-
684
653
  private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
685
654
  if (batch.length == 0) {
686
655
  return new Map();
@@ -867,9 +836,18 @@ export class PostgresSyncRulesStorage
867
836
  }
868
837
 
869
838
  private makeActiveCheckpoint(row: models.ActiveCheckpointDecoded | null) {
870
- return {
871
- checkpoint: row?.last_checkpoint ?? 0n,
872
- lsn: row?.last_checkpoint_lsn ?? null
873
- } satisfies storage.ReplicationCheckpoint;
839
+ return new PostgresReplicationCheckpoint(this, row?.last_checkpoint ?? 0n, row?.last_checkpoint_lsn ?? null);
840
+ }
841
+ }
842
+
843
+ class PostgresReplicationCheckpoint implements storage.ReplicationCheckpoint {
844
+ constructor(
845
+ private storage: PostgresSyncRulesStorage,
846
+ public readonly checkpoint: utils.InternalOpId,
847
+ public readonly lsn: string | null
848
+ ) {}
849
+
850
+ getParameterSets(lookups: sync_rules.ParameterLookup[]): Promise<sync_rules.SqliteJsonRow[]> {
851
+ return this.storage.getParameterSets(this, lookups);
874
852
  }
875
853
  }
@@ -31,6 +31,7 @@ export interface PostgresBucketBatchOptions {
31
31
  no_checkpoint_before_lsn: string;
32
32
  store_current_data: boolean;
33
33
  keep_alive_op?: InternalOpId | null;
34
+ resumeFromLsn: string | null;
34
35
  /**
35
36
  * Set to true for initial replication.
36
37
  */
@@ -61,6 +62,8 @@ export class PostgresBucketBatch
61
62
 
62
63
  public last_flushed_op: InternalOpId | null = null;
63
64
 
65
+ public resumeFromLsn: string | null;
66
+
64
67
  protected db: lib_postgres.DatabaseClient;
65
68
  protected group_id: number;
66
69
  protected last_checkpoint_lsn: string | null;
@@ -73,6 +76,7 @@ export class PostgresBucketBatch
73
76
  protected batch: OperationBatch | null;
74
77
  private lastWaitingLogThrottled = 0;
75
78
  private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
79
+ private needsActivation = true;
76
80
 
77
81
  constructor(protected options: PostgresBucketBatchOptions) {
78
82
  super();
@@ -81,6 +85,7 @@ export class PostgresBucketBatch
81
85
  this.group_id = options.group_id;
82
86
  this.last_checkpoint_lsn = options.last_checkpoint_lsn;
83
87
  this.no_checkpoint_before_lsn = options.no_checkpoint_before_lsn;
88
+ this.resumeFromLsn = options.resumeFromLsn;
84
89
  this.write_checkpoint_batch = [];
85
90
  this.sync_rules = options.sync_rules;
86
91
  this.markRecordUnavailable = options.markRecordUnavailable;
@@ -321,6 +326,7 @@ export class PostgresBucketBatch
321
326
  // Don't create a checkpoint if there were no changes
322
327
  if (!createEmptyCheckpoints && this.persisted_op == null) {
323
328
  // Nothing to commit - return true
329
+ await this.autoActivate(lsn);
324
330
  return true;
325
331
  }
326
332
 
@@ -363,6 +369,7 @@ export class PostgresBucketBatch
363
369
  .decoded(StatefulCheckpoint)
364
370
  .first();
365
371
 
372
+ await this.autoActivate(lsn);
366
373
  await notifySyncRulesUpdate(this.db, doc!);
367
374
 
368
375
  this.persisted_op = null;
@@ -406,13 +413,14 @@ export class PostgresBucketBatch
406
413
  .decoded(StatefulCheckpoint)
407
414
  .first();
408
415
 
416
+ await this.autoActivate(lsn);
409
417
  await notifySyncRulesUpdate(this.db, updated!);
410
418
 
411
419
  this.last_checkpoint_lsn = lsn;
412
420
  return true;
413
421
  }
414
422
 
415
- async setSnapshotLsn(lsn: string): Promise<void> {
423
+ async setResumeLsn(lsn: string): Promise<void> {
416
424
  await this.db.sql`
417
425
  UPDATE sync_rules
418
426
  SET
@@ -459,15 +467,15 @@ export class PostgresBucketBatch
459
467
  }
460
468
  });
461
469
  return tables.map((table) => {
462
- const copy = new storage.SourceTable(
463
- table.id,
464
- table.connectionTag,
465
- table.objectId,
466
- table.schema,
467
- table.table,
468
- table.replicaIdColumns,
469
- table.snapshotComplete
470
- );
470
+ const copy = new storage.SourceTable({
471
+ id: table.id,
472
+ connectionTag: table.connectionTag,
473
+ objectId: table.objectId,
474
+ schema: table.schema,
475
+ name: table.name,
476
+ replicaIdColumns: table.replicaIdColumns,
477
+ snapshotComplete: table.snapshotComplete
478
+ });
471
479
  copy.syncData = table.syncData;
472
480
  copy.syncParameters = table.syncParameters;
473
481
  return copy;
@@ -916,6 +924,59 @@ export class PostgresBucketBatch
916
924
  return result;
917
925
  }
918
926
 
927
+ /**
928
+ * Switch from processing -> active if relevant.
929
+ *
930
+ * Called on new commits.
931
+ */
932
+ private async autoActivate(lsn: string): Promise<void> {
933
+ if (!this.needsActivation) {
934
+ // Already activated
935
+ return;
936
+ }
937
+
938
+ let didActivate = false;
939
+ await this.db.transaction(async (db) => {
940
+ const syncRulesRow = await db.sql`
941
+ SELECT
942
+ state
943
+ FROM
944
+ sync_rules
945
+ WHERE
946
+ id = ${{ type: 'int4', value: this.group_id }}
947
+ `
948
+ .decoded(pick(models.SyncRules, ['state']))
949
+ .first();
950
+
951
+ if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
952
+ await db.sql`
953
+ UPDATE sync_rules
954
+ SET
955
+ state = ${{ type: 'varchar', value: storage.SyncRuleState.ACTIVE }}
956
+ WHERE
957
+ id = ${{ type: 'int4', value: this.group_id }}
958
+ `.execute();
959
+ didActivate = true;
960
+ }
961
+
962
+ await db.sql`
963
+ UPDATE sync_rules
964
+ SET
965
+ state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
966
+ WHERE
967
+ (
968
+ state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
969
+ OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
970
+ )
971
+ AND id != ${{ type: 'int4', value: this.group_id }}
972
+ `.execute();
973
+ });
974
+ if (didActivate) {
975
+ this.logger.info(`Activated new sync rules at ${lsn}`);
976
+ }
977
+ this.needsActivation = false;
978
+ }
979
+
919
980
  /**
920
981
  * Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
921
982
  * TODO maybe share this with an abstract class