@powersync/service-module-postgres-storage 0.16.3 → 0.18.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 (52) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
  3. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
  4. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
  5. package/dist/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
  6. package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
  7. package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
  8. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  9. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  10. package/dist/storage/PostgresCompactor.d.ts +2 -0
  11. package/dist/storage/PostgresCompactor.js +20 -0
  12. package/dist/storage/PostgresCompactor.js.map +1 -1
  13. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  14. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  15. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  16. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  17. package/dist/storage/batch/PostgresBucketBatch.js +217 -240
  18. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  19. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  20. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  21. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  22. package/dist/storage/current-data-store.d.ts +8 -2
  23. package/dist/storage/current-data-store.js +66 -11
  24. package/dist/storage/current-data-store.js.map +1 -1
  25. package/dist/types/models/SourceTable.d.ts +7 -2
  26. package/dist/types/models/SourceTable.js +6 -2
  27. package/dist/types/models/SourceTable.js.map +1 -1
  28. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  29. package/dist/types/models/WriteCheckpoint.js +5 -2
  30. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  31. package/dist/utils/checkpoints.d.ts +9 -0
  32. package/dist/utils/checkpoints.js +26 -0
  33. package/dist/utils/checkpoints.js.map +1 -0
  34. package/package.json +8 -8
  35. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  36. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  37. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  38. package/src/storage/PostgresCompactor.ts +24 -0
  39. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  40. package/src/storage/batch/PostgresBucketBatch.ts +227 -246
  41. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  42. package/src/storage/current-data-store.ts +66 -11
  43. package/src/types/models/SourceTable.ts +7 -2
  44. package/src/types/models/WriteCheckpoint.ts +5 -2
  45. package/src/utils/checkpoints.ts +31 -0
  46. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  47. package/test/src/checkpoint_notifications.test.ts +522 -0
  48. package/test/src/storage.test.ts +214 -5
  49. package/test/src/storage_compacting.test.ts +3 -3
  50. package/test/src/storage_sync.test.ts +48 -4
  51. package/test/tsconfig.json +1 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -60,7 +60,6 @@ export interface PostgresBucketBatchOptions {
60
60
  * via the Postgres NOTIFY protocol.
61
61
  */
62
62
  const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
63
- type StatefulCheckpointDecoded = t.Decoded<typeof StatefulCheckpoint>;
64
63
 
65
64
  const CheckpointWithStatus = StatefulCheckpoint.and(
66
65
  t.object({
@@ -145,6 +144,7 @@ export class PostgresBucketBatch
145
144
 
146
145
  async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
147
146
  const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
147
+ const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
148
148
  const { connection_id, source } = options;
149
149
  const { schema, name: table, objectId, replicaIdColumns, connectionTag, sendsCompleteRows } = source;
150
150
 
@@ -155,9 +155,10 @@ export class PostgresBucketBatch
155
155
  }));
156
156
 
157
157
  return this.db.transaction(async (db) => {
158
- let sourceTableRow: SourceTableDecoded | null;
158
+ // Find records that overlap by name or relation id.
159
+ let candidateRows: SourceTableDecoded[];
159
160
  if (objectId != null) {
160
- sourceTableRow = await db.sql`
161
+ candidateRows = await db.sql`
161
162
  SELECT
162
163
  *
163
164
  FROM
@@ -165,15 +166,18 @@ export class PostgresBucketBatch
165
166
  WHERE
166
167
  group_id = ${{ type: 'int4', value: this.group_id }}
167
168
  AND connection_id = ${{ type: 'int4', value: connection_id }}
168
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
169
- AND schema_name = ${{ type: 'varchar', value: schema }}
170
- AND table_name = ${{ type: 'varchar', value: table }}
171
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
169
+ AND (
170
+ relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
171
+ OR (
172
+ schema_name = ${{ type: 'varchar', value: schema }}
173
+ AND table_name = ${{ type: 'varchar', value: table }}
174
+ )
175
+ )
172
176
  `
173
177
  .decoded(models.SourceTable)
174
- .first();
178
+ .rows();
175
179
  } else {
176
- sourceTableRow = await db.sql`
180
+ candidateRows = await db.sql`
177
181
  SELECT
178
182
  *
179
183
  FROM
@@ -183,15 +187,42 @@ export class PostgresBucketBatch
183
187
  AND connection_id = ${{ type: 'int4', value: connection_id }}
184
188
  AND schema_name = ${{ type: 'varchar', value: schema }}
185
189
  AND table_name = ${{ type: 'varchar', value: table }}
186
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
187
190
  `
188
191
  .decoded(models.SourceTable)
189
- .first();
192
+ .rows();
190
193
  }
191
194
 
192
- if (sourceTableRow == null) {
195
+ const candidateTables = candidateRows.map((row) => {
196
+ const table = this.sourceTableFromRow(row, connectionTag, syncRules);
197
+ table.storeCurrentData = sendsCompleteRows !== true;
198
+ return table;
199
+ });
200
+ const candidates = candidateTables.map((table) => table.clone());
201
+ const resolution = await reconcile({ source, candidates });
202
+ storage.validateSourceTableCandidateResolution(candidates, resolution);
203
+
204
+ for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
205
+ await db.sql`
206
+ UPDATE source_tables
207
+ SET
208
+ source_metadata = ${{ type: 'jsonb', value: sourceMetadata ?? null }}
209
+ WHERE
210
+ id = ${{ type: 'varchar', value: id.toString() }}
211
+ `.execute();
212
+ }
213
+
214
+ const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
215
+ const compatibleTables = materializedResolution.compatibleTables;
216
+
217
+ // Keep one record, preferring one that has already been snapshotted.
218
+ const reuse = compatibleTables.find((candidate) => candidate.snapshotComplete) ?? compatibleTables[0] ?? null;
219
+
220
+ let sourceTable: storage.SourceTable;
221
+ if (reuse != null) {
222
+ sourceTable = reuse;
223
+ } else {
193
224
  const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
194
- sourceTableRow = await db.sql`
225
+ const insertedRow = await db.sql`
195
226
  INSERT INTO
196
227
  source_tables (
197
228
  id,
@@ -200,7 +231,8 @@ export class PostgresBucketBatch
200
231
  relation_id,
201
232
  schema_name,
202
233
  table_name,
203
- replica_id_columns
234
+ replica_id_columns,
235
+ source_metadata
204
236
  )
205
237
  VALUES
206
238
  (
@@ -210,95 +242,27 @@ export class PostgresBucketBatch
210
242
  ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
211
243
  ${{ type: 'varchar', value: schema }},
212
244
  ${{ type: 'varchar', value: table }},
213
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
245
+ ${{ type: 'jsonb', value: normalizedReplicaIdColumns }},
246
+ ${{ type: 'jsonb', value: resolution.newTableValues.sourceMetadata ?? null }}
214
247
  )
215
248
  RETURNING
216
249
  *
217
250
  `
218
251
  .decoded(models.SourceTable)
219
252
  .first();
253
+ sourceTable = this.sourceTableFromRow(insertedRow!, connectionTag, syncRules);
254
+ sourceTable.storeCurrentData = sendsCompleteRows !== true;
220
255
  }
221
256
 
222
- const sourceTable = new storage.SourceTable({
223
- id: sourceTableRow!.id,
224
- ref: source,
225
- objectId,
226
- replicaIdColumns,
227
- snapshotComplete: sourceTableRow!.snapshot_done ?? true,
228
- ...syncRules.getMatchingSources(source)
229
- });
230
- if (!sourceTable.snapshotComplete) {
231
- sourceTable.snapshotStatus = {
232
- totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
233
- replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
234
- lastKey: sourceTableRow!.snapshot_last_key
235
- };
236
- }
237
- sourceTable.syncEvent = syncRules.tableTriggersEvent(source);
238
- sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
239
- sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
240
- sourceTable.storeCurrentData = sendsCompleteRows !== true;
241
-
242
- let truncatedTables: SourceTableDecoded[] = [];
243
- if (objectId != null) {
244
- truncatedTables = await db.sql`
245
- SELECT
246
- *
247
- FROM
248
- source_tables
249
- WHERE
250
- group_id = ${{ type: 'int4', value: this.group_id }}
251
- AND connection_id = ${{ type: 'int4', value: connection_id }}
252
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
253
- AND (
254
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
255
- OR (
256
- schema_name = ${{ type: 'varchar', value: schema }}
257
- AND table_name = ${{ type: 'varchar', value: table }}
258
- )
259
- )
260
- `
261
- .decoded(models.SourceTable)
262
- .rows();
263
- } else {
264
- truncatedTables = await db.sql`
265
- SELECT
266
- *
267
- FROM
268
- source_tables
269
- WHERE
270
- group_id = ${{ type: 'int4', value: this.group_id }}
271
- AND connection_id = ${{ type: 'int4', value: connection_id }}
272
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
273
- AND (
274
- schema_name = ${{ type: 'varchar', value: schema }}
275
- AND table_name = ${{ type: 'varchar', value: table }}
276
- )
277
- `
278
- .decoded(models.SourceTable)
279
- .rows();
280
- }
257
+ const dropTables = [
258
+ ...materializedResolution.incompatibleTables,
259
+ // PostgreSQL storage only keeps one SourceTable per physical table.
260
+ ...compatibleTables.filter((candidate) => !storage.sourceTableIdEquals(candidate.id, sourceTable.id))
261
+ ];
281
262
 
282
263
  return {
283
264
  tables: [sourceTable],
284
- dropTables: truncatedTables.map((doc) => {
285
- const ref = { connectionTag, schema: doc.schema_name, name: doc.table_name };
286
- const dropTable = new storage.SourceTable({
287
- id: doc.id,
288
- ref,
289
- objectId: doc.relation_id?.object_id ?? 0,
290
- replicaIdColumns:
291
- doc.replica_id_columns?.map(
292
- (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
293
- ) ?? [],
294
- snapshotComplete: doc.snapshot_done ?? true,
295
- ...syncRules.getMatchingSources(ref)
296
- });
297
- dropTable.syncEvent = syncRules.tableTriggersEvent(ref);
298
- dropTable.syncData = dropTable.bucketDataSources.length > 0;
299
- dropTable.syncParameters = dropTable.parameterLookupSources.length > 0;
300
- return dropTable;
301
- })
265
+ dropTables
302
266
  };
303
267
  });
304
268
  }
@@ -335,9 +299,10 @@ export class PostgresBucketBatch
335
299
  objectId: row.relation_id?.object_id,
336
300
  replicaIdColumns:
337
301
  row.replica_id_columns?.map(
338
- (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
302
+ (column) => ({ name: column.name, typeId: column.type_oid, type: column.type }) satisfies ColumnDescriptor
339
303
  ) ?? [],
340
304
  snapshotComplete: row.snapshot_done ?? true,
305
+ sourceMetadata: row.source_metadata,
341
306
  ...syncRules.getMatchingSources(ref)
342
307
  });
343
308
 
@@ -465,12 +430,11 @@ export class PostgresBucketBatch
465
430
  }
466
431
  const { flushedAny } = await persistedBatch.flush(db);
467
432
  clearedError = flushedAny && !this.clearedError;
468
- if (clearedError) {
469
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
470
- await this.clearError(db);
471
- }
472
433
  });
473
434
  if (clearedError) {
435
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
436
+ // Cleared outside the replication transaction - see flushInner.
437
+ await this.clearError();
474
438
  this.clearedError = true;
475
439
  }
476
440
  }
@@ -537,6 +501,10 @@ export class PostgresBucketBatch
537
501
  });
538
502
 
539
503
  if (clearedError) {
504
+ // Clear the error outside the replication transaction (plain autocommit update,
505
+ // like the keepalive), to avoid serialization conflicts on the sync_rules row
506
+ // when multiple writers flush concurrently.
507
+ await this.clearError();
540
508
  this.clearedError = true;
541
509
  }
542
510
 
@@ -562,136 +530,144 @@ export class PostgresBucketBatch
562
530
 
563
531
  const persisted_op = this.persisted_op ?? null;
564
532
 
565
- const result = await this.db.sql`
566
- WITH
567
- selected AS (
568
- SELECT
569
- id,
570
- state,
571
- last_checkpoint,
572
- last_checkpoint_lsn,
573
- snapshot_done,
574
- no_checkpoint_before,
575
- keepalive_op,
576
- (
577
- snapshot_done = TRUE
578
- AND (
579
- last_checkpoint_lsn IS NULL
580
- OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
581
- )
533
+ // Transaction failures restart replication from the last durable checkpoint.
534
+ const result = await this.db.transaction(async (db) => {
535
+ const checkpoint = await db.sql`
536
+ WITH
537
+ selected AS (
538
+ SELECT
539
+ id,
540
+ state,
541
+ last_checkpoint,
542
+ last_checkpoint_lsn,
543
+ snapshot_done,
544
+ no_checkpoint_before,
545
+ keepalive_op,
546
+ (
547
+ snapshot_done = TRUE
548
+ AND (
549
+ last_checkpoint_lsn IS NULL
550
+ OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
551
+ )
552
+ AND (
553
+ no_checkpoint_before IS NULL
554
+ OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
555
+ )
556
+ ) AS can_checkpoint
557
+ FROM
558
+ sync_rules
559
+ WHERE
560
+ id = ${{ type: 'int4', value: this.group_id }}
561
+ FOR UPDATE
562
+ ),
563
+ computed AS (
564
+ SELECT
565
+ selected.*,
566
+ CASE
567
+ WHEN selected.can_checkpoint THEN GREATEST(
568
+ selected.last_checkpoint,
569
+ ${{ type: 'int8', value: persisted_op }},
570
+ selected.keepalive_op,
571
+ 0
572
+ )
573
+ ELSE selected.last_checkpoint
574
+ END AS new_last_checkpoint,
575
+ CASE
576
+ WHEN selected.can_checkpoint THEN NULL
577
+ ELSE GREATEST(
578
+ selected.keepalive_op,
579
+ ${{ type: 'int8', value: persisted_op }},
580
+ 0
581
+ )
582
+ END AS new_keepalive_op
583
+ FROM
584
+ selected
585
+ ),
586
+ updated AS (
587
+ UPDATE sync_rules AS sr
588
+ SET
589
+ last_checkpoint_lsn = CASE
590
+ WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
591
+ ELSE sr.last_checkpoint_lsn
592
+ END,
593
+ last_checkpoint_ts = CASE
594
+ WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
595
+ ELSE sr.last_checkpoint_ts
596
+ END,
597
+ last_keepalive_ts = ${{ type: 1184, value: now }},
598
+ last_fatal_error = CASE
599
+ WHEN computed.can_checkpoint THEN NULL
600
+ ELSE sr.last_fatal_error
601
+ END,
602
+ keepalive_op = computed.new_keepalive_op,
603
+ last_checkpoint = computed.new_last_checkpoint,
604
+ snapshot_lsn = CASE
605
+ WHEN computed.can_checkpoint THEN NULL
606
+ ELSE sr.snapshot_lsn
607
+ END
608
+ FROM
609
+ computed
610
+ WHERE
611
+ sr.id = computed.id
582
612
  AND (
583
- no_checkpoint_before IS NULL
584
- OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
613
+ sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
614
+ OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
615
+ OR ${{ type: 'bool', value: createEmptyCheckpoints }}
585
616
  )
586
- ) AS can_checkpoint
587
- FROM
588
- sync_rules
589
- WHERE
590
- id = ${{ type: 'int4', value: this.group_id }}
591
- FOR UPDATE
592
- ),
593
- computed AS (
594
- SELECT
595
- selected.*,
596
- CASE
597
- WHEN selected.can_checkpoint THEN GREATEST(
598
- selected.last_checkpoint,
599
- ${{ type: 'int8', value: persisted_op }},
600
- selected.keepalive_op,
601
- 0
602
- )
603
- ELSE selected.last_checkpoint
604
- END AS new_last_checkpoint,
605
- CASE
606
- WHEN selected.can_checkpoint THEN NULL
607
- ELSE GREATEST(
608
- selected.keepalive_op,
609
- ${{ type: 'int8', value: persisted_op }},
610
- 0
611
- )
612
- END AS new_keepalive_op
613
- FROM
614
- selected
615
- ),
616
- updated AS (
617
- UPDATE sync_rules AS sr
618
- SET
619
- last_checkpoint_lsn = CASE
620
- WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
621
- ELSE sr.last_checkpoint_lsn
622
- END,
623
- last_checkpoint_ts = CASE
624
- WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
625
- ELSE sr.last_checkpoint_ts
626
- END,
627
- last_keepalive_ts = ${{ type: 1184, value: now }},
628
- last_fatal_error = CASE
629
- WHEN computed.can_checkpoint THEN NULL
630
- ELSE sr.last_fatal_error
631
- END,
632
- keepalive_op = computed.new_keepalive_op,
633
- last_checkpoint = computed.new_last_checkpoint,
634
- snapshot_lsn = CASE
635
- WHEN computed.can_checkpoint THEN NULL
636
- ELSE sr.snapshot_lsn
637
- END
638
- FROM
639
- computed
640
- WHERE
641
- sr.id = computed.id
642
- AND (
643
- sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
644
- OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
645
- OR ${{ type: 'bool', value: createEmptyCheckpoints }}
646
- )
647
- RETURNING
648
- sr.id,
649
- sr.state,
650
- sr.last_checkpoint,
651
- sr.last_checkpoint_lsn,
652
- sr.snapshot_done,
653
- sr.no_checkpoint_before,
654
- computed.can_checkpoint,
655
- computed.keepalive_op,
656
- computed.new_last_checkpoint
657
- )
658
- SELECT
659
- id,
660
- state,
661
- last_checkpoint,
662
- last_checkpoint_lsn,
663
- snapshot_done,
664
- no_checkpoint_before,
665
- can_checkpoint,
666
- keepalive_op,
667
- new_last_checkpoint,
668
- TRUE AS created_checkpoint
669
- FROM
670
- updated
671
- UNION ALL
672
- SELECT
673
- id,
674
- state,
675
- new_last_checkpoint AS last_checkpoint,
676
- last_checkpoint_lsn,
677
- snapshot_done,
678
- no_checkpoint_before,
679
- can_checkpoint,
680
- keepalive_op,
681
- new_last_checkpoint,
682
- FALSE AS created_checkpoint
683
- FROM
684
- computed
685
- WHERE
686
- NOT EXISTS (
687
- SELECT
688
- 1
689
- FROM
690
- updated
691
- )
692
- `
693
- .decoded(CheckpointWithStatus)
694
- .first();
617
+ RETURNING
618
+ sr.id,
619
+ sr.state,
620
+ sr.last_checkpoint,
621
+ sr.last_checkpoint_lsn,
622
+ sr.snapshot_done,
623
+ sr.no_checkpoint_before,
624
+ computed.can_checkpoint,
625
+ computed.keepalive_op,
626
+ computed.new_last_checkpoint
627
+ )
628
+ SELECT
629
+ id,
630
+ state,
631
+ last_checkpoint,
632
+ last_checkpoint_lsn,
633
+ snapshot_done,
634
+ no_checkpoint_before,
635
+ can_checkpoint,
636
+ keepalive_op,
637
+ new_last_checkpoint,
638
+ TRUE AS created_checkpoint
639
+ FROM
640
+ updated
641
+ UNION ALL
642
+ SELECT
643
+ id,
644
+ state,
645
+ new_last_checkpoint AS last_checkpoint,
646
+ last_checkpoint_lsn,
647
+ snapshot_done,
648
+ no_checkpoint_before,
649
+ can_checkpoint,
650
+ keepalive_op,
651
+ new_last_checkpoint,
652
+ FALSE AS created_checkpoint
653
+ FROM
654
+ computed
655
+ WHERE
656
+ NOT EXISTS (
657
+ SELECT
658
+ 1
659
+ FROM
660
+ updated
661
+ )
662
+ `
663
+ .decoded(CheckpointWithStatus)
664
+ .first();
665
+
666
+ if (checkpoint?.can_checkpoint && checkpoint.state == storage.SyncRuleState.ACTIVE) {
667
+ await notifySyncRulesUpdate(db, checkpoint);
668
+ }
669
+ return checkpoint;
670
+ });
695
671
 
696
672
  if (result == null) {
697
673
  throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
@@ -723,10 +699,8 @@ export class PostgresBucketBatch
723
699
  });
724
700
  }
725
701
  }
726
- await this.autoActivate(lsn);
727
- await notifySyncRulesUpdate(this.db, {
702
+ await this.autoActivate(lsn, {
728
703
  id: result.id,
729
- state: result.state,
730
704
  last_checkpoint: result.last_checkpoint,
731
705
  last_checkpoint_lsn: result.last_checkpoint_lsn
732
706
  });
@@ -1033,10 +1007,8 @@ export class PostgresBucketBatch
1033
1007
  }
1034
1008
  }
1035
1009
 
1010
+ // The error itself is cleared by the caller, outside the replication transaction - see flushInner.
1036
1011
  const clearedError = didFlush && !this.clearedError;
1037
- if (clearedError) {
1038
- await this.clearError(db);
1039
- }
1040
1012
 
1041
1013
  // Don't return empty batches
1042
1014
  return {
@@ -1305,14 +1277,13 @@ export class PostgresBucketBatch
1305
1277
  *
1306
1278
  * Called on new commits.
1307
1279
  */
1308
- private async autoActivate(lsn: string): Promise<void> {
1280
+ private async autoActivate(lsn: string, checkpoint: models.ActiveCheckpointDecoded): Promise<void> {
1309
1281
  if (!this.needsActivation) {
1310
1282
  // Already activated
1311
1283
  return;
1312
1284
  }
1313
1285
 
1314
- let didActivate = false;
1315
- await this.db.transaction(async (db) => {
1286
+ const activationResult = await this.db.transaction(async (db) => {
1316
1287
  const syncRulesRow = await db.sql`
1317
1288
  SELECT
1318
1289
  state,
@@ -1346,13 +1317,18 @@ export class PostgresBucketBatch
1346
1317
  )
1347
1318
  AND id != ${{ type: 'int4', value: this.group_id }}
1348
1319
  `.execute();
1349
- didActivate = true;
1350
- this.needsActivation = false;
1320
+ await notifySyncRulesUpdate(db, checkpoint);
1321
+ return 'activated';
1351
1322
  } else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
1352
- this.needsActivation = false;
1323
+ return 'not-processing';
1353
1324
  }
1325
+ return 'pending';
1354
1326
  });
1355
- if (didActivate) {
1327
+
1328
+ if (activationResult != 'pending') {
1329
+ this.needsActivation = false;
1330
+ }
1331
+ if (activationResult == 'activated') {
1356
1332
  this.logger.info(`Activated new replication stream at ${lsn}`);
1357
1333
  }
1358
1334
  }
@@ -1438,12 +1414,17 @@ export class PostgresBucketBatch
1438
1414
  * Uses Postgres' NOTIFY functionality to update different processes when the
1439
1415
  * active checkpoint has been updated.
1440
1416
  */
1441
- export const notifySyncRulesUpdate = async (db: lib_postgres.DatabaseClient, update: StatefulCheckpointDecoded) => {
1442
- if (update.state != storage.SyncRuleState.ACTIVE) {
1443
- return;
1444
- }
1445
-
1446
- await db.query({
1447
- statement: `NOTIFY ${NOTIFICATION_CHANNEL}, '${models.ActiveCheckpointNotification.encode({ active_checkpoint: update })}'`
1448
- });
1417
+ export const notifySyncRulesUpdate = async (
1418
+ db: lib_postgres.AbstractPostgresConnection,
1419
+ update: models.ActiveCheckpointDecoded
1420
+ ) => {
1421
+ const payload = models.ActiveCheckpointNotification.encode({ active_checkpoint: update });
1422
+
1423
+ await db.sql`
1424
+ SELECT
1425
+ pg_notify (
1426
+ ${{ type: 'varchar', value: NOTIFICATION_CHANNEL }},
1427
+ ${{ type: 'varchar', value: payload }}
1428
+ )
1429
+ `.execute();
1449
1430
  };