@powersync/service-module-postgres-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (133) hide show
  1. package/CHANGELOG.md +179 -6
  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/migrations/scripts/1788000000000-sync-config-version-label.d.ts +3 -0
  9. package/dist/migrations/scripts/1788000000000-sync-config-version-label.js +12 -0
  10. package/dist/migrations/scripts/1788000000000-sync-config-version-label.js.map +1 -0
  11. package/dist/{@types/storage → storage}/PostgresBucketStorageFactory.d.ts +14 -12
  12. package/dist/storage/PostgresBucketStorageFactory.js +73 -62
  13. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  14. package/dist/{@types/storage → storage}/PostgresCompactor.d.ts +3 -1
  15. package/dist/storage/PostgresCompactor.js +21 -1
  16. package/dist/storage/PostgresCompactor.js.map +1 -1
  17. package/dist/storage/PostgresReportStorage.js.map +1 -1
  18. package/dist/storage/PostgresStorageProvider.js +3 -1
  19. package/dist/storage/PostgresStorageProvider.js.map +1 -1
  20. package/dist/{@types/storage → storage}/PostgresSyncRulesStorage.d.ts +39 -13
  21. package/dist/storage/PostgresSyncRulesStorage.js +266 -181
  22. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  23. package/dist/{@types/storage → storage}/batch/PostgresBucketBatch.d.ts +10 -26
  24. package/dist/storage/batch/PostgresBucketBatch.js +290 -286
  25. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  26. package/dist/{@types/storage → storage}/checkpoints/PostgresWriteCheckpointAPI.d.ts +2 -2
  27. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +163 -26
  28. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  29. package/dist/{@types/storage → storage}/current-data-store.d.ts +8 -2
  30. package/dist/storage/current-data-store.js +66 -11
  31. package/dist/storage/current-data-store.js.map +1 -1
  32. package/dist/{@types/storage → storage}/storage-index.d.ts +1 -0
  33. package/dist/storage/storage-index.js +1 -0
  34. package/dist/storage/storage-index.js.map +1 -1
  35. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.d.ts +17 -0
  36. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js +94 -0
  37. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js.map +1 -0
  38. package/dist/{@types/types → types}/models/ActiveCheckpoint.d.ts +2 -2
  39. package/dist/types/models/ActiveCheckpoint.js +3 -2
  40. package/dist/types/models/ActiveCheckpoint.js.map +1 -1
  41. package/dist/{@types/types → types}/models/ActiveCheckpointNotification.d.ts +2 -2
  42. package/dist/types/models/BucketData.d.ts +22 -0
  43. package/dist/types/models/BucketData.js +7 -6
  44. package/dist/types/models/BucketData.js.map +1 -1
  45. package/dist/{@types/types → types}/models/CurrentData.d.ts +1 -1
  46. package/dist/types/models/CurrentData.js +2 -1
  47. package/dist/types/models/CurrentData.js.map +1 -1
  48. package/dist/{@types/types → types}/models/SdkReporting.d.ts +3 -3
  49. package/dist/types/models/SdkReporting.js +3 -5
  50. package/dist/types/models/SdkReporting.js.map +1 -1
  51. package/dist/{@types/types → types}/models/SourceTable.d.ts +13 -8
  52. package/dist/types/models/SourceTable.js +11 -6
  53. package/dist/types/models/SourceTable.js.map +1 -1
  54. package/dist/{@types/types → types}/models/SyncRules.d.ts +11 -10
  55. package/dist/types/models/SyncRules.js +12 -11
  56. package/dist/types/models/SyncRules.js.map +1 -1
  57. package/dist/{@types/types → types}/models/WriteCheckpoint.d.ts +2 -0
  58. package/dist/types/models/WriteCheckpoint.js +6 -2
  59. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  60. package/dist/{@types/types → types}/types.d.ts +2 -0
  61. package/dist/utils/checkpoints.d.ts +9 -0
  62. package/dist/utils/checkpoints.js +26 -0
  63. package/dist/utils/checkpoints.js.map +1 -0
  64. package/dist/utils/test-utils.js +2 -2
  65. package/dist/utils/test-utils.js.map +1 -1
  66. package/package.json +10 -22
  67. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  68. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  69. package/src/migrations/scripts/1788000000000-sync-config-version-label.ts +18 -0
  70. package/src/storage/PostgresBucketStorageFactory.ts +82 -65
  71. package/src/storage/PostgresCompactor.ts +25 -1
  72. package/src/storage/PostgresReportStorage.ts +1 -2
  73. package/src/storage/PostgresStorageProvider.ts +3 -1
  74. package/src/storage/PostgresSyncRulesStorage.ts +295 -196
  75. package/src/storage/batch/PostgresBucketBatch.ts +321 -310
  76. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +176 -26
  77. package/src/storage/current-data-store.ts +66 -11
  78. package/src/storage/storage-index.ts +1 -0
  79. package/src/storage/sync-rules/PostgresPersistedSyncConfigContent.ts +105 -0
  80. package/src/types/models/ActiveCheckpoint.ts +3 -2
  81. package/src/types/models/BucketData.ts +7 -6
  82. package/src/types/models/CurrentData.ts +2 -1
  83. package/src/types/models/SdkReporting.ts +4 -4
  84. package/src/types/models/SourceTable.ts +12 -6
  85. package/src/types/models/SyncRules.ts +12 -11
  86. package/src/types/models/WriteCheckpoint.ts +6 -2
  87. package/src/utils/checkpoints.ts +31 -0
  88. package/src/utils/test-utils.ts +2 -2
  89. package/test/src/__snapshots__/storage_sync.test.ts.snap +44 -1621
  90. package/test/src/checkpoint_notifications.test.ts +528 -0
  91. package/test/src/migrations.test.ts +1 -1
  92. package/test/src/storage.test.ts +235 -11
  93. package/test/src/storage_compacting.test.ts +17 -9
  94. package/test/src/storage_sync.test.ts +55 -8
  95. package/test/src/util.ts +1 -1
  96. package/test/tsconfig.json +1 -3
  97. package/tsconfig.json +1 -4
  98. package/tsconfig.tsbuildinfo +1 -0
  99. package/dist/.tsbuildinfo +0 -1
  100. package/dist/@types/storage/sync-rules/PostgresPersistedSyncRulesContent.d.ts +0 -9
  101. package/dist/@types/types/models/BucketData.d.ts +0 -22
  102. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js +0 -49
  103. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js.map +0 -1
  104. package/src/storage/sync-rules/PostgresPersistedSyncRulesContent.ts +0 -54
  105. /package/dist/{@types/index.d.ts → index.d.ts} +0 -0
  106. /package/dist/{@types/migrations → migrations}/PostgresMigrationAgent.d.ts +0 -0
  107. /package/dist/{@types/migrations → migrations}/PostgresMigrationStore.d.ts +0 -0
  108. /package/dist/{@types/migrations → migrations}/migration-utils.d.ts +0 -0
  109. /package/dist/{@types/migrations → migrations}/scripts/1684951997326-init.d.ts +0 -0
  110. /package/dist/{@types/migrations → migrations}/scripts/1749024804042-snapshot-progress.d.ts +0 -0
  111. /package/dist/{@types/migrations → migrations}/scripts/1756282360128-connection-reporting.d.ts +0 -0
  112. /package/dist/{@types/migrations → migrations}/scripts/1771232439485-storage-version.d.ts +0 -0
  113. /package/dist/{@types/migrations → migrations}/scripts/1771424826685-current-data-pending-deletes.d.ts +0 -0
  114. /package/dist/{@types/migrations → migrations}/scripts/1771491856000-sync-plan.d.ts +0 -0
  115. /package/dist/{@types/module → module}/PostgresStorageModule.d.ts +0 -0
  116. /package/dist/{@types/storage → storage}/PostgresReportStorage.d.ts +0 -0
  117. /package/dist/{@types/storage → storage}/PostgresStorageProvider.d.ts +0 -0
  118. /package/dist/{@types/storage → storage}/batch/OperationBatch.d.ts +0 -0
  119. /package/dist/{@types/storage → storage}/batch/PostgresPersistedBatch.d.ts +0 -0
  120. /package/dist/{@types/storage → storage}/table-id.d.ts +0 -0
  121. /package/dist/{@types/types → types}/codecs.d.ts +0 -0
  122. /package/dist/{@types/types → types}/models/BucketParameters.d.ts +0 -0
  123. /package/dist/{@types/types → types}/models/Instance.d.ts +0 -0
  124. /package/dist/{@types/types → types}/models/Migration.d.ts +0 -0
  125. /package/dist/{@types/types → types}/models/json.d.ts +0 -0
  126. /package/dist/{@types/types → types}/models/models-index.d.ts +0 -0
  127. /package/dist/{@types/utils → utils}/application-name.d.ts +0 -0
  128. /package/dist/{@types/utils → utils}/bson.d.ts +0 -0
  129. /package/dist/{@types/utils → utils}/bucket-data.d.ts +0 -0
  130. /package/dist/{@types/utils → utils}/db.d.ts +0 -0
  131. /package/dist/{@types/utils → utils}/test-utils.d.ts +0 -0
  132. /package/dist/{@types/utils → utils}/ts-codec.d.ts +0 -0
  133. /package/dist/{@types/utils → utils}/utils-index.d.ts +0 -0
