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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,6 @@ import {
22
22
  import { JSONBig } from '@powersync/service-jsonbig';
23
23
  import * as sync_rules from '@powersync/service-sync-rules';
24
24
  import * as timers from 'timers/promises';
25
- import * as uuid from 'uuid';
26
25
  import { bigint, BIGINT_MAX } from '../types/codecs.js';
27
26
  import { models, RequiredOperationBatchLimits } from '../types/types.js';
28
27
  import { replicaIdToSubkey } from '../utils/bson.js';
@@ -32,7 +31,6 @@ import * as framework from '@powersync/lib-services-framework';
32
31
  import { StatementParam } from '@powersync/service-jpgwire';
33
32
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
34
33
  import * as t from 'ts-codec';
35
- import { SourceTableDecoded, StoredRelationId } from '../types/models/SourceTable.js';
36
34
  import { pick } from '../utils/ts-codec.js';
37
35
  import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
38
36
  import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
@@ -69,7 +67,7 @@ export class PostgresSyncRulesStorage
69
67
 
70
68
  // TODO we might be able to share this in an abstract class
71
69
  private parsedSyncRulesCache:
72
- | { parsed: sync_rules.HydratedSyncRules; options: storage.ParseSyncRulesOptions }
70
+ | { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
73
71
  | undefined;
74
72
  private _checksumCache: storage.ChecksumCache | undefined;
75
73
 
@@ -109,14 +107,14 @@ export class PostgresSyncRulesStorage
109
107
  }
110
108
 
111
109
  // TODO we might be able to share this in an abstract class
112
- getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncRules {
110
+ getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
113
111
  const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
114
112
  /**
115
113
  * Check if the cached sync config, if present, had the same options.
116
114
  * Parse sync config if the options are different or if there is no cached value.
117
115
  */
118
116
  if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
119
- this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncRules(), options };
117
+ this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
120
118
  }
121
119
 
122
120
  return this.parsedSyncRulesCache!.parsed;
@@ -188,168 +186,6 @@ export class PostgresSyncRulesStorage
188
186
  );
189
187
  }
190
188
 
