@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
@@ -1,5 +1,6 @@
1
1
  import { BaseObserver, container, ErrorCode, errors, ReplicationAssertionError, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
2
2
  import { deserializeReplicaId, storage, utils } from '@powersync/service-core';
3
+ import { orNull } from '@powersync/service-types';
3
4
  import * as timers from 'timers/promises';
4
5
  import * as t from 'ts-codec';
5
6
  import * as uuid from 'uuid';
@@ -19,10 +20,10 @@ import { PostgresPersistedBatch } from './PostgresPersistedBatch.js';
19
20
  const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
20
21
  const CheckpointWithStatus = StatefulCheckpoint.and(t.object({
21
22
  snapshot_done: t.boolean,
22
- no_checkpoint_before: t.string.or(t.Null),
23
+ no_checkpoint_before: orNull(t.string),
23
24
  can_checkpoint: t.boolean,
24
- keepalive_op: bigint.or(t.Null),
25
- new_last_checkpoint: bigint.or(t.Null),
25
+ keepalive_op: orNull(bigint),
26
+ new_last_checkpoint: orNull(bigint),
26
27
  created_checkpoint: t.boolean
27
28
  }));
28
29
  /**
@@ -55,7 +56,7 @@ export class PostgresBucketBatch extends BaseObserver {
55
56
  this.options = options;
56
57
  this.logger = options.logger;
57
58
  this.db = options.db;
58
- this.group_id = options.group_id;
59
+ this.group_id = options.replicationStreamId;
59
60
  this.last_checkpoint_lsn = options.last_checkpoint_lsn;
60
61
  this.resumeFromLsn = options.resumeFromLsn;
61
62
  this.skipExistingRows = options.skip_existing_rows;
@@ -71,9 +72,6 @@ export class PostgresBucketBatch extends BaseObserver {
71
72
  this.persisted_op = options.keep_alive_op;
72
73
  }
73
74
  }
74
- get lastCheckpointLsn() {
75
- return this.last_checkpoint_lsn;
76
- }
77
75
  async [Symbol.asyncDispose]() {
78
76
  if (this.batch != null || this.write_checkpoint_batch.length > 0) {
79
77
  // We don't error here, since:
@@ -87,18 +85,20 @@ export class PostgresBucketBatch extends BaseObserver {
87
85
  await this[Symbol.asyncDispose]();
88
86
  }
89
87
  async resolveTables(options) {
90
- const syncRules = options.syncRules ?? this.sync_rules;
88
+ const syncRules = options.parsedSyncConfig?.hydratedSyncConfig ?? this.sync_rules;
89
+ const reconcile = options.reconcileSourceTables ?? storage.defaultSourceTableReconciler;
91
90
  const { connection_id, source } = options;
92
- const { schema, name: table, objectId, replicaIdColumns, connectionTag } = source;
91
+ const { schema, name: table, objectId, replicaIdColumns, connectionTag, sendsCompleteRows } = source;
93
92
  const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
94
93
  name: column.name,
95
94
  type: column.type,
96
95
  type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
97
96
  }));
98
97
  return this.db.transaction(async (db) => {
99
- let sourceTableRow;
98
+ // Find records that overlap by name or relation id.
99
+ let candidateRows;
100
100
  if (objectId != null) {
101
- sourceTableRow = await db.sql `
101
+ candidateRows = await db.sql `
102
102
  SELECT
103
103
  *
104
104
  FROM
@@ -106,16 +106,19 @@ export class PostgresBucketBatch extends BaseObserver {
106
106
  WHERE
107
107
  group_id = ${{ type: 'int4', value: this.group_id }}
108
108
  AND connection_id = ${{ type: 'int4', value: connection_id }}
109
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } }}
110
- AND schema_name = ${{ type: 'varchar', value: schema }}
111
- AND table_name = ${{ type: 'varchar', value: table }}
112
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
109
+ AND (
110
+ relation_id = ${{ type: 'jsonb', value: { object_id: objectId } }}
111
+ OR (
112
+ schema_name = ${{ type: 'varchar', value: schema }}
113
+ AND table_name = ${{ type: 'varchar', value: table }}
114
+ )
115
+ )
113
116
  `
114
117
  .decoded(models.SourceTable)
115
- .first();
118
+ .rows();
116
119
  }
117
120
  else {
118
- sourceTableRow = await db.sql `
121
+ candidateRows = await db.sql `
119
122
  SELECT
120
123
  *
121
124
  FROM
@@ -125,14 +128,38 @@ export class PostgresBucketBatch extends BaseObserver {
125
128
  AND connection_id = ${{ type: 'int4', value: connection_id }}
126
129
  AND schema_name = ${{ type: 'varchar', value: schema }}
127
130
  AND table_name = ${{ type: 'varchar', value: table }}
128
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
129
131
  `
130
132
  .decoded(models.SourceTable)
131
- .first();
133
+ .rows();
132
134
  }
133
- if (sourceTableRow == null) {
135
+ const candidateTables = candidateRows.map((row) => {
136
+ const table = this.sourceTableFromRow(row, connectionTag, syncRules);
137
+ table.storeCurrentData = sendsCompleteRows !== true;
138
+ return table;
139
+ });
140
+ const candidates = candidateTables.map((table) => table.clone());
141
+ const resolution = await reconcile({ source, candidates });
142
+ storage.validateSourceTableCandidateResolution(candidates, resolution);
143
+ for (const { id, sourceMetadata } of storage.diffSourceTableUpdates(candidateTables, resolution)) {
144
+ await db.sql `
145
+ UPDATE source_tables
146
+ SET
147
+ source_metadata = ${{ type: 'jsonb', value: sourceMetadata ?? null }}
148
+ WHERE
149
+ id = ${{ type: 'varchar', value: id.toString() }}
150
+ `.execute();
151
+ }
152
+ const materializedResolution = storage.materializeSourceTableResolution(candidateTables, resolution);
153
+ const compatibleTables = materializedResolution.compatibleTables;
154
+ // Keep one record, preferring one that has already been snapshotted.
155
+ const reuse = compatibleTables.find((candidate) => candidate.snapshotComplete) ?? compatibleTables[0] ?? null;
156
+ let sourceTable;
157
+ if (reuse != null) {
158
+ sourceTable = reuse;
159
+ }
160
+ else {
134
161
  const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
135
- sourceTableRow = await db.sql `
162
+ const insertedRow = await db.sql `
136
163
  INSERT INTO
137
164
  source_tables (
138
165
  id,
@@ -141,7 +168,8 @@ export class PostgresBucketBatch extends BaseObserver {
141
168
  relation_id,
142
169
  schema_name,
143
170
  table_name,
144
- replica_id_columns
171
+ replica_id_columns,
172
+ source_metadata
145
173
  )
146
174
  VALUES
147
175
  (
@@ -151,89 +179,25 @@ export class PostgresBucketBatch extends BaseObserver {
151
179
  ${{ type: 'jsonb', value: { object_id: objectId } }},
152
180
  ${{ type: 'varchar', value: schema }},
153
181
  ${{ type: 'varchar', value: table }},
154
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
182
+ ${{ type: 'jsonb', value: normalizedReplicaIdColumns }},
183
+ ${{ type: 'jsonb', value: resolution.newTableValues.sourceMetadata ?? null }}
155
184
  )
156
185
  RETURNING
157
186
  *
158
187
  `
159
188
  .decoded(models.SourceTable)
160
189
  .first();
190
+ sourceTable = this.sourceTableFromRow(insertedRow, connectionTag, syncRules);
191
+ sourceTable.storeCurrentData = sendsCompleteRows !== true;
161
192
  }
162
- const sourceTable = new storage.SourceTable({
163
- id: sourceTableRow.id,
164
- ref: source,
165
- objectId,
166
- replicaIdColumns,
167
- snapshotComplete: sourceTableRow.snapshot_done ?? true,
168
- ...syncRules.getMatchingSources(source)
169
- });
170
- if (!sourceTable.snapshotComplete) {
171
- sourceTable.snapshotStatus = {
172
- totalEstimatedCount: Number(sourceTableRow.snapshot_total_estimated_count ?? -1n),
173
- replicatedCount: Number(sourceTableRow.snapshot_replicated_count ?? 0n),
174
- lastKey: sourceTableRow.snapshot_last_key
175
- };
176
- }
177
- sourceTable.syncEvent = syncRules.tableTriggersEvent(source);
178
- sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
179
- sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
180
- let truncatedTables = [];
181
- if (objectId != null) {
182
- truncatedTables = await db.sql `
183
- SELECT
184
- *
185
- FROM
186
- source_tables
187
- WHERE
188
- group_id = ${{ type: 'int4', value: this.group_id }}
189
- AND connection_id = ${{ type: 'int4', value: connection_id }}
190
- AND id != ${{ type: 'varchar', value: sourceTableRow.id }}
191
- AND (
192
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } }}
193
- OR (
194
- schema_name = ${{ type: 'varchar', value: schema }}
195
- AND table_name = ${{ type: 'varchar', value: table }}
196
- )
197
- )
198
- `
199
- .decoded(models.SourceTable)
200
- .rows();
201
- }
202
- else {
203
- truncatedTables = await db.sql `
204
- SELECT
205
- *
206
- FROM
207
- source_tables
208
- WHERE
209
- group_id = ${{ type: 'int4', value: this.group_id }}
210
- AND connection_id = ${{ type: 'int4', value: connection_id }}
211
- AND id != ${{ type: 'varchar', value: sourceTableRow.id }}
212
- AND (
213
- schema_name = ${{ type: 'varchar', value: schema }}
214
- AND table_name = ${{ type: 'varchar', value: table }}
215
- )
216
- `
217
- .decoded(models.SourceTable)
218
- .rows();
219
- }
193
+ const dropTables = [
194
+ ...materializedResolution.incompatibleTables,
195
+ // PostgreSQL storage only keeps one SourceTable per physical table.
196
+ ...compatibleTables.filter((candidate) => !storage.sourceTableIdEquals(candidate.id, sourceTable.id))
197
+ ];
220
198
  return {
221
199
  tables: [sourceTable],
222
- dropTables: truncatedTables.map((doc) => {
223
- const ref = { connectionTag, schema: doc.schema_name, name: doc.table_name };
224
- const dropTable = new storage.SourceTable({
225
- id: doc.id,
226
- ref,
227
- objectId: doc.relation_id?.object_id ?? 0,
228
- replicaIdColumns: doc.replica_id_columns?.map((c) => ({ name: c.name, typeId: c.typeId, type: c.type })) ?? [],
229
- snapshotComplete: doc.snapshot_done ?? true,
230
- ...syncRules.getMatchingSources(ref)
231
- });
232
- dropTable.syncEvent = syncRules.tableTriggersEvent(ref);
233
- dropTable.syncData = dropTable.bucketDataSources.length > 0;
234
- dropTable.syncParameters = dropTable.parameterLookupSources.length > 0;
235
- return dropTable;
236
- })
200
+ dropTables
237
201
  };
238
202
  });
239
203
  }
@@ -260,8 +224,9 @@ export class PostgresBucketBatch extends BaseObserver {
260
224
  id: row.id,
261
225
  ref,
262
226
  objectId: row.relation_id?.object_id,
263
- replicaIdColumns: row.replica_id_columns?.map((c) => ({ name: c.name, typeId: c.typeId, type: c.type })) ?? [],
227
+ replicaIdColumns: row.replica_id_columns?.map((column) => ({ name: column.name, typeId: column.type_oid, type: column.type })) ?? [],
264
228
  snapshotComplete: row.snapshot_done ?? true,
229
+ sourceMetadata: row.source_metadata,
265
230
  ...syncRules.getMatchingSources(ref)
266
231
  });
267
232
  if (!sourceTable.snapshotComplete) {
@@ -279,17 +244,23 @@ export class PostgresBucketBatch extends BaseObserver {
279
244
  async save(record) {
280
245
  // TODO maybe share with abstract class
281
246
  const { after, before, sourceTable, tag } = record;
282
- for (const event of this.getTableEvents(sourceTable)) {
283
- this.iterateListeners((cb) => cb.replicationEvent?.({
284
- batch: this,
285
- table: sourceTable,
286
- data: {
287
- op: tag,
288
- after: after && utils.isCompleteRow(this.options.store_current_data, after) ? after : undefined,
289
- before: before && utils.isCompleteRow(this.options.store_current_data, before) ? before : undefined
290
- },
291
- event
292
- }));
247
+ const storeCurrentData = this.options.store_current_data && sourceTable.storeCurrentData;
248
+ // PostgreSQL storage resolves each physical table to one SourceTable. syncEvent records whether
249
+ // that table matches any event definition; each matching definition is evaluated once here and
250
+ // may produce multiple payloads from its matching queries.
251
+ if (sourceTable.syncEvent) {
252
+ for (const event of this.getTableEvents(sourceTable)) {
253
+ this.iterateListeners((cb) => cb.replicationEvent?.({
254
+ batch: this,
255
+ table: sourceTable,
256
+ data: {
257
+ op: tag,
258
+ after: after && utils.isCompleteRow(storeCurrentData, after) ? after : undefined,
259
+ before: before && utils.isCompleteRow(storeCurrentData, before) ? before : undefined
260
+ },
261
+ event
262
+ }));
263
+ }
293
264
  }
294
265
  /**
295
266
  * Return if the table is just an event table
@@ -332,7 +303,9 @@ export class PostgresBucketBatch extends BaseObserver {
332
303
  let processedCount = 0;
333
304
  while (lastBatchCount == BATCH_LIMIT) {
334
305
  lastBatchCount = 0;
306
+ let clearedError = false;
335
307
  await this.withReplicationTransaction(async (db) => {
308
+ clearedError = false;
336
309
  const persistedBatch = new PostgresPersistedBatch({
337
310
  group_id: this.group_id,
338
311
  storageConfig: this.storageConfig,
@@ -370,10 +343,14 @@ export class PostgresBucketBatch extends BaseObserver {
370
343
  }
371
344
  }
372
345
  const { flushedAny } = await persistedBatch.flush(db);
373
- if (flushedAny) {
374
- await this.clearError(db);
375
- }
346
+ clearedError = flushedAny && !this.clearedError;
376
347
  });
348
+ if (clearedError) {
349
+ // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
350
+ // Cleared outside the replication transaction - see flushInner.
351
+ await this.clearError();
352
+ this.clearedError = true;
353
+ }
377
354
  }
378
355
  if (processedCount == 0) {
379
356
  // The op sequence should not have progressed
@@ -419,10 +396,21 @@ export class PostgresBucketBatch extends BaseObserver {
419
396
  }
420
397
  await this.hooks?.beforeBatchFlush?.(this);
421
398
  let resumeBatch = null;
399
+ let clearedError = false;
422
400
  const lastOp = await this.withReplicationTransaction(async (db) => {
423
- resumeBatch = await this.replicateBatch(db, batch);
401
+ clearedError = false;
402
+ const result = await this.replicateBatch(db, batch);
403
+ resumeBatch = result.resumeBatch;
404
+ clearedError ||= result.clearedError;
424
405
  return this.getLastOpIdSequence(db);
425
406
  });
407
+ if (clearedError) {
408
+ // Clear the error outside the replication transaction (plain autocommit update,
409
+ // like the keepalive), to avoid serialization conflicts on the sync_rules row
410
+ // when multiple writers flush concurrently.
411
+ await this.clearError();
412
+ this.clearedError = true;
413
+ }
426
414
  // null if done, set if we need another flush
427
415
  this.batch = resumeBatch;
428
416
  if (lastOp == null) {
@@ -438,136 +426,143 @@ export class PostgresBucketBatch extends BaseObserver {
438
426
  await this.flush();
439
427
  const now = new Date().toISOString();
440
428
  const persisted_op = this.persisted_op ?? null;
441
- const result = await this.db.sql `
442
- WITH
443
- selected AS (
444
- SELECT
445
- id,
446
- state,
447
- last_checkpoint,
448
- last_checkpoint_lsn,
449
- snapshot_done,
450
- no_checkpoint_before,
451
- keepalive_op,
452
- (
453
- snapshot_done = TRUE
454
- AND (
455
- last_checkpoint_lsn IS NULL
456
- OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
457
- )
429
+ // Transaction failures restart replication from the last durable checkpoint.
430
+ const result = await this.db.transaction(async (db) => {
431
+ const checkpoint = await db.sql `
432
+ WITH
433
+ selected AS (
434
+ SELECT
435
+ id,
436
+ state,
437
+ last_checkpoint,
438
+ last_checkpoint_lsn,
439
+ snapshot_done,
440
+ no_checkpoint_before,
441
+ keepalive_op,
442
+ (
443
+ snapshot_done = TRUE
444
+ AND (
445
+ last_checkpoint_lsn IS NULL
446
+ OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
447
+ )
448
+ AND (
449
+ no_checkpoint_before IS NULL
450
+ OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
451
+ )
452
+ ) AS can_checkpoint
453
+ FROM
454
+ sync_rules
455
+ WHERE
456
+ id = ${{ type: 'int4', value: this.group_id }}
457
+ FOR UPDATE
458
+ ),
459
+ computed AS (
460
+ SELECT
461
+ selected.*,
462
+ CASE
463
+ WHEN selected.can_checkpoint THEN GREATEST(
464
+ selected.last_checkpoint,
465
+ ${{ type: 'int8', value: persisted_op }},
466
+ selected.keepalive_op,
467
+ 0
468
+ )
469
+ ELSE selected.last_checkpoint
470
+ END AS new_last_checkpoint,
471
+ CASE
472
+ WHEN selected.can_checkpoint THEN NULL
473
+ ELSE GREATEST(
474
+ selected.keepalive_op,
475
+ ${{ type: 'int8', value: persisted_op }},
476
+ 0
477
+ )
478
+ END AS new_keepalive_op
479
+ FROM
480
+ selected
481
+ ),
482
+ updated AS (
483
+ UPDATE sync_rules AS sr
484
+ SET
485
+ last_checkpoint_lsn = CASE
486
+ WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
487
+ ELSE sr.last_checkpoint_lsn
488
+ END,
489
+ last_checkpoint_ts = CASE
490
+ WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
491
+ ELSE sr.last_checkpoint_ts
492
+ END,
493
+ last_keepalive_ts = ${{ type: 1184, value: now }},
494
+ last_fatal_error = CASE
495
+ WHEN computed.can_checkpoint THEN NULL
496
+ ELSE sr.last_fatal_error
497
+ END,
498
+ keepalive_op = computed.new_keepalive_op,
499
+ last_checkpoint = computed.new_last_checkpoint,
500
+ snapshot_lsn = CASE
501
+ WHEN computed.can_checkpoint THEN NULL
502
+ ELSE sr.snapshot_lsn
503
+ END
504
+ FROM
505
+ computed
506
+ WHERE
507
+ sr.id = computed.id
458
508
  AND (
459
- no_checkpoint_before IS NULL
460
- OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
509
+ sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
510
+ OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
511
+ OR ${{ type: 'bool', value: createEmptyCheckpoints }}
461
512
  )
462
- ) AS can_checkpoint
463
- FROM
464
- sync_rules
465
- WHERE
466
- id = ${{ type: 'int4', value: this.group_id }}
467
- FOR UPDATE
468
- ),
469
- computed AS (
470
- SELECT
471
- selected.*,
472
- CASE
473
- WHEN selected.can_checkpoint THEN GREATEST(
474
- selected.last_checkpoint,
475
- ${{ type: 'int8', value: persisted_op }},
476
- selected.keepalive_op,
477
- 0
478
- )
479
- ELSE selected.last_checkpoint
480
- END AS new_last_checkpoint,
481
- CASE
482
- WHEN selected.can_checkpoint THEN NULL
483
- ELSE GREATEST(
484
- selected.keepalive_op,
485
- ${{ type: 'int8', value: persisted_op }},
486
- 0
487
- )
488
- END AS new_keepalive_op
489
- FROM
490
- selected
491
- ),
492
- updated AS (
493
- UPDATE sync_rules AS sr
494
- SET
495
- last_checkpoint_lsn = CASE
496
- WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
497
- ELSE sr.last_checkpoint_lsn
498
- END,
499
- last_checkpoint_ts = CASE
500
- WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
501
- ELSE sr.last_checkpoint_ts
502
- END,
503
- last_keepalive_ts = ${{ type: 1184, value: now }},
504
- last_fatal_error = CASE
505
- WHEN computed.can_checkpoint THEN NULL
506
- ELSE sr.last_fatal_error
507
- END,
508
- keepalive_op = computed.new_keepalive_op,
509
- last_checkpoint = computed.new_last_checkpoint,
510
- snapshot_lsn = CASE
511
- WHEN computed.can_checkpoint THEN NULL
512
- ELSE sr.snapshot_lsn
513
- END
514
- FROM
515
- computed
516
- WHERE
517
- sr.id = computed.id
518
- AND (
519
- sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
520
- OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
521
- OR ${{ type: 'bool', value: createEmptyCheckpoints }}
522
- )
523
- RETURNING
524
- sr.id,
525
- sr.state,
526
- sr.last_checkpoint,
527
- sr.last_checkpoint_lsn,
528
- sr.snapshot_done,
529
- sr.no_checkpoint_before,
530
- computed.can_checkpoint,
531
- computed.keepalive_op,
532
- computed.new_last_checkpoint
533
- )
534
- SELECT
535
- id,
536
- state,
537
- last_checkpoint,
538
- last_checkpoint_lsn,
539
- snapshot_done,
540
- no_checkpoint_before,
541
- can_checkpoint,
542
- keepalive_op,
543
- new_last_checkpoint,
544
- TRUE AS created_checkpoint
545
- FROM
546
- updated
547
- UNION ALL
548
- SELECT
549
- id,
550
- state,
551
- new_last_checkpoint AS last_checkpoint,
552
- last_checkpoint_lsn,
553
- snapshot_done,
554
- no_checkpoint_before,
555
- can_checkpoint,
556
- keepalive_op,
557
- new_last_checkpoint,
558
- FALSE AS created_checkpoint
559
- FROM
560
- computed
561
- WHERE
562
- NOT EXISTS (
563
- SELECT
564
- 1
565
- FROM
566
- updated
567
- )
568
- `
569
- .decoded(CheckpointWithStatus)
570
- .first();
513
+ RETURNING
514
+ sr.id,
515
+ sr.state,
516
+ sr.last_checkpoint,
517
+ sr.last_checkpoint_lsn,
518
+ sr.snapshot_done,
519
+ sr.no_checkpoint_before,
520
+ computed.can_checkpoint,
521
+ computed.keepalive_op,
522
+ computed.new_last_checkpoint
523
+ )
524
+ SELECT
525
+ id,
526
+ state,
527
+ last_checkpoint,
528
+ last_checkpoint_lsn,
529
+ snapshot_done,
530
+ no_checkpoint_before,
531
+ can_checkpoint,
532
+ keepalive_op,
533
+ new_last_checkpoint,
534
+ TRUE AS created_checkpoint
535
+ FROM
536
+ updated
537
+ UNION ALL
538
+ SELECT
539
+ id,
540
+ state,
541
+ new_last_checkpoint AS last_checkpoint,
542
+ last_checkpoint_lsn,
543
+ snapshot_done,
544
+ no_checkpoint_before,
545
+ can_checkpoint,
546
+ keepalive_op,
547
+ new_last_checkpoint,
548
+ FALSE AS created_checkpoint
549
+ FROM
550
+ computed
551
+ WHERE
552
+ NOT EXISTS (
553
+ SELECT
554
+ 1
555
+ FROM
556
+ updated
557
+ )
558
+ `
559
+ .decoded(CheckpointWithStatus)
560
+ .first();
561
+ if (checkpoint?.can_checkpoint && checkpoint.state == storage.SyncRuleState.ACTIVE) {
562
+ await notifySyncRulesUpdate(db, checkpoint);
563
+ }
564
+ return checkpoint;
565
+ });
571
566
  if (result == null) {
572
567
  throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
573
568
  }
@@ -591,10 +586,8 @@ export class PostgresBucketBatch extends BaseObserver {
591
586
  });
592
587
  }
593
588
  }
594
- await this.autoActivate(lsn);
595
- await notifySyncRulesUpdate(this.db, {
589
+ await this.autoActivate(lsn, {
596
590
  id: result.id,
597
- state: result.state,
598
591
  last_checkpoint: result.last_checkpoint,
599
592
  last_checkpoint_lsn: result.last_checkpoint_lsn
600
593
  });
@@ -637,20 +630,21 @@ export class PostgresBucketBatch extends BaseObserver {
637
630
  }
638
631
  async markSnapshotDone(no_checkpoint_before_lsn, options) {
639
632
  await this.db.transaction(async (db) => {
640
- const snapshotRequiredCount = await db.sql `
633
+ const blockingTables = await db.sql `
641
634
  SELECT
642
- COUNT(*) AS count
635
+ schema_name,
636
+ table_name
643
637
  FROM
644
638
  source_tables
645
639
  WHERE
646
640
  group_id = ${{ type: 'int4', value: this.group_id }}
647
641
  AND snapshot_done = FALSE
648
642
  `
649
- .decoded(t.object({ count: bigint }))
650
- .first();
651
- if ((snapshotRequiredCount?.count ?? 0n) > 0n) {
643
+ .decoded(t.object({ schema_name: t.string, table_name: t.string }))
644
+ .rows();
645
+ if (blockingTables.length > 0) {
652
646
  if (options?.throwOnConflict ?? true) {
653
- throw new ReplicationAssertionError(`Cannot mark snapshot done while ${snapshotRequiredCount?.count} source table${snapshotRequiredCount?.count == 1n ? '' : 's'} still require snapshotting`);
647
+ throw new ReplicationAssertionError(`Cannot mark snapshot done while source tables still require snapshotting. Tables: ${blockingTables.map((t) => `${t.schema_name}.${t.table_name}`).join(', ')}`);
654
648
  }
655
649
  else {
656
650
  return;
@@ -752,14 +746,20 @@ export class PostgresBucketBatch extends BaseObserver {
752
746
  }
753
747
  async replicateBatch(db, batch) {
754
748
  let sizes = undefined;
755
- if (this.options.store_current_data && !this.options.skip_existing_rows) {
756
- // We skip this step if we don't store current_data, since the sizes will
749
+ // Check if any table in this batch needs to store current_data
750
+ const anyTableStoresCurrentData = this.options.store_current_data && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
751
+ if (anyTableStoresCurrentData && !this.options.skip_existing_rows) {
752
+ // We skip this step if no tables store current_data, since the sizes will
757
753
  // always be small in that case.
758
754
  // With skipExistingRows, we don't load the full documents into memory,
759
755
  // so we can also skip the size lookup step.
760
756
  // Find sizes of current_data documents, to assist in intelligent batching without
761
757
  // exceeding memory limits.
762
- const sizeLookups = batch.batch.map((r) => {
758
+ // Within this branch the batch stores current_data, so the per-table flag is the
759
+ // effective value - only look up sizes for tables that actually store current_data.
760
+ const sizeLookups = batch.batch
761
+ .filter((r) => r.record.sourceTable.storeCurrentData)
762
+ .map((r) => {
763
763
  return {
764
764
  source_table: postgresTableId(r.record.sourceTable.id),
765
765
  /**
@@ -849,14 +849,13 @@ export class PostgresBucketBatch extends BaseObserver {
849
849
  didFlush ||= flushedAny;
850
850
  }
851
851
  }
852
- if (didFlush) {
853
- await this.clearError(db);
854
- }
852
+ // The error itself is cleared by the caller, outside the replication transaction - see flushInner.
853
+ const clearedError = didFlush && !this.clearedError;
855
854
  // Don't return empty batches
856
- if (resumeBatch?.batch.length) {
857
- return resumeBatch;
858
- }
859
- return null;
855
+ return {
856
+ resumeBatch: resumeBatch?.batch.length ? resumeBatch : null,
857
+ clearedError
858
+ };
860
859
  }
861
860
  async saveOperation(persistedBatch, operation, currentData) {
862
861
  const record = operation.record;
@@ -865,6 +864,9 @@ export class PostgresBucketBatch extends BaseObserver {
865
864
  const afterId = operation.afterId;
866
865
  let after = record.after;
867
866
  const sourceTable = record.sourceTable;
867
+ // Effective per-record flag: store current_data only if both the batch (source-level)
868
+ // and the table (e.g. non-FULL replica identity) require it.
869
+ const storeCurrentData = this.options.store_current_data && sourceTable.storeCurrentData;
868
870
  let existingBuckets = [];
869
871
  let newBuckets = [];
870
872
  let existingLookups = [];
@@ -890,7 +892,7 @@ export class PostgresBucketBatch extends BaseObserver {
890
892
  existingBuckets = [];
891
893
  existingLookups = [];
892
894
  // Log to help with debugging if there was a consistency issue
893
- if (this.options.store_current_data) {
895
+ if (storeCurrentData) {
894
896
  if (this.markRecordUnavailable != null) {
895
897
  // This will trigger a "resnapshot" of the record.
896
898
  // This is not relevant if storeCurrentData is false, since we'll get the full row
@@ -906,7 +908,7 @@ export class PostgresBucketBatch extends BaseObserver {
906
908
  else {
907
909
  existingBuckets = result.buckets;
908
910
  existingLookups = result.lookups;
909
- if (this.options.store_current_data) {
911
+ if (storeCurrentData) {
910
912
  const data = storage.deserializeBson(result.data);
911
913
  after = storage.mergeToast(after, data);
912
914
  }
@@ -918,7 +920,9 @@ export class PostgresBucketBatch extends BaseObserver {
918
920
  // Not an error if we re-apply a transaction
919
921
  existingBuckets = [];
920
922
  existingLookups = [];
921
- // Log to help with debugging if there was a consistency issue
923
+ // Log to help with debugging if there was a consistency issue.
924
+ // Gate on the batch-level flag: FULL tables (per-record flag false) still get a
925
+ // current_data entry, so a missing record on DELETE is meaningful for them too.
922
926
  if (this.options.store_current_data && this.markRecordUnavailable == null) {
923
927
  this.logger.warn(`Cannot find previous record for delete on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
924
928
  }
@@ -929,7 +933,7 @@ export class PostgresBucketBatch extends BaseObserver {
929
933
  }
930
934
  }
931
935
  let afterData;
932
- if (afterId != null && !this.options.store_current_data) {
936
+ if (afterId != null && !storeCurrentData) {
933
937
  afterData = storage.serializeBson({});
934
938
  }
935
939
  else if (afterId != null) {
@@ -948,7 +952,7 @@ export class PostgresBucketBatch extends BaseObserver {
948
952
  container.reporter.captureMessage(`Data too big on ${record.sourceTable.qualifiedName}.${record.after?.id}: ${e.message}`, {
949
953
  level: errors.ErrorSeverity.WARNING,
950
954
  metadata: {
951
- replication_slot: this.options.slot_name,
955
+ replication_slot: this.options.replicationStreamName,
952
956
  table: record.sourceTable.qualifiedName
953
957
  }
954
958
  });
@@ -985,7 +989,7 @@ export class PostgresBucketBatch extends BaseObserver {
985
989
  // However, it will be valid by the end of the transaction.
986
990
  //
987
991
  // In this case, we don't save the op, but we do save the current data.
988
- if (afterId && after && utils.isCompleteRow(this.options.store_current_data, after)) {
992
+ if (afterId && after && utils.isCompleteRow(storeCurrentData, after)) {
989
993
  // Insert or update
990
994
  if (sourceTable.syncData) {
991
995
  const { results: evaluated, errors: syncErrors } = this.sync_rules.evaluateRowWithErrors({
@@ -997,7 +1001,7 @@ export class PostgresBucketBatch extends BaseObserver {
997
1001
  container.reporter.captureMessage(`Failed to evaluate data query on ${record.sourceTable.qualifiedName}.${record.after?.id}: ${error.error}`, {
998
1002
  level: errors.ErrorSeverity.WARNING,
999
1003
  metadata: {
1000
- replication_slot: this.options.slot_name,
1004
+ replication_slot: this.options.replicationStreamName,
1001
1005
  table: record.sourceTable.qualifiedName
1002
1006
  }
1003
1007
  });
@@ -1025,7 +1029,7 @@ export class PostgresBucketBatch extends BaseObserver {
1025
1029
  container.reporter.captureMessage(`Failed to evaluate parameter query on ${record.sourceTable.qualifiedName}.${record.after?.id}: ${error.error}`, {
1026
1030
  level: errors.ErrorSeverity.WARNING,
1027
1031
  metadata: {
1028
- replication_slot: this.options.slot_name,
1032
+ replication_slot: this.options.replicationStreamName,
1029
1033
  table: record.sourceTable.qualifiedName
1030
1034
  }
1031
1035
  });
@@ -1072,13 +1076,12 @@ export class PostgresBucketBatch extends BaseObserver {
1072
1076
  *
1073
1077
  * Called on new commits.
1074
1078
  */
1075
- async autoActivate(lsn) {
1079
+ async autoActivate(lsn, checkpoint) {
1076
1080
  if (!this.needsActivation) {
1077
1081
  // Already activated
1078
1082
  return;
1079
1083
  }
1080
- let didActivate = false;
1081
- await this.db.transaction(async (db) => {
1084
+ const activationResult = await this.db.transaction(async (db) => {
1082
1085
  const syncRulesRow = await db.sql `
1083
1086
  SELECT
1084
1087
  state,
@@ -1110,19 +1113,23 @@ export class PostgresBucketBatch extends BaseObserver {
1110
1113
  )
1111
1114
  AND id != ${{ type: 'int4', value: this.group_id }}
1112
1115
  `.execute();
1113
- didActivate = true;
1114
- this.needsActivation = false;
1116
+ await notifySyncRulesUpdate(db, checkpoint);
1117
+ return 'activated';
1115
1118
  }
1116
1119
  else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
1117
- this.needsActivation = false;
1120
+ return 'not-processing';
1118
1121
  }
1122
+ return 'pending';
1119
1123
  });
1120
- if (didActivate) {
1124
+ if (activationResult != 'pending') {
1125
+ this.needsActivation = false;
1126
+ }
1127
+ if (activationResult == 'activated') {
1121
1128
  this.logger.info(`Activated new replication stream at ${lsn}`);
1122
1129
  }
1123
1130
  }
1124
1131
  /**
1125
- * Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
1132
+ * Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
1126
1133
  * TODO maybe share this with an abstract class
1127
1134
  */
1128
1135
  getTableEvents(table) {
@@ -1165,10 +1172,6 @@ export class PostgresBucketBatch extends BaseObserver {
1165
1172
  }
1166
1173
  }
1167
1174
  async clearError(db = this.db) {
1168
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
1169
- if (this.clearedError) {
1170
- return;
1171
- }
1172
1175
  await db.sql `
1173
1176
  UPDATE sync_rules
1174
1177
  SET
@@ -1176,7 +1179,6 @@ export class PostgresBucketBatch extends BaseObserver {
1176
1179
  WHERE
1177
1180
  id = ${{ type: 'int4', value: this.group_id }}
1178
1181
  `.execute();
1179
- this.clearedError = true;
1180
1182
  }
1181
1183
  async getLastOpIdSequence(db) {
1182
1184
  // When no op_id has been generated, last_value = 1 and nextval() will be 1.
@@ -1200,11 +1202,13 @@ export class PostgresBucketBatch extends BaseObserver {
1200
1202
  * active checkpoint has been updated.
1201
1203
  */
1202
1204
  export const notifySyncRulesUpdate = async (db, update) => {
1203
- if (update.state != storage.SyncRuleState.ACTIVE) {
1204
- return;
1205
- }
1206
- await db.query({
1207
- statement: `NOTIFY ${NOTIFICATION_CHANNEL}, '${models.ActiveCheckpointNotification.encode({ active_checkpoint: update })}'`
1208
- });
1205
+ const payload = models.ActiveCheckpointNotification.encode({ active_checkpoint: update });
1206
+ await db.sql `
1207
+ SELECT
1208
+ pg_notify (
1209
+ ${{ type: 'varchar', value: NOTIFICATION_CHANNEL }},
1210
+ ${{ type: 'varchar', value: payload }}
1211
+ )
1212
+ `.execute();
1209
1213
  };
1210
1214
  //# sourceMappingURL=PostgresBucketBatch.js.map