@powersync/service-module-postgres-storage 0.0.0-dev-20260511080634 → 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.
@@ -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