@@ -19,6 +19,7 @@ import {
19
19
  utils
20
20
  } from '@powersync/service-core';
21
21
  import * as sync_rules from '@powersync/service-sync-rules';
22
+ import { orNull } from '@powersync/service-types';
22
23
  import * as timers from 'timers/promises';
23
24
  import * as t from 'ts-codec';
24
25
  import * as uuid from 'uuid';
@@ -38,8 +39,8 @@ export interface PostgresBucketBatchOptions {
38
39
  logger: Logger;
39
40
  db: lib_postgres.DatabaseClient;
40
41
  sync_rules: sync_rules.HydratedSyncConfig;
41
- group_id: number;
42
- slot_name: string;
42
+ replicationStreamId: number;
43
+ replicationStreamName: string;
43
44
  last_checkpoint_lsn: string | null;
44
45
  store_current_data: boolean;
45
46
  keep_alive_op?: InternalOpId | null;
@@ -60,15 +61,14 @@ export interface PostgresBucketBatchOptions {
60
61
  * via the Postgres NOTIFY protocol.
61
62
  */
62
63
  const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
63
- type StatefulCheckpointDecoded = t.Decoded<typeof StatefulCheckpoint>;
64
64
 
65
65
  const CheckpointWithStatus = StatefulCheckpoint.and(
66
66
  t.object({
67
67
  snapshot_done: t.boolean,
68
- no_checkpoint_before: t.string.or(t.Null),
68
+ no_checkpoint_before: orNull(t.string),
69
69
  can_checkpoint: t.boolean,
70
- keepalive_op: bigint.or(t.Null),
71
- new_last_checkpoint: bigint.or(t.Null),
70
+ keepalive_op: orNull(bigint),
71
+ new_last_checkpoint: orNull(bigint),
72
72
  created_checkpoint: t.boolean
73
73
  })
74
74
  );
@@ -112,7 +112,7 @@ export class PostgresBucketBatch
112
112
  super();
113
113
  this.logger = options.logger;
114
114
  this.db = options.db;
115
- this.group_id = options.group_id;
115
+ this.group_id = options.replicationStreamId;
116
116
  this.last_checkpoint_lsn = options.last_checkpoint_lsn;
117
117
  this.resumeFromLsn = options.resumeFromLsn;
118
118
  this.skipExistingRows = options.skip_existing_rows;
@@ -129,10 +129,6 @@ export class PostgresBucketBatch
129
129
  }
130
130
  }