191
- async resolveTable(options: storage.ResolveTableOptions): Promise<storage.ResolveTableResult> {
192
- const { group_id, connection_id, connection_tag, entity_descriptor } = options;
193
-
194
- const { schema, name: table, objectId, replicaIdColumns } = entity_descriptor;
195
-
196
- const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
197
- name: column.name,
198
- type: column.type,
199
- // The PGWire returns this as a BigInt. We want to store this as JSONB
200
- type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
201
- }));
202
- return this.db.transaction(async (db) => {
203
- let sourceTableRow: SourceTableDecoded | null;
204
- if (objectId != null) {
205
- sourceTableRow = await db.sql`
206
- SELECT
207
- *
208
- FROM
209
- source_tables
210
- WHERE
211
- group_id = ${{ type: 'int4', value: group_id }}
212
- AND connection_id = ${{ type: 'int4', value: connection_id }}
213
- AND relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
214
- AND schema_name = ${{ type: 'varchar', value: schema }}
215
- AND table_name = ${{ type: 'varchar', value: table }}
216
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
217
- `
218
- .decoded(models.SourceTable)
219
- .first();
220
- } else {
221
- sourceTableRow = await db.sql`
222
- SELECT
223
- *
224
- FROM
225
- source_tables
226
- WHERE
227
- group_id = ${{ type: 'int4', value: group_id }}
228
- AND connection_id = ${{ type: 'int4', value: connection_id }}
229
- AND schema_name = ${{ type: 'varchar', value: schema }}
230
- AND table_name = ${{ type: 'varchar', value: table }}
231
- AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
232
- `
233
- .decoded(models.SourceTable)
234
- .first();
235
- }
236
-
237
- if (sourceTableRow == null) {
238
- const row = await db.sql`
239
- INSERT INTO
240
- source_tables (
241
- id,
242
- group_id,
243
- connection_id,
244
- relation_id,
245
- schema_name,
246
- table_name,
247
- replica_id_columns
248
- )
249
- VALUES
250
- (
251
- ${{ type: 'varchar', value: uuid.v4() }},
252
- ${{ type: 'int4', value: group_id }},
253
- ${{ type: 'int4', value: connection_id }},
254
- --- The objectId can be string | number | undefined, we store it as jsonb value
255
- ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
256
- ${{ type: 'varchar', value: schema }},
257
- ${{ type: 'varchar', value: table }},
258
- ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
259
- )
260
- RETURNING
261
- *
262
- `
263
- .decoded(models.SourceTable)
264
- .first();
265
- sourceTableRow = row;
266
- }
267
-
268
- const sourceTable = new storage.SourceTable({
269
- id: sourceTableRow!.id,
270
- connectionTag: connection_tag,
271
- objectId: objectId,
272
- schema: schema,
273
- name: table,
274
- replicaIdColumns: replicaIdColumns,
275
- snapshotComplete: sourceTableRow!.snapshot_done ?? true
276
- });
277
- if (!sourceTable.snapshotComplete) {
278
- sourceTable.snapshotStatus = {
279
- totalEstimatedCount: Number(sourceTableRow!.snapshot_total_estimated_count ?? -1n),
280
- replicatedCount: Number(sourceTableRow!.snapshot_replicated_count ?? 0n),
281
- lastKey: sourceTableRow!.snapshot_last_key
282
- };
283
- }
284
- sourceTable.syncEvent = options.sync_rules.tableTriggersEvent(sourceTable);
285
- sourceTable.syncData = options.sync_rules.tableSyncsData(sourceTable);
286
- sourceTable.syncParameters = options.sync_rules.tableSyncsParameters(sourceTable);
287
-
288
- let truncatedTables: SourceTableDecoded[] = [];
289
- if (objectId != null) {
290
- // relation_id present - check for renamed tables
291
- truncatedTables = await db.sql`
292
- SELECT
293
- *
294
- FROM
295
- source_tables
296
- WHERE
297
- group_id = ${{ type: 'int4', value: group_id }}
298
- AND connection_id = ${{ type: 'int4', value: connection_id }}
299
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
300
- AND (
301
- relation_id = ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }}
302
- OR (
303
- schema_name = ${{ type: 'varchar', value: schema }}
304
- AND table_name = ${{ type: 'varchar', value: table }}
305
- )
306
- )
307
- `
308
- .decoded(models.SourceTable)
309
- .rows();
310
- } else {
311
- // relation_id not present - only check for changed replica_id_columns
312
- truncatedTables = await db.sql`
313
- SELECT
314
- *
315
- FROM
316
- source_tables
317
- WHERE
318
- group_id = ${{ type: 'int4', value: group_id }}
319
- AND connection_id = ${{ type: 'int4', value: connection_id }}
320
- AND id != ${{ type: 'varchar', value: sourceTableRow!.id }}
321
- AND (
322
- schema_name = ${{ type: 'varchar', value: schema }}
323
- AND table_name = ${{ type: 'varchar', value: table }}
324
- )
325
- `
326
- .decoded(models.SourceTable)
327
- .rows();
328
- }
329
-
330
- return {
331
- table: sourceTable,
332
- dropTables: truncatedTables.map(
333
- (doc) =>
334
- new storage.SourceTable({
335
- id: doc.id,
336
- connectionTag: connection_tag,
337
- objectId: doc.relation_id?.object_id ?? 0,
338
- schema: doc.schema_name,
339
- name: doc.table_name,
340
- replicaIdColumns:
341
- doc.replica_id_columns?.map((c) => ({
342
- name: c.name,
343
- typeOid: c.typeId,
344
- type: c.type
345
- })) ?? [],
346
- snapshotComplete: doc.snapshot_done ?? true
347
- })
348
- )
349
- };
350
- });
351
- }
352
-
353
189
  async createWriter(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {
354
190
  const syncRules = await this.db.sql`
355
191
  SELECT
@@ -370,7 +206,7 @@ export class PostgresSyncRulesStorage
370
206
  const writer = new PostgresBucketBatch({
371
207
  logger: options.logger ?? this.logger,
372
208
  db: this.db,
373
- sync_rules: this.sync_rules.parsed(options).hydratedSyncRules(),
209
+ sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
374
210
  group_id: this.group_id,
375
211
  slot_name: this.slot_name,
376
212
  last_checkpoint_lsn: checkpoint_lsn,
@@ -380,6 +216,7 @@ export class PostgresSyncRulesStorage
380
216
  skip_existing_rows: options.skipExistingRows ?? false,
381
217
  batch_limits: this.options.batchLimits,
382
218
  markRecordUnavailable: options.markRecordUnavailable,
219
+ hooks: options.hooks,
383
220
  storageConfig: this.storageConfig
384
221
  });
385
222
  this.iterateListeners((cb) => cb.batchStarted?.(writer));
@@ -670,13 +507,16 @@ export class PostgresSyncRulesStorage
670
507
  snapshot_done,
671
508
  snapshot_lsn,
672
509
  last_checkpoint_lsn,
673
- state
510
+ state,
511
+ keepalive_op
674
512
  FROM
675
513
  sync_rules
676
514
  WHERE
677
515
  id = ${{ type: 'int4', value: this.group_id }}
678
516
  `
679
- .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn']))
517
+ .decoded(
518
+ pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
519
+ )
680
520
  .first();
681
521
 
682
522
  if (syncRulesRow == null) {
@@ -687,7 +527,8 @@ export class PostgresSyncRulesStorage
687
527
  snapshot_done: syncRulesRow.snapshot_done,
688
528
  active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
689
529
  checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
690
- snapshot_lsn: syncRulesRow.snapshot_lsn ?? null
530
+ snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
531
+ keepalive_op: syncRulesRow.keepalive_op ?? null
691
532
  };
692
533
  }
693
534
 
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  BucketStorageMarkRecordUnavailable,
14
14
  CheckpointResult,
15
+ ColumnDescriptor,
15
16
  deserializeReplicaId,
16
17
  InternalOpId,
17
18
  storage,
@@ -20,8 +21,10 @@ import {
20
21
  import * as sync_rules from '@powersync/service-sync-rules';
21
22
  import * as timers from 'timers/promises';
22
23
  import * as t from 'ts-codec';
24
+ import * as uuid from 'uuid';
23
25
  import { bigint } from '../../types/codecs.js';
24
26
  import { CurrentBucket, V3CurrentDataDecoded } from '../../types/models/CurrentData.js';
27
+ import { SourceTableDecoded, StoredRelationId } from '../../types/models/SourceTable.js';
25
28
  import { models, RequiredOperationBatchLimits } from '../../types/types.js';
26
29
  import { NOTIFICATION_CHANNEL } from '../../utils/db.js';
27
30
  import { pick } from '../../utils/ts-codec.js';
@@ -34,7 +37,7 @@ import { PostgresPersistedBatch } from './PostgresPersistedBatch.js';
34
37
  export interface PostgresBucketBatchOptions {
35
38
  logger: Logger;
36
39
  db: lib_postgres.DatabaseClient;
37
- sync_rules: sync_rules.HydratedSyncRules;
40
+ sync_rules: sync_rules.HydratedSyncConfig;
38
41
  group_id: number;
39
42
  slot_name: string;
40
43
  last_checkpoint_lsn: string | null;
@@ -48,6 +51,7 @@ export interface PostgresBucketBatchOptions {
48
51
  batch_limits: RequiredOperationBatchLimits;
49
52
 
50
53
  markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
54
+ hooks: storage.StorageHooks | undefined;
51
55
  storageConfig: storage.StorageVersionConfig;
52
56
  }
53
57
 
@@ -85,6 +89,7 @@ export class PostgresBucketBatch
85
89
  public last_flushed_op: InternalOpId | null = null;
86
90
 
87
91
  public resumeFromLsn: string | null;
92
+ public readonly skipExistingRows: boolean;
88
93
 
89
94
  protected db: lib_postgres.DatabaseClient;
90
95
  protected group_id: number;
@@ -93,10 +98,11 @@ export class PostgresBucketBatch
93
98
  protected persisted_op: InternalOpId | null;
94
99
 
95
100
  protected write_checkpoint_batch: storage.CustomWriteCheckpointOptions[];
96
- protected readonly sync_rules: sync_rules.HydratedSyncRules;
101
+ protected readonly sync_rules: sync_rules.HydratedSyncConfig;
97
102
  protected batch: OperationBatch | null;
98
103
  private lastWaitingLogThrottled = 0;
99
104
  private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
105
+ private hooks: storage.StorageHooks | undefined;
100
106
  private needsActivation = true;
101
107
  private clearedError = false;
102
108
  private readonly storageConfig: storage.StorageVersionConfig;
@@ -109,9 +115,11 @@ export class PostgresBucketBatch
109
115
  this.group_id = options.group_id;
110
116
  this.last_checkpoint_lsn = options.last_checkpoint_lsn;
111
117
  this.resumeFromLsn = options.resumeFromLsn;
118
+ this.skipExistingRows = options.skip_existing_rows;
112
119
  this.write_checkpoint_batch = [];
113
120
  this.sync_rules = options.sync_rules;
114
121
  this.markRecordUnavailable = options.markRecordUnavailable;
122
+ this.hooks = options.hooks;
115
123
  this.batch = null;
116
124
  this.persisted_op = null;
117
125
  this.storageConfig = options.storageConfig;
@@ -139,6 +147,215 @@ export class PostgresBucketBatch
139
147
  await this[Symbol.asyncDispose]();
140
148
  }
141
149
 
150
+ async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
151
+ const syncRules = options.syncRules ?? this.sync_rules;
152
+ const { connection_id, source } = options;
153
+ const { schema, name: table, objectId, replicaIdColumns, connectionTag } = source;
154
+
155
+ const normalizedReplicaIdColumns = replicaIdColumns.map((column) => ({
156
+ name: column.name,
157
+ type: column.type,
158
+ type_oid: typeof column.typeId !== 'undefined' ? Number(column.typeId) : column.typeId
159
+ }));
160
+ return this.db.transaction(async (db) => {
161
+ let sourceTableRow: SourceTableDecoded | null;
162
+ if (objectId != null) {
163
+ sourceTableRow = await db.sql`
164
+ SELECT
165
+ *
166
+ FROM
167
+ source_tables
168
+ WHERE
169
+ group_id = ${{ type: 'int4', value: this.group_id }}
170
+ 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 }}
175
+ `
176
+ .decoded(models.SourceTable)
177
+ .first();
178
+ } else {
179
+ sourceTableRow = await db.sql`
180
+ SELECT
181
+ *
182
+ FROM
183
+ source_tables
184
+ WHERE
185
+ group_id = ${{ type: 'int4', value: this.group_id }}
186
+ AND connection_id = ${{ type: 'int4', value: connection_id }}
187
+ AND schema_name = ${{ type: 'varchar', value: schema }}
188
+ AND table_name = ${{ type: 'varchar', value: table }}
189
+ AND replica_id_columns = ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
190
+ `
191
+ .decoded(models.SourceTable)
192
+ .first();
193
+ }
194
+
195
+ if (sourceTableRow == null) {
196
+ const id = options.idGenerator ? postgresTableId(options.idGenerator()) : uuid.v4();
197
+ sourceTableRow = await db.sql`
198
+ INSERT INTO
199
+ source_tables (
200
+ id,
201
+ group_id,
202
+ connection_id,
203
+ relation_id,
204
+ schema_name,
205
+ table_name,
206
+ replica_id_columns
207
+ )
208
+ VALUES
209
+ (
210
+ ${{ type: 'varchar', value: id }},
211
+ ${{ type: 'int4', value: this.group_id }},
212
+ ${{ type: 'int4', value: connection_id }},
213
+ ${{ type: 'jsonb', value: { object_id: objectId } satisfies StoredRelationId }},
214
+ ${{ type: 'varchar', value: schema }},
215
+ ${{ type: 'varchar', value: table }},
216
+ ${{ type: 'jsonb', value: normalizedReplicaIdColumns }}
217
+ )
218
+ RETURNING
219
+ *
220
+ `
221
+ .decoded(models.SourceTable)
222
+ .first();
223
+ }
224
+
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
+ }
283
+
284
+ return {
285
+ 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
+ })
304
+ };
305
+ });
306
+ }
307
+
308
+ async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
309
+ const row = await this.db.sql`
310
+ SELECT
311
+ *
312
+ FROM
313
+ source_tables
314
+ WHERE
315
+ group_id = ${{ type: 'int4', value: this.group_id }}
316
+ AND id = ${{ type: 'varchar', value: table.id.toString() }}
317
+ `
318
+ .decoded(models.SourceTable)
319
+ .first();
320
+
321
+ if (row == null) {
322
+ return null;
323
+ }
324
+
325
+ return this.sourceTableFromRow(row, table.ref.connectionTag, this.sync_rules);
326
+ }
327
+
328
+ private sourceTableFromRow(
329
+ row: SourceTableDecoded,
330
+ connectionTag: string,
331
+ syncRules: sync_rules.HydratedSyncConfig
332
+ ): storage.SourceTable {
333
+ const ref = { connectionTag, schema: row.schema_name, name: row.table_name };
334
+ const sourceTable = new storage.SourceTable({
335
+ id: row.id,
336
+ ref,
337
+ objectId: row.relation_id?.object_id,
338
+ replicaIdColumns:
339
+ row.replica_id_columns?.map(
340
+ (c) => ({ name: c.name, typeId: c.typeId, type: c.type }) satisfies ColumnDescriptor
341
+ ) ?? [],
342
+ snapshotComplete: row.snapshot_done ?? true,
343
+ ...syncRules.getMatchingSources(ref)
344
+ });
345
+
346
+ if (!sourceTable.snapshotComplete) {
347
+ sourceTable.snapshotStatus = {
348
+ totalEstimatedCount: Number(row.snapshot_total_estimated_count ?? -1n),
349
+ replicatedCount: Number(row.snapshot_replicated_count ?? 0n),
350
+ lastKey: row.snapshot_last_key
351
+ };
352
+ }
353
+ sourceTable.syncEvent = syncRules.tableTriggersEvent(ref);
354
+ sourceTable.syncData = sourceTable.bucketDataSources.length > 0;
355
+ sourceTable.syncParameters = sourceTable.parameterLookupSources.length > 0;
356
+ return sourceTable;
357
+ }
358
+
142
359
  async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
