@powersync/service-module-postgres 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
+ import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres';
1
2
  import {
2
3
  api,
3
- auth,
4
4
  ConfigurationFileSyncRulesProvider,
5
5
  ConnectionTestResult,
6
6
  modules,
@@ -8,8 +8,8 @@ import {
8
8
  system
9
9
  } from '@powersync/service-core';
10
10
  import * as jpgwire from '@powersync/service-jpgwire';
11
+ import { ReplicationMetric } from '@powersync/service-types';
11
12
  import { PostgresRouteAPIAdapter } from '../api/PostgresRouteAPIAdapter.js';
12
- import { SupabaseKeyCollector } from '../auth/SupabaseKeyCollector.js';
13
13
  import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js';
14
14
  import { PgManager } from '../replication/PgManager.js';
15
15
  import { PostgresErrorRateLimiter } from '../replication/PostgresErrorRateLimiter.js';
@@ -18,8 +18,6 @@ import { PUBLICATION_NAME } from '../replication/WalStream.js';
18
18
  import { WalStreamReplicator } from '../replication/WalStreamReplicator.js';
19
19
  import * as types from '../types/types.js';
20
20
  import { PostgresConnectionConfig } from '../types/types.js';
21
- import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres';
22
- import { ReplicationMetric } from '@powersync/service-types';
23
21
  import { getApplicationName } from '../utils/application-name.js';
24
22
 
25
23
  export class PostgresModule extends replication.ReplicationModule<types.PostgresConnectionConfig> {
@@ -32,19 +30,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
32
30
  }
33
31
 
34
32
  async onInitialized(context: system.ServiceContextContainer): Promise<void> {
35
- const client_auth = context.configuration.base_config.client_auth;
36
-
37
- if (client_auth?.supabase && client_auth?.supabase_jwt_secret == null) {
38
- // Only use the deprecated SupabaseKeyCollector when there is no
39
- // secret hardcoded. Hardcoded secrets are handled elsewhere, using
40
- // StaticSupabaseKeyCollector.
41
-
42
- // Support for SupabaseKeyCollector is deprecated and support will be
43
- // completely removed by Supabase soon. We can keep support a while
44
- // longer for self-hosted setups, before also removing that on our side.
45
- this.registerSupabaseAuth(context);
46
- }
47
-
48
33
  // Record replicated bytes using global jpgwire metrics. Only registered if this module is replicating
49
34
  if (context.replicationEngine) {
50
35
  jpgwire.setMetricsRecorder({
@@ -110,32 +95,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
110
95
  }
111
96
  }
112
97
 
113
- // TODO: This should rather be done by registering the key collector in some kind of auth engine
114
- private registerSupabaseAuth(context: system.ServiceContextContainer) {
115
- const { configuration } = context;
116
- // Register the Supabase key collector(s)
117
- configuration.connections
118
- ?.map((baseConfig) => {
119
- if (baseConfig.type != types.POSTGRES_CONNECTION_TYPE) {
120
- return;
121
- }
122
- try {
123
- return this.resolveConfig(types.PostgresConnectionConfig.decode(baseConfig as any));
124
- } catch (ex) {
125
- this.logger.warn('Failed to decode configuration.', ex);
126
- }
127
- })
128
- .filter((c) => !!c)
129
- .forEach((config) => {
130
- const keyCollector = new SupabaseKeyCollector(config!);
131
- context.lifeCycleEngine.withLifecycle(keyCollector, {
132
- // Close the internal pool
133
- stop: (collector) => collector.shutdown()
134
- });
135
- configuration.client_keystore.collector.add(new auth.CachedKeyCollector(keyCollector));
136
- });
137
- }
138
-
139
98
  async testConnection(config: PostgresConnectionConfig): Promise<ConnectionTestResult> {
140
99
  this.decodeConfig(config);
141
100
  const normalizedConfig = this.resolveConfig(this.decodedConfig!);
@@ -1,4 +1,4 @@
1
- import { ReplicationAssertionError, ServiceError } from '@powersync/lib-services-framework';
1
+ import { ReplicationAssertionError } from '@powersync/lib-services-framework';
2
2
  import { storage } from '@powersync/service-core';
3
3
  import { PgoutputRelation } from '@powersync/service-jpgwire';
4
4
 
@@ -27,6 +27,6 @@ export function getPgOutputRelation(source: PgoutputRelation): storage.SourceEnt
27
27
  name: source.name,
28
28
  schema: source.schema,
29
29
  objectId: getRelId(source),
30
- replicationColumns: getReplicaIdColumns(source)
30
+ replicaIdColumns: getReplicaIdColumns(source)
31
31
  } satisfies storage.SourceEntityDescriptor;
32
32
  }
@@ -36,7 +36,7 @@ export class SimpleSnapshotQuery implements SnapshotQuery {
36
36
  ) {}
37
37
 
38
38
  public async initialize(): Promise<void> {
39
- await this.connection.query(`DECLARE snapshot_cursor CURSOR FOR SELECT * FROM ${this.table.escapedIdentifier}`);
39
+ await this.connection.query(`DECLARE snapshot_cursor CURSOR FOR SELECT * FROM ${this.table.qualifiedName}`);
40
40
  }
41
41
 
42
42
  public nextChunk(): AsyncIterableIterator<PgChunk> {
@@ -121,7 +121,7 @@ export class ChunkedSnapshotQuery implements SnapshotQuery {
121
121
  const escapedKeyName = escapeIdentifier(this.key.name);
122
122
  if (this.lastKey == null) {
123
123
  stream = this.connection.stream(
124
- `SELECT * FROM ${this.table.escapedIdentifier} ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`
124
+ `SELECT * FROM ${this.table.qualifiedName} ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`
125
125
  );
126
126
  } else {
127
127
  if (this.key.typeId == null) {
@@ -129,7 +129,7 @@ export class ChunkedSnapshotQuery implements SnapshotQuery {
129
129
  }
130
130
  let type: StatementParam['type'] = Number(this.key.typeId);
131
131
  stream = this.connection.stream({
132
- statement: `SELECT * FROM ${this.table.escapedIdentifier} WHERE ${escapedKeyName} > $1 ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`,
132
+ statement: `SELECT * FROM ${this.table.qualifiedName} WHERE ${escapedKeyName} > $1 ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`,
133
133
  params: [{ value: this.lastKey, type }]
134
134
  });
135
135
  }
@@ -197,7 +197,7 @@ export class IdSnapshotQuery implements SnapshotQuery {
197
197
  throw new Error(`Cannot determine primary key array type for ${JSON.stringify(keyDefinition)}`);
198
198
  }
199
199
  yield* this.connection.stream({
200
- statement: `SELECT * FROM ${this.table.escapedIdentifier} WHERE ${escapeIdentifier(keyDefinition.name)} = ANY($1)`,
200
+ statement: `SELECT * FROM ${this.table.qualifiedName} WHERE ${escapeIdentifier(keyDefinition.name)} = ANY($1)`,
201
201
  params: [
202
202
  {
203
203
  type: type,
@@ -256,7 +256,7 @@ export class WalStream {
256
256
  name,
257
257
  schema,
258
258
  objectId: relid,
259
- replicationColumns: cresult.replicationColumns
259
+ replicaIdColumns: cresult.replicationColumns
260
260
  } as SourceEntityDescriptor,
261
261
  false
262
262
  );
@@ -321,7 +321,7 @@ export class WalStream {
321
321
 
322
322
  // Check that replication slot exists
323
323
  for (let i = 120; i >= 0; i--) {
324
- await touch();
324
+ this.touch();
325
325
 
326
326
  if (i == 0) {
327
327
  container.reporter.captureException(last_error, {
@@ -479,7 +479,7 @@ WHERE oid = $1::regclass`,
479
479
 
480
480
  for (let table of tablesWithStatus) {
481
481
  await this.snapshotTableInTx(batch, db, table);
482
- await touch();
482
+ this.touch();
483
483
  }
484
484
 
485
485
  // Always commit the initial snapshot at zero.
@@ -628,7 +628,7 @@ WHERE oid = $1::regclass`,
628
628
  at += rows.length;
629
629
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(rows.length);
630
630
 
631
- await touch();
631
+ this.touch();
632
632
  }
633
633
 
634
634
  // Important: flush before marking progress
@@ -737,6 +737,9 @@ WHERE oid = $1::regclass`,
737
737
  rows.map((r) => r.key)
738
738
  );
739
739
  }
740
+ // Even with resnapshot, we need to wait until we get a new consistent checkpoint
741
+ // after the snapshot, so we need to send a keepalive message.
742
+ await sendKeepAlive(db);
740
743
  } finally {
741
744
  await db.end();
742
745
  }
@@ -837,7 +840,6 @@ WHERE oid = $1::regclass`,
837
840
 
838
841
  async streamChanges(replicationConnection: pgwire.PgConnection) {
839
842
  // When changing any logic here, check /docs/wal-lsns.md.
840
-
841
843
  const { createEmptyCheckpoints } = await this.ensureStorageCompatibility();
842
844
 
843
845
  const replicationOptions: Record<string, string> = {
@@ -867,9 +869,6 @@ WHERE oid = $1::regclass`,
867
869
 
868
870
  this.startedStreaming = true;
869
871
 
870
- // Auto-activate as soon as initial replication is done
871
- await this.storage.autoActivate();
872
-
873
872
  let resnapshot: { table: storage.SourceTable; key: PrimaryKeyValue }[] = [];
874
873
 
875
874
  const markRecordUnavailable = (record: SaveUpdate) => {
@@ -911,7 +910,7 @@ WHERE oid = $1::regclass`,
911
910
  let count = 0;
912
911
 
913
912
  for await (const chunk of replicationStream.pgoutputDecode()) {
914
- await touch();
913
+ this.touch();
915
914
 
916
915
  if (this.abort_signal.aborted) {
917
916
  break;
@@ -1091,11 +1090,10 @@ WHERE oid = $1::regclass`,
1091
1090
  }
1092
1091
  return Date.now() - this.oldestUncommittedChange.getTime();
1093
1092
  }
1094
- }
1095
1093
 
1096
- async function touch() {
1097
- // FIXME: The hosted Kubernetes probe does not actually check the timestamp on this.
1098
- // FIXME: We need a timeout of around 5+ minutes in Kubernetes if we do start checking the timestamp,
1099
- // or reduce PING_INTERVAL here.
1100
- return container.probes.touch();
1094
+ private touch() {
1095
+ container.probes.touch().catch((e) => {
1096
+ this.logger.error(`Error touching probe`, e);
1097
+ });
1098
+ }
1101
1099
  }
@@ -315,7 +315,15 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
315
315
 
316
316
  const id_columns = id_columns_result?.replicationColumns ?? [];
317
317
 
318
- const sourceTable = new storage.SourceTable(0, connectionTag, relationId ?? 0, schema, name, id_columns, true);
318
+ const sourceTable = new storage.SourceTable({
319
+ id: 0,
320
+ connectionTag: connectionTag,
321
+ objectId: relationId ?? 0,
322
+ schema: schema,
323
+ name: name,
324
+ replicaIdColumns: id_columns,
325
+ snapshotComplete: true
326
+ });
319
327
 
320
328
  const syncData = syncRules.tableSyncsData(sourceTable);
321
329
  const syncParameters = syncRules.tableSyncsParameters(sourceTable);
@@ -342,7 +350,7 @@ export async function getDebugTableInfo(options: GetDebugTableInfoOptions): Prom
342
350
 
343
351
  let selectError = null;
344
352
  try {
345
- await lib_postgres.retriedQuery(db, `SELECT * FROM ${sourceTable.escapedIdentifier} LIMIT 1`);
353
+ await lib_postgres.retriedQuery(db, `SELECT * FROM ${sourceTable.qualifiedName} LIMIT 1`);
346
354
  } catch (e) {
347
355
  selectError = { level: 'fatal', message: e.message };
348
356
  }
@@ -36,6 +36,8 @@ const checkpointTests = (factory: TestStorageFactory) => {
36
36
  await context.replicateSnapshot();
37
37
 
38
38
  context.startStreaming();
39
+ // Wait for a consistent checkpoint before we start.
40
+ await context.getCheckpoint();
39
41
  const storage = context.storage!;
40
42
 
41
43
  const controller = new AbortController();
@@ -87,7 +87,6 @@ function defineBatchTests(factory: storage.TestStorageFactory) {
87
87
  const start = Date.now();
88
88
 
89
89
  await context.replicateSnapshot();
90
- await context.storage!.autoActivate();
91
90
  context.startStreaming();
92
91
 
93
92
  const checkpoint = await context.getCheckpoint({ timeout: 100_000 });
@@ -97,7 +97,6 @@ bucket_definitions:
97
97
  await pool.query(`ALTER TABLE test_data REPLICA IDENTITY FULL`);
98
98
 
99
99
  await walStream.initReplication(replicationConnection);
100
- await storage.autoActivate();
101
100
  let abort = false;
102
101
  streamPromise = walStream.streamChanges(replicationConnection).finally(() => {
103
102
  abort = true;
@@ -348,7 +347,6 @@ bucket_definitions:
348
347
  let initialReplicationDone = false;
349
348
  streamPromise = (async () => {
350
349
  await walStream.initReplication(replicationConnection);
351
- await storage.autoActivate();
352
350
  initialReplicationDone = true;
353
351
  await walStream.streamChanges(replicationConnection);
354
352
  })()
package/test/src/util.ts CHANGED
@@ -90,10 +90,8 @@ export async function getClientCheckpoint(
90
90
  while (Date.now() - start < timeout) {
91
91
  const storage = await storageFactory.getActiveStorage();
92
92
  const cp = await storage?.getCheckpoint();
93
- if (cp == null) {
94
- throw new Error('No sync rules available');
95
- }
96
- if (cp.lsn && cp.lsn >= lsn) {
93
+
94
+ if (cp?.lsn != null && cp.lsn >= lsn) {
97
95
  logger.info(`Got write checkpoint: ${lsn} : ${cp.checkpoint}`);
98
96
  return cp.checkpoint;
99
97
  }
@@ -34,13 +34,11 @@ bucket_definitions:
34
34
  `CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text, num int8)`
35
35
  );
36
36
 
37
- await context.replicateSnapshot();
37
+ await context.initializeReplication();
38
38
 
39
39
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
40
40
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
41
41
 
42
- context.startStreaming();
43
-
44
42
  const [{ test_id }] = pgwireRows(
45
43
  await pool.query(
46
44
  `INSERT INTO test_data(description, num) VALUES('test1', 1152921504606846976) returning id as test_id`
@@ -53,7 +51,8 @@ bucket_definitions:
53
51
  const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
54
52
  const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
55
53
  expect(endRowCount - startRowCount).toEqual(1);
56
- expect(endTxCount - startTxCount).toEqual(1);
54
+ // In some rare cases there may be additional empty transactions, so we allow for that.
55
+ expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
57
56
  });
58
57
 
59
58
  test('replicating case sensitive table', async () => {
@@ -69,13 +68,11 @@ bucket_definitions:
69
68
  await pool.query(`DROP TABLE IF EXISTS "test_DATA"`);
70
69
  await pool.query(`CREATE TABLE "test_DATA"(id uuid primary key default uuid_generate_v4(), description text)`);
71
70
 
72
- await context.replicateSnapshot();
71
+ await context.initializeReplication();
73
72
 
74
73
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
75
74
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
76
75
 
77
- context.startStreaming();
78
-
79
76
  const [{ test_id }] = pgwireRows(
80
77
  await pool.query(`INSERT INTO "test_DATA"(description) VALUES('test1') returning id as test_id`)
81
78
  );
@@ -86,7 +83,7 @@ bucket_definitions:
86
83
  const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
87
84
  const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
88
85
  expect(endRowCount - startRowCount).toEqual(1);
89
- expect(endTxCount - startTxCount).toEqual(1);
86
+ expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
90
87
  });
91
88
 
92
89
  test('replicating TOAST values', async () => {
@@ -143,8 +140,7 @@ bucket_definitions:
143
140
  await pool.query(`DROP TABLE IF EXISTS test_data`);
144
141
  await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);
145
142
 
146
- await context.replicateSnapshot();
147
- context.startStreaming();
143
+ await context.initializeReplication();
148
144
 
149
145
  const [{ test_id }] = pgwireRows(
150
146
  await pool.query(`INSERT INTO test_data(description) VALUES('test1') returning id as test_id`)
@@ -166,8 +162,7 @@ bucket_definitions:
166
162
  await pool.query(`DROP TABLE IF EXISTS test_data`);
167
163
  await pool.query(`CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`);
168
164
 
169
- await context.replicateSnapshot();
170
- context.startStreaming();
165
+ await context.initializeReplication();
171
166
 
172
167
  const [{ test_id }] = pgwireRows(
173
168
  await pool.query(`INSERT INTO test_data(description) VALUES('test1') returning id as test_id`)
@@ -179,8 +174,8 @@ bucket_definitions:
179
174
  )
180
175
  );
181
176
 
182
- // This update may fail replicating with:
183
- // Error: Update on missing record public.test_data:074a601e-fc78-4c33-a15d-f89fdd4af31d :: {"g":1,"t":"651e9fbe9fec6155895057ec","k":"1a0b34da-fb8c-5e6f-8421-d7a3c5d4df4f"}
177
+ // Since we don't have an old copy of the record with the new primary key, this
178
+ // may trigger a "resnapshot".
184
179
  await pool.query(`UPDATE test_data SET description = 'test2b' WHERE id = '${test_id2}'`);
185
180
 
186
181
  // Re-use old id again
@@ -264,16 +259,12 @@ bucket_definitions:
264
259
 
265
260
  await pool.query(`CREATE TABLE test_donotsync(id uuid primary key default uuid_generate_v4(), description text)`);
266
261
 
267
- await context.replicateSnapshot();
262
+ await context.initializeReplication();
268
263
 
269
264
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
270
265
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
271
266
 
272
- context.startStreaming();
273
-
274
- const [{ test_id }] = pgwireRows(
275
- await pool.query(`INSERT INTO test_donotsync(description) VALUES('test1') returning id as test_id`)
276
- );
267
+ await pool.query(`INSERT INTO test_donotsync(description) VALUES('test1') returning id as test_id`);
277
268
 
278
269
  const data = await context.getBucketData('global[]');
279
270
 
@@ -283,7 +274,7 @@ bucket_definitions:
283
274
 
284
275
  // There was a transaction, but we should not replicate any actual data
285
276
  expect(endRowCount - startRowCount).toEqual(0);
286
- expect(endTxCount - startTxCount).toEqual(1);
277
+ expect(endTxCount - startTxCount).toBeGreaterThanOrEqual(1);
287
278
  });
288
279
 
289
280
  test('reporting slot issues', async () => {
@@ -118,11 +118,20 @@ export class WalStreamTestContext implements AsyncDisposable {
118
118
  return this._walStream!;
119
119
  }
120
120
 
121
+ /**
122
+ * Replicate a snapshot, start streaming, and wait for a consistent checkpoint.
123
+ */
124
+ async initializeReplication() {
125
+ await this.replicateSnapshot();
126
+ this.startStreaming();
127
+ // Make sure we're up to date
128
+ await this.getCheckpoint();
129
+ }
130
+
121
131
  async replicateSnapshot() {
122
132
  const promise = (async () => {
123
133
  this.replicationConnection = await this.connectionManager.replicationConnection();
124
134
  await this.walStream.initReplication(this.replicationConnection);
125
- await this.storage!.autoActivate();
126
135
  })();
127
136
  this.snapshotPromise = promise.catch((e) => e);
128
137
  await promise;