131
131
 
132
- get lastCheckpointLsn() {
133
- return this.last_checkpoint_lsn;
134
- }
135
-
136
132
  async [Symbol.asyncDispose]() {
137
133
  if (this.batch != null || this.write_checkpoint_batch.length > 0) {
138
134
  // We don't error here, since:
@@ -148,19 +144,22 @@ export class PostgresBucketBatch
148
144
  }
149
145
 
150
146
  async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
151
- const syncRules = options.syncRules ?? this.sync_rules;
147
+ const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
148
+ const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
152
149
  const { connection_id, source } = options;
153
- const { schema, name: table, objectId, replicaIdColumns, connectionTag } = source;
150
+ const { schema, name: table, objectId, replicaIdColumns, connectionTag, sendsCompleteRows } = source;
154
151
 
155
152
  const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
156
153
  name: column.name,
157
154
  type: column.type,
158
155
  type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
159
156
  }));
157
+
160
158
  return this.db.transaction(async (db) => {
161
- let sourceTableRow: SourceTableDecoded | null;
159
+ // Find records that overlap by name or relation id.
160
+ let candidateRows: SourceTableDecoded[];
162
161
  if (objectId != null) {
163
- sourceTableRow = await db.sql`
162
+ candidateRows = await db.sql`
164
163
  SELECT
165
164
  *
166
165
  FROM
@@ -168,15 +167,18 @@ export class PostgresBucketBatch
168
167
  WHERE
169
168
  group_id = ${{ type: 'int4', value: this.group_id }}
170
169
  AND connection_id = ${{ type: 'int4', value: connection_id }}
171
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
172
- AND schema_name = ${{ type: 'varchar', value: schema }}
173
- AND table_name = ${{ type: 'varchar', value: table }}
174
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
170
+ AND (
171
+ relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
172
+ OR (
173
+ schema_name = ${{ type: 'varchar', value: schema }}
174
+ AND table_name = ${{ type: 'varchar', value: table }}
175
+ )
176
+ )
175
177
  `
176
178
  .decoded(models.SourceTable)
177
- .first();
179
+ .rows();
178
180
  } else {
179
- sourceTableRow = await db.sql`
181
+ candidateRows = await db.sql`
180
182
  SELECT
181
183
  *
182
184
  FROM
@@ -186,15 +188,42 @@ export class PostgresBucketBatch
186
188
  AND connection_id = ${{ type: 'int4', value: connection_id }}
187
189
  AND schema_name = ${{ type: 'varchar', value: schema }}
188
190
  AND table_name = ${{ type: 'varchar', value: table }}
189
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
190
191
  `
191
192
  .decoded(models.SourceTable)
192
- .first();
193
+ .rows();
194
+ }
195
+
196
+ const candidateTables = candidateRows.map((row) => {
197
+ const table = this.sourceTableFromRow(row, connectionTag, syncRules);
198
+ table.storeCurrentData = sendsCompleteRows !== true;
199
+ return table;
200
+ });
201
+ const candidates = candidateTables.map((table) => table.clone());
202
+ const resolution = await reconcile({ source, candidates });
203
+ storage.validateSourceTableCandidateResolution(candidates, resolution);
204
+
205
+ for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
206
+ await db.sql`
207
+ UPDATE source_tables
208
+ SET
209
+ source_metadata = ${{ type: 'jsonb', value: sourceMetadata ?? null }}
210
+ WHERE
211
+ id = ${{ type: 'varchar', value: id.toString() }}
212
+ `.execute();
193
213
  }
194
214
 
195
- if (sourceTableRow == null) {
215
+ const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
216
+ const compatibleTables = materializedResolution.compatibleTables;
217
+
218
+ // Keep one record, preferring one that has already been snapshotted.
219
+ const reuse = compatibleTables.find((candidate) => candidate.snapshotComplete) ?? compatibleTables[0] ?? null;
220
+
221
+ let sourceTable: storage.SourceTable;
222
+ if (reuse != null) {
223
+ sourceTable = reuse;
224
+ } else {
196
225
  const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
197
- sourceTableRow = await db.sql`
226
+ const insertedRow = await db.sql`
198
227
  INSERT INTO
199
228
  source_tables (
200
229
  id,
@@ -203,7 +232,8 @@ export class PostgresBucketBatch
203
232
  relation_id,
204
233
  schema_name,
205
234
  table_name,
206
- replica_id_columns
235
+ replica_id_columns,
236
+ source_metadata
207
237
  )
208
238
  VALUES
209
239
  (
@@ -213,94 +243,27 @@ export class PostgresBucketBatch
213
243
  ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
214
244
  ${{ type: 'varchar', value: schema }},
215
245
  ${{ type: 'varchar', value: table }},
216
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
246
+ ${{ type: 'jsonb', value: normalizedReplicaIdColumns }},
247
+ ${{ type: 'jsonb', value: resolution.newTableValues.sourceMetadata ?? null }}
217
248
  )
218
249
  RETURNING
219
250
  *
220
251
  `
221
252
  .decoded(models.SourceTable)
222
253
  .first();
254
+ sourceTable = this.sourceTableFromRow(insertedRow!, connectionTag, syncRules);
255
+ sourceTable.storeCurrentData = sendsCompleteRows !== true;
223
256
  }
224
257
 
225
- const sourceTable = new storage.SourceTable({
226
- id: sourceTableRow!.id,
227
- ref: source,
228
- objectId,
229
- replicaIdColumns,
230
- snapshotComplete: sourceTableRow!.snapshot_done ?? true,
231
- ...syncRules.getMatchingSources(source)
232
- });
233
- if (!sourceTable.snapshotComplete) {
234
- sourceTable.snapshotStatus = {
235
- totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
236
- replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
237
- lastKey: sourceTableRow!.snapshot_last_key
238
- };
239
- }
240
- sourceTable.syncEvent = syncRules.tableTriggersEvent(source);
241
- sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
242
- sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
243
-
244
- let truncatedTables: SourceTableDecoded[] = [];
245
- if (objectId != null) {
246
- truncatedTables = await db.sql`
247
- SELECT
248
- *
249
- FROM
250
- source_tables
251
- WHERE
252
- group_id = ${{ type: 'int4', value: this.group_id }}
253
- AND connection_id = ${{ type: 'int4', value: connection_id }}
254
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
255
- AND (
256
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
257
- OR (
258
- schema_name = ${{ type: 'varchar', value: schema }}
259
- AND table_name = ${{ type: 'varchar', value: table }}
260
- )
261
- )
262
- `
263
- .decoded(models.SourceTable)
264
- .rows();
265
- } else {
266
- truncatedTables = await db.sql`
267
- SELECT
268
- *
269
- FROM
270
- source_tables
271
- WHERE
272
- group_id = ${{ type: 'int4', value: this.group_id }}
273
- AND connection_id = ${{ type: 'int4', value: connection_id }}
274
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
275
- AND (
276
- schema_name = ${{ type: 'varchar', value: schema }}
277
- AND table_name = ${{ type: 'varchar', value: table }}
278
- )
279
- `
280
- .decoded(models.SourceTable)
281
- .rows();
282
- }
258
+ const dropTables = [
259
+ ...materializedResolution.incompatibleTables,
260
+ // PostgreSQL storage only keeps one SourceTable per physical table.
261
+ ...compatibleTables.filter((candidate) => !storage.sourceTableIdEquals(candidate.id, sourceTable.id))
262
+ ];
283
263
 
284
264
  return {
285
265
  tables: [sourceTable],
286
- dropTables: truncatedTables.map((doc) => {
287
- const ref = { connectionTag, schema: doc.schema_name, name: doc.table_name };
288
- const dropTable = new storage.SourceTable({
289
- id: doc.id,
290
- ref,
291
- objectId: doc.relation_id?.object_id ?? 0,
292
- replicaIdColumns:
293
- doc.replica_id_columns?.map(
294
- (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
295
- ) ?? [],
296
- snapshotComplete: doc.snapshot_done ?? true,
297
- ...syncRules.getMatchingSources(ref)
298
- });
299
- dropTable.syncEvent = syncRules.tableTriggersEvent(ref);
300
- dropTable.syncData = dropTable.bucketDataSources.length > 0;
301
- dropTable.syncParameters = dropTable.parameterLookupSources.length > 0;
302
- return dropTable;
303
- })
266
+ dropTables
304
267
  };
305
268
  });
306
269
  }
@@ -337,9 +300,10 @@ export class PostgresBucketBatch
337
300
  objectId: row.relation_id?.object_id,
338
301
  replicaIdColumns:
339
302
  row.replica_id_columns?.map(
340
- (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
303
+ (column) => ({ name: column.name, typeId: column.type_oid, type: column.type }) satisfies ColumnDescriptor
341
304
  ) ?? [],
342
305
  snapshotComplete: row.snapshot_done ?? true,
306
+ sourceMetadata: row.source_metadata,
343
307
  ...syncRules.getMatchingSources(ref)
344
308
  });
345
309
 
@@ -359,19 +323,25 @@ export class PostgresBucketBatch
359
323
  async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
360
324
  // TODO maybe share with abstract class
361
325
  const { after, before, sourceTable, tag } = record;
362
- for (const event of this.getTableEvents(sourceTable)) {
363
- this.iterateListeners((cb) =>
364
- cb.replicationEvent?.({
365
- batch: this,
366
- table: sourceTable,
367
- data: {
368
- op: tag,
369
- after: after && utils.isCompleteRow(this.options.store_current_data, after) ? after : undefined,
370
- before: before && utils.isCompleteRow(this.options.store_current_data, before) ? before : undefined
371
- },
372
- event
373
- })
374
- );
326
+ const storeCurrentData = this.options.store_current_data && sourceTable.storeCurrentData;
327
+ // PostgreSQL storage resolves each physical table to one SourceTable. syncEvent records whether
328
+ // that table matches any event definition; each matching definition is evaluated once here and
329
+ // may produce multiple payloads from its matching queries.
330
+ if (sourceTable.syncEvent) {
331
+ for (const event of this.getTableEvents(sourceTable)) {
332
+ this.iterateListeners((cb) =>
333
+ cb.replicationEvent?.({
334
+ batch: this,
335
+ table: sourceTable,
336
+ data: {
337
+ op: tag,
338
+ after: after && utils.isCompleteRow(storeCurrentData, after) ? after : undefined,
339
+ before: before && utils.isCompleteRow(storeCurrentData, before) ? before : undefined
340
+ },
341
+ event
342
+ })
343
+ );
344
+ }
375
345
  }
376
346
  /**
377
347
  * Return if the table is just an event table
@@ -420,7 +390,9 @@ export class PostgresBucketBatch
420
390
  let processedCount = 0;
421
391
  while (lastBatchCount == BATCH_LIMIT) {
422
392
  lastBatchCount = 0;
393
+ let clearedError = false;
423
394
  await this.withReplicationTransaction(async (db) => {
395
+ clearedError = false;
424
396
  const persistedBatch = new PostgresPersistedBatch({
425
397
  group_id: this.group_id,
426
398
  storageConfig: this.storageConfig,
@@ -460,10 +432,14 @@ export class PostgresBucketBatch
460
432
  }
461
433
  }
462
434
  const { flushedAny } = await persistedBatch.flush(db);
463
- if (flushedAny) {
464
- await this.clearError(db);
465
- }
435
+ clearedError = flushedAny && !this.clearedError;
466
436
  });
437
+ if (clearedError) {
438
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
439
+ // Cleared outside the replication transaction - see flushInner.
440
+ await this.clearError();
441
+ this.clearedError = true;
442
+ }
467
443
  }
468
444
  if (processedCount == 0) {
469
445
  // The op sequence should not have progressed
@@ -516,13 +492,25 @@ export class PostgresBucketBatch
516
492
  await this.hooks?.beforeBatchFlush?.(this);
517
493
 
518
494
  let resumeBatch: OperationBatch | null = null;
495
+ let clearedError = false;
519
496
 
520
497
  const lastOp = await this.withReplicationTransaction(async (db) => {
521
- resumeBatch = await this.replicateBatch(db, batch);
498
+ clearedError = false;
499
+ const result = await this.replicateBatch(db, batch);
500
+ resumeBatch = result.resumeBatch;
501
+ clearedError ||= result.clearedError;
522
502
 
523
503
  return this.getLastOpIdSequence(db);
524
504
  });
525
505
 
506
+ if (clearedError) {
507
+ // Clear the error outside the replication transaction (plain autocommit update,
508
+ // like the keepalive), to avoid serialization conflicts on the sync_rules row
509
+ // when multiple writers flush concurrently.
510
+ await this.clearError();
511
+ this.clearedError = true;
512
+ }
513
+
526
514
  // null if done, set if we need another flush
527
515
  this.batch = resumeBatch;
528
516
 
@@ -545,136 +533,144 @@ export class PostgresBucketBatch
545
533
 
546
534
  const persisted_op = this.persisted_op ?? null;
547
535
 
548
- const result = await this.db.sql`
549
- WITH
550
- selected AS (
551
- SELECT
552
- id,
553
- state,
554
- last_checkpoint,
555
- last_checkpoint_lsn,
556
- snapshot_done,
557
- no_checkpoint_before,
558
- keepalive_op,
559
- (
560
- snapshot_done = TRUE
561
- AND (
562
- last_checkpoint_lsn IS NULL
563
- OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
564
- )
536
+ // Transaction failures restart replication from the last durable checkpoint.
537
+ const result = await this.db.transaction(async (db) => {
538
+ const checkpoint = await db.sql`
539
+ WITH
540
+ selected AS (
541
+ SELECT
542
+ id,
543
+ state,
544
+ last_checkpoint,
545
+ last_checkpoint_lsn,
546
+ snapshot_done,
547
+ no_checkpoint_before,
548
+ keepalive_op,
549
+ (
550
+ snapshot_done = TRUE
551
+ AND (
552
+ last_checkpoint_lsn IS NULL
553
+ OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
554
+ )
555
+ AND (
556
+ no_checkpoint_before IS NULL
557
+ OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
558
+ )
559
+ ) AS can_checkpoint
560
+ FROM
561
+ sync_rules
562
+ WHERE
563
+ id = ${{ type: 'int4', value: this.group_id }}
564
+ FOR UPDATE
565
+ ),
566
+ computed AS (
567
+ SELECT
568
+ selected.*,
569
+ CASE
570
+ WHEN selected.can_checkpoint THEN GREATEST(
571
+ selected.last_checkpoint,
572
+ ${{ type: 'int8', value: persisted_op }},
573
+ selected.keepalive_op,
574
+ 0
575
+ )
576
+ ELSE selected.last_checkpoint
577
+ END AS new_last_checkpoint,
578
+ CASE
579
+ WHEN selected.can_checkpoint THEN NULL
580
+ ELSE GREATEST(
581
+ selected.keepalive_op,
582
+ ${{ type: 'int8', value: persisted_op }},
583
+ 0
584
+ )
585
+ END AS new_keepalive_op
586
+ FROM
587
+ selected
588
+ ),
589
+ updated AS (
590
+ UPDATE sync_rules AS sr
591
+ SET
592
+ last_checkpoint_lsn = CASE
593
+ WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
594
+ ELSE sr.last_checkpoint_lsn
595
+ END,
596
+ last_checkpoint_ts = CASE
597
+ WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
598
+ ELSE sr.last_checkpoint_ts
599
+ END,
600
+ last_keepalive_ts = ${{ type: 1184, value: now }},
601
+ last_fatal_error = CASE
602
+ WHEN computed.can_checkpoint THEN NULL
603
+ ELSE sr.last_fatal_error
604
+ END,
605
+ keepalive_op = computed.new_keepalive_op,
606
+ last_checkpoint = computed.new_last_checkpoint,
607
+ snapshot_lsn = CASE
608
+ WHEN computed.can_checkpoint THEN NULL
609
+ ELSE sr.snapshot_lsn
610
+ END
611
+ FROM
612
+ computed
613
+ WHERE
614
+ sr.id = computed.id
565
615
  AND (
566
- no_checkpoint_before IS NULL
567
- OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
616
+ sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
617
+ OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
618
+ OR ${{ type: 'bool', value: createEmptyCheckpoints }}
568
619
  )
569
- ) AS can_checkpoint
570
- FROM
571
- sync_rules
572
- WHERE
573
- id = ${{ type: 'int4', value: this.group_id }}
574
- FOR UPDATE
575
- ),
576
- computed AS (
577
- SELECT
578
- selected.*,
579
- CASE
580
- WHEN selected.can_checkpoint THEN GREATEST(
581
- selected.last_checkpoint,
582
- ${{ type: 'int8', value: persisted_op }},
583
- selected.keepalive_op,
584
- 0
585
- )
586
- ELSE selected.last_checkpoint
587
- END AS new_last_checkpoint,
588
- CASE
589
- WHEN selected.can_checkpoint THEN NULL
590
- ELSE GREATEST(
591
- selected.keepalive_op,
592
- ${{ type: 'int8', value: persisted_op }},
593
- 0
594
- )
595
- END AS new_keepalive_op
596
- FROM
597
- selected
598
- ),
599
- updated AS (
600
- UPDATE sync_rules AS sr
601
- SET
602
- last_checkpoint_lsn = CASE
603
- WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
604
- ELSE sr.last_checkpoint_lsn
605
- END,
606
- last_checkpoint_ts = CASE
607
- WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
608
- ELSE sr.last_checkpoint_ts
609
- END,
610
- last_keepalive_ts = ${{ type: 1184, value: now }},
611
- last_fatal_error = CASE
612
- WHEN computed.can_checkpoint THEN NULL
613
- ELSE sr.last_fatal_error
614
- END,
615
- keepalive_op = computed.new_keepalive_op,
616
- last_checkpoint = computed.new_last_checkpoint,
617
- snapshot_lsn = CASE
618
- WHEN computed.can_checkpoint THEN NULL
619
- ELSE sr.snapshot_lsn
620
- END
621
- FROM
622
- computed
623
- WHERE
624
- sr.id = computed.id
625
- AND (
626
- sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
627
- OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
628
- OR ${{ type: 'bool', value: createEmptyCheckpoints }}
629
- )
630
- RETURNING
631
- sr.id,
632
- sr.state,
633
- sr.last_checkpoint,
634
- sr.last_checkpoint_lsn,
635
- sr.snapshot_done,
636
- sr.no_checkpoint_before,
637
- computed.can_checkpoint,
638
- computed.keepalive_op,
639
- computed.new_last_checkpoint
640
- )
641
- SELECT
642
- id,
643
- state,
644
- last_checkpoint,
645
- last_checkpoint_lsn,
646
- snapshot_done,
647
- no_checkpoint_before,
648
- can_checkpoint,
649
- keepalive_op,
650
- new_last_checkpoint,
651
- TRUE AS created_checkpoint
652
- FROM
653
- updated
654
- UNION ALL
655
- SELECT
656
- id,
657
- state,
658
- new_last_checkpoint AS last_checkpoint,
659
- last_checkpoint_lsn,
660
- snapshot_done,
661
- no_checkpoint_before,
662
- can_checkpoint,
663
- keepalive_op,
664
- new_last_checkpoint,
665
- FALSE AS created_checkpoint
666
- FROM
667
- computed
668
- WHERE
669
- NOT EXISTS (
670
- SELECT
671
- 1
672
- FROM
673
- updated
674
- )
675
- `
676
- .decoded(CheckpointWithStatus)
677
- .first();
620
+ RETURNING
621
+ sr.id,
622
+ sr.state,
623
+ sr.last_checkpoint,
624
+ sr.last_checkpoint_lsn,
625
+ sr.snapshot_done,
626
+ sr.no_checkpoint_before,
627
+ computed.can_checkpoint,
628
+ computed.keepalive_op,
629
+ computed.new_last_checkpoint
630
+ )
631
+ SELECT
632
+ id,
633
+ state,
634
+ last_checkpoint,
635
+ last_checkpoint_lsn,
636
+ snapshot_done,
637
+ no_checkpoint_before,
638
+ can_checkpoint,
639
+ keepalive_op,
640
+ new_last_checkpoint,
641
+ TRUE AS created_checkpoint
642
+ FROM
643
+ updated
644
+ UNION ALL
645
+ SELECT
646
+ id,
647
+ state,
648
+ new_last_checkpoint AS last_checkpoint,
649
+ last_checkpoint_lsn,
650
+ snapshot_done,
651
+ no_checkpoint_before,
652
+ can_checkpoint,
653
+ keepalive_op,
654
+ new_last_checkpoint,
655
+ FALSE AS created_checkpoint
656
+ FROM
657
+ computed
658
+ WHERE
659
+ NOT EXISTS (
660
+ SELECT
661
+ 1
662
+ FROM
663
+ updated
664
+ )
665
+ `
666
+ .decoded(CheckpointWithStatus)
667
+ .first();
668
+
669
+ if (checkpoint?.can_checkpoint && checkpoint.state == storage.SyncRuleState.ACTIVE) {
670
+ await notifySyncRulesUpdate(db, checkpoint);
671
+ }
672
+ return checkpoint;
673
+ });
678
674
 
679
675
  if (result == null) {
680
676
  throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
@@ -706,10 +702,8 @@ export class PostgresBucketBatch
706
702
  });
707
703
  }
708
704
  }
709
- await this.autoActivate(lsn);
710
- await notifySyncRulesUpdate(this.db, {
705
+ await this.autoActivate(lsn, {
711
706
  id: result.id,
712
- state: result.state,
713
707
  last_checkpoint: result.last_checkpoint,
714
708
  last_checkpoint_lsn: result.last_checkpoint_lsn
715
709
  });
@@ -758,23 +752,22 @@ export class PostgresBucketBatch
758
752
 
759
753
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
760
754
  await this.db.transaction(async (db) => {
761
- const snapshotRequiredCount = await db.sql`
755
+ const blockingTables = await db.sql`
762
756
  SELECT
763
- COUNT(*) AS count
757
+ schema_name,
758
+ table_name
764
759
  FROM
765
760
  source_tables
766
761
  WHERE
767
762
  group_id = ${{ type: 'int4', value: this.group_id }}
768
763
  AND snapshot_done = FALSE
769
764
  `
770
- .decoded(t.object({ count: bigint }))
771
- .first();
772
- if ((snapshotRequiredCount?.count ?? 0n) > 0n) {
765
+ .decoded(t.object({ schema_name: t.string, table_name: t.string }))
766
+ .rows();
767
+ if (blockingTables.length > 0) {
773
768
  if (options?.throwOnConflict ?? true) {
774
769
  throw new ReplicationAssertionError(
775
- `Cannot mark snapshot done while ${snapshotRequiredCount?.count} source table${
776
- snapshotRequiredCount?.count == 1n ? '' : 's'
777
- } still require snapshotting`
770
+ `Cannot mark snapshot done while source tables still require snapshotting. Tables: ${blockingTables.map((t) => `${t.schema_name}.${t.table_name}`).join(', ')}`
778
771
  );
779
772
  } else {
780
773
  return;
@@ -890,10 +883,17 @@ export class PostgresBucketBatch
890
883
  });
891
884
  }
892
885
 
893
- protected async replicateBatch(db: lib_postgres.WrappedConnection, batch: OperationBatch) {
886
+ protected async replicateBatch(
887
+ db: lib_postgres.WrappedConnection,
888
+ batch: OperationBatch
889
+ ): Promise<{ resumeBatch: OperationBatch | null; clearedError: boolean }> {
894
890
  let sizes: Map<string, number> | undefined = undefined;
895
- if (this.options.store_current_data && !this.options.skip_existing_rows) {
896
- // We skip this step if we don't store current_data, since the sizes will
891
+ // Check if any table in this batch needs to store current_data
892
+ const anyTableStoresCurrentData =
893
+ this.options.store_current_data && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
894
+
895
+ if (anyTableStoresCurrentData && !this.options.skip_existing_rows) {
896
+ // We skip this step if no tables store current_data, since the sizes will
897
897
  // always be small in that case.
898
898
 
899
899
  // With skipExistingRows, we don't load the full documents into memory,
@@ -901,15 +901,19 @@ export class PostgresBucketBatch
901
901
 
902
902
  // Find sizes of current_data documents, to assist in intelligent batching without
903
903
  // exceeding memory limits.
904
- const sizeLookups = batch.batch.map((r) => {
905
- return {
906
- source_table: postgresTableId(r.record.sourceTable.id),
907
- /**
908
- * Encode to hex in order to pass a jsonb
909
- */
910
- source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
911
- };
912
- });
904
+ // Within this branch the batch stores current_data, so the per-table flag is the
905
+ // effective value - only look up sizes for tables that actually store current_data.
906
+ const sizeLookups = batch.batch
907
+ .filter((r) => r.record.sourceTable.storeCurrentData)
908
+ .map((r) => {
909
+ return {
910
+ source_table: postgresTableId(r.record.sourceTable.id),
911
+ /**
912
+ * Encode to hex in order to pass a jsonb
913
+ */
914
+ source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
915
+ };
916
+ });
913
917
 
914
918
  sizes = new Map<string, number>();
915
919
 
@@ -1006,15 +1010,14 @@ export class PostgresBucketBatch
1006
1010
  }
1007
1011
  }
1008
1012
 
1009
- if (didFlush) {
1010
- await this.clearError(db);
1011
- }
1013
+ // The error itself is cleared by the caller, outside the replication transaction - see flushInner.
1014
+ const clearedError = didFlush && !this.clearedError;
1012
1015
 
1013
1016
  // Don't return empty batches
1014
- if (resumeBatch?.batch.length) {
1015
- return resumeBatch;
1016
- }
1017
- return null;
1017
+ return {
1018
+ resumeBatch: resumeBatch?.batch.length ? resumeBatch : null,
1019
+ clearedError
1020
+ };
1018
1021
  }
1019
1022
 
1020
1023
  protected async saveOperation(
@@ -1028,6 +1031,9 @@ export class PostgresBucketBatch
1028
1031
  const afterId = operation.afterId;
1029
1032
  let after = record.after;
1030
1033
  const sourceTable = record.sourceTable;
1034
+ // Effective per-record flag: store current_data only if both the batch (source-level)
1035
+ // and the table (e.g. non-FULL replica identity) require it.
1036
+ const storeCurrentData = this.options.store_current_data && sourceTable.storeCurrentData;
1031
1037
 
1032
1038
  let existingBuckets: CurrentBucket[] = [];
1033
1039
  let newBuckets: CurrentBucket[] = [];
@@ -1056,7 +1062,7 @@ export class PostgresBucketBatch
1056
1062
  existingLookups = [];
1057
1063
  // Log to help with debugging if there was a consistency issue
1058
1064
 
1059
- if (this.options.store_current_data) {
1065
+ if (storeCurrentData) {
1060
1066
  if (this.markRecordUnavailable != null) {
1061
1067
  // This will trigger a "resnapshot" of the record.
1062
1068
  // This is not relevant if storeCurrentData is false, since we'll get the full row
@@ -1072,7 +1078,7 @@ export class PostgresBucketBatch
1072
1078
  } else {
1073
1079
  existingBuckets = result.buckets;
1074
1080
  existingLookups = result.lookups;
1075
- if (this.options.store_current_data) {
1081
+ if (storeCurrentData) {
1076
1082
  const data = storage.deserializeBson(result.data) as sync_rules.SqliteRow;
1077
1083
  after = storage.mergeToast(after!, data);
1078
1084
  }
@@ -1083,7 +1089,9 @@ export class PostgresBucketBatch
1083
1089
  // Not an error if we re-apply a transaction
1084
1090
  existingBuckets = [];
1085
1091
  existingLookups = [];
1086
- // Log to help with debugging if there was a consistency issue
1092
+ // Log to help with debugging if there was a consistency issue.
1093
+ // Gate on the batch-level flag: FULL tables (per-record flag false) still get a
1094
+ // current_data entry, so a missing record on DELETE is meaningful for them too.
1087
1095
  if (this.options.store_current_data && this.markRecordUnavailable == null) {
1088
1096
  this.logger.warn(
1089
1097
  `Cannot find previous record for delete on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`
@@ -1096,7 +1104,7 @@ export class PostgresBucketBatch
1096
1104
  }
1097
1105
 
1098
1106
  let afterData: Buffer<ArrayBuffer> | undefined;
1099
- if (afterId != null && !this.options.store_current_data) {
1107
+ if (afterId != null && !storeCurrentData) {
1100
1108
  afterData = storage.serializeBson({});
1101
1109
  } else if (afterId != null) {
1102
1110
  try {
@@ -1118,7 +1126,7 @@ export class PostgresBucketBatch
1118
1126
  {
1119
1127
  level: errors.ErrorSeverity.WARNING,
1120
1128
  metadata: {
1121
- replication_slot: this.options.slot_name,
1129
+ replication_slot: this.options.replicationStreamName,
1122
1130
  table: record.sourceTable.qualifiedName
1123
1131
  }
1124
1132
  }
@@ -1159,7 +1167,7 @@ export class PostgresBucketBatch
1159
1167
  // However, it will be valid by the end of the transaction.
1160
1168
  //
1161
1169
  // In this case, we don't save the op, but we do save the current data.
1162
- if (afterId && after && utils.isCompleteRow(this.options.store_current_data, after)) {
1170
+ if (afterId && after && utils.isCompleteRow(storeCurrentData, after)) {
1163
1171
  // Insert or update
1164
1172
  if (sourceTable.syncData) {
1165
1173
  const { results: evaluated, errors: syncErrors } = this.sync_rules.evaluateRowWithErrors({
@@ -1174,7 +1182,7 @@ export class PostgresBucketBatch
1174
1182
  {
1175
1183
  level: errors.ErrorSeverity.WARNING,
1176
1184
  metadata: {
1177
- replication_slot: this.options.slot_name,
1185
+ replication_slot: this.options.replicationStreamName,
1178
1186
  table: record.sourceTable.qualifiedName
1179
1187
  }
1180
1188
  }
@@ -1215,7 +1223,7 @@ export class PostgresBucketBatch
1215
1223
  {
1216
1224
  level: errors.ErrorSeverity.WARNING,
1217
1225
  metadata: {
1218
- replication_slot: this.options.slot_name,
1226
+ replication_slot: this.options.replicationStreamName,
1219
1227
  table: record.sourceTable.qualifiedName
1220
1228
  }
1221
1229
  }
@@ -1272,14 +1280,13 @@ export class PostgresBucketBatch
1272
1280
  *
1273
1281
  * Called on new commits.
1274
1282
  */
1275
- private async autoActivate(lsn: string): Promise<void> {
1283
+ private async autoActivate(lsn: string, checkpoint: models.ActiveCheckpointDecoded): Promise<void> {
1276
1284
  if (!this.needsActivation) {
1277
1285
  // Already activated
1278
1286
  return;
1279
1287
  }
1280
1288
 
1281
- let didActivate = false;
1282
- await this.db.transaction(async (db) => {
1289
+ const activationResult = await this.db.transaction(async (db) => {
1283
1290
  const syncRulesRow = await db.sql`
1284
1291
  SELECT
1285
1292
  state,
@@ -1313,22 +1320,27 @@ export class PostgresBucketBatch
1313
1320
  )
1314
1321
  AND id != ${{ type: 'int4', value: this.group_id }}
1315
1322
  `.execute();
1316
- didActivate = true;
1317
- this.needsActivation = false;
1323
+ await notifySyncRulesUpdate(db, checkpoint);
1324
+ return 'activated';
1318
1325
  } else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
1319
- this.needsActivation = false;
1326
+ return 'not-processing';
1320
1327
  }
1328
+ return 'pending';
1321
1329
  });
1322
- if (didActivate) {
1330
+
1331
+ if (activationResult != 'pending') {
1332
+ this.needsActivation = false;
1333
+ }
1334
+ if (activationResult == 'activated') {
1323
1335
  this.logger.info(`Activated new replication stream at ${lsn}`);
1324
1336
  }
1325
1337
  }
1326
1338
 
1327
1339
  /**
1328
- * Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
1340
+ * Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
1329
1341
  * TODO maybe share this with an abstract class
1330
1342
  */
1331
- protected getTableEvents(table: storage.SourceTable): sync_rules.SqlEventDescriptor[] {
1343
+ protected getTableEvents(table: storage.SourceTable): sync_rules.HydratedEventDescriptor[] {
1332
1344
  return this.sync_rules.eventDescriptors.filter((evt) =>
1333
1345
  [...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table.ref))
1334
1346
  );
@@ -1374,11 +1386,6 @@ export class PostgresBucketBatch
1374
1386
  protected async clearError(
1375
1387
  db: lib_postgres.AbstractPostgresConnection | lib_postgres.DatabaseClient = this.db
1376
1388
  ): Promise<void> {
1377
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
1378
- if (this.clearedError) {
1379
- return;
1380
- }
1381
-
1382
1389
  await db.sql`
1383
1390
  UPDATE sync_rules
1384
1391
  SET
@@ -1386,7 +1393,6 @@ export class PostgresBucketBatch
1386
1393
  WHERE
1387
1394
  id = ${{ type: 'int4', value: this.group_id }}
1388
1395
  `.execute();
1389
- this.clearedError = true;
1390
1396
  }
1391
1397
 
1392
1398
  private async getLastOpIdSequence(db: lib_postgres.AbstractPostgresConnection) {
@@ -1411,12 +1417,17 @@ export class PostgresBucketBatch
1411
1417
  * Uses Postgres' NOTIFY functionality to update different processes when the
1412
1418
  * active checkpoint has been updated.
1413
1419
  */
1414
- export const notifySyncRulesUpdate = async (db: lib_postgres.DatabaseClient, update: StatefulCheckpointDecoded) => {
1415
- if (update.state != storage.SyncRuleState.ACTIVE) {
1416
- return;
1417
- }
1418
-
1419
- await db.query({
1420
- statement: `NOTIFY ${NOTIFICATION_CHANNEL}, '${models.ActiveCheckpointNotification.encode({ active_checkpoint: update })}'`
1421
- });
1420
+ export const notifySyncRulesUpdate = async (
1421
+ db: lib_postgres.AbstractPostgresConnection,
1422
+ update: models.ActiveCheckpointDecoded
1423
+ ) => {
1424
+ const payload = models.ActiveCheckpointNotification.encode({ active_checkpoint: update });
1425
+
1426
+ await db.sql`
1427
+ SELECT
1428
+ pg_notify (
1429
+ ${{ type: 'varchar', value: NOTIFICATION_CHANNEL }},
1430
+ ${{ type: 'varchar', value: payload }}
1431
+ )
1432
+ `.execute();
1422
1433
  };