143
360
  // TODO maybe share with abstract class
144
361
  const { after, before, sourceTable, tag } = record;
@@ -296,6 +513,8 @@ export class PostgresBucketBatch
296
513
  return null;
297
514
  }
298
515
 
516
+ await this.hooks?.beforeBatchFlush?.(this);
517
+
299
518
  let resumeBatch: OperationBatch | null = null;
300
519
 
301
520
  const lastOp = await this.withReplicationTransaction(async (db) => {
@@ -313,6 +532,7 @@ export class PostgresBucketBatch
313
532
 
314
533
  this.persisted_op = lastOp;
315
534
  this.last_flushed_op = lastOp;
535
+ await this.hooks?.afterBatchFlush?.(this);
316
536
  return { flushed_op: lastOp };
317
537
  }
318
538
 
@@ -536,6 +756,50 @@ export class PostgresBucketBatch
536
756
  });
537
757
  }
538
758
 
759
+ async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
760
+ await this.db.transaction(async (db) => {
761
+ const snapshotRequiredCount = await db.sql`
762
+ SELECT
763
+ COUNT(*) AS count
764
+ FROM
765
+ source_tables
766
+ WHERE
767
+ group_id = ${{ type: 'int4', value: this.group_id }}
768
+ AND snapshot_done = FALSE
769
+ `
770
+ .decoded(t.object({ count: bigint }))
771
+ .first();
772
+ if ((snapshotRequiredCount?.count ?? 0n) > 0n) {
773
+ if (options?.throwOnConflict ?? true) {
774
+ throw new ReplicationAssertionError(
775
+ `Cannot mark snapshot done while ${snapshotRequiredCount?.count} source table${
776
+ snapshotRequiredCount?.count == 1n ? '' : 's'
777
+ } still require snapshotting`
778
+ );
779
+ } else {
780
+ return;
781
+ }
782
+ }
783
+
784
+ await db.sql`
785
+ UPDATE sync_rules
786
+ SET
787
+ snapshot_done = TRUE,
788
+ last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
789
+ no_checkpoint_before = CASE
790
+ WHEN no_checkpoint_before IS NULL
791
+ OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
792
+ type: 'varchar',
793
+ value: no_checkpoint_before_lsn
794
+ }}
795
+ ELSE no_checkpoint_before
796
+ END
797
+ WHERE
798
+ id = ${{ type: 'int4', value: this.group_id }}
799
+ `.execute();
800
+ });
801
+ }
802
+
539
803
  async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
540
804
  await this.db.sql`
541
805
  UPDATE sync_rules
@@ -900,7 +1164,8 @@ export class PostgresBucketBatch
900
1164
  if (sourceTable.syncData) {
901
1165
  const { results: evaluated, errors: syncErrors } = this.sync_rules.evaluateRowWithErrors({
902
1166
  record: after,
903
- sourceTable
1167
+ sourceTable: sourceTable.ref,
1168
+ bucketDataSources: sourceTable.bucketDataSources
904
1169
  });
905
1170
 
906
1171
  for (const error of syncErrors) {
@@ -939,8 +1204,9 @@ export class PostgresBucketBatch
939
1204
  if (sourceTable.syncParameters) {
940
1205
  // Parameters
941
1206
  const { results: paramEvaluated, errors: paramErrors } = this.sync_rules.evaluateParameterRowWithErrors(
942
- sourceTable,
943
- after
1207
+ sourceTable.ref,
1208
+ after,
1209
+ { parameterLookupSources: sourceTable.parameterLookupSources }
944
1210
  );
945
1211
 
946
1212
  for (let error of paramErrors) {
@@ -1064,7 +1330,7 @@ export class PostgresBucketBatch
1064
1330
  */
1065
1331
  protected getTableEvents(table: storage.SourceTable): sync_rules.SqlEventDescriptor[] {
1066
1332
  return this.sync_rules.eventDescriptors.filter((evt) =>
1067
- [...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table))
1333
+ [...evt.getSourceTables()].some((sourceTable) => sourceTable.matches(table.ref))
1068
1334
  );
1069
1335
  }
1070
1336