@powersync/service-module-postgres 0.0.0-dev-20251111093449 → 0.0.0-dev-20251124070259

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 (39) hide show
  1. package/CHANGELOG.md +29 -5
  2. package/dist/api/PostgresRouteAPIAdapter.js +1 -2
  3. package/dist/api/PostgresRouteAPIAdapter.js.map +1 -1
  4. package/dist/module/PostgresModule.d.ts +0 -1
  5. package/dist/module/PostgresModule.js +4 -9
  6. package/dist/module/PostgresModule.js.map +1 -1
  7. package/dist/replication/ConnectionManagerFactory.d.ts +3 -5
  8. package/dist/replication/ConnectionManagerFactory.js +11 -9
  9. package/dist/replication/ConnectionManagerFactory.js.map +1 -1
  10. package/dist/replication/PgManager.d.ts +6 -4
  11. package/dist/replication/PgManager.js +17 -7
  12. package/dist/replication/PgManager.js.map +1 -1
  13. package/dist/replication/PostgresErrorRateLimiter.js +6 -1
  14. package/dist/replication/PostgresErrorRateLimiter.js.map +1 -1
  15. package/dist/replication/WalStream.js +5 -1
  16. package/dist/replication/WalStream.js.map +1 -1
  17. package/dist/replication/WalStreamReplicationJob.d.ts +1 -2
  18. package/dist/replication/WalStreamReplicationJob.js +47 -70
  19. package/dist/replication/WalStreamReplicationJob.js.map +1 -1
  20. package/dist/types/resolver.d.ts +9 -3
  21. package/dist/types/resolver.js +26 -5
  22. package/dist/types/resolver.js.map +1 -1
  23. package/dist/types/types.d.ts +1 -4
  24. package/dist/types/types.js.map +1 -1
  25. package/package.json +11 -11
  26. package/src/api/PostgresRouteAPIAdapter.ts +1 -1
  27. package/src/module/PostgresModule.ts +4 -9
  28. package/src/replication/ConnectionManagerFactory.ts +14 -13
  29. package/src/replication/PgManager.ts +22 -11
  30. package/src/replication/PostgresErrorRateLimiter.ts +5 -1
  31. package/src/replication/WalStream.ts +5 -1
  32. package/src/replication/WalStreamReplicationJob.ts +48 -68
  33. package/src/types/resolver.ts +27 -6
  34. package/src/types/types.ts +1 -5
  35. package/test/src/pg_test.test.ts +2 -2
  36. package/test/src/slow_tests.test.ts +2 -2
  37. package/test/src/wal_stream.test.ts +14 -3
  38. package/test/src/wal_stream_utils.ts +1 -1
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -12,19 +12,13 @@ export interface WalStreamReplicationJobOptions extends replication.AbstractRepl
12
12
 
13
13
  export class WalStreamReplicationJob extends replication.AbstractReplicationJob {
14
14
  private connectionFactory: ConnectionManagerFactory;
15
- private readonly connectionManager: PgManager;
15
+ private connectionManager: PgManager | null = null;
16
16
  private lastStream: WalStream | null = null;
17
17
 
18
18
  constructor(options: WalStreamReplicationJobOptions) {
19
19
  super(options);
20
20
  this.logger = logger.child({ prefix: `[${this.slotName}] ` });
21
21
  this.connectionFactory = options.connectionFactory;
22
- this.connectionManager = this.connectionFactory.create({
23
- // Pool connections are only used intermittently.
24
- idleTimeout: 30_000,
25
- maxSize: 2,
26
- applicationName: getApplicationName()
27
- });
28
22
  }
29
23
 
30
24
  /**
@@ -40,10 +34,12 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
40
34
  * **This may be a bug in pgwire or how we're using it.
41
35
  */
42
36
  async keepAlive() {
43
- try {
44
- await sendKeepAlive(this.connectionManager.pool);
45
- } catch (e) {
46
- this.logger.warn(`KeepAlive failed, unable to post to WAL`, e);
37
+ if (this.connectionManager) {
38
+ try {
39
+ await sendKeepAlive(this.connectionManager.pool);
40
+ } catch (e) {
41
+ this.logger.warn(`KeepAlive failed, unable to post to WAL`, e);
42
+ }
47
43
  }
48
44
  }
49
45
 
@@ -53,35 +49,58 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
53
49
 
54
50
  async replicate() {
55
51
  try {
56
- await this.replicateLoop();
52
+ await this.replicateOnce();
57
53
  } catch (e) {
58
54
  // Fatal exception
59
- container.reporter.captureException(e, {
60
- metadata: {
61
- replication_slot: this.slotName
55
+
56
+ if (!this.isStopped) {
57
+ // Ignore aborted errors
58
+
59
+ this.logger.error(`Replication error`, e);
60
+ if (e.cause != null) {
61
+ // Example:
62
+ // PgError.conn_ended: Unable to do postgres query on ended connection
63
+ // at PgConnection.stream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:315:13)
64
+ // at stream.next (<anonymous>)
65
+ // at PgResult.fromStream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:1174:22)
66
+ // at PgConnection.query (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:311:21)
67
+ // at WalStream.startInitialReplication (file:///.../powersync/powersync-service/lib/replication/WalStream.js:266:22)
68
+ // ...
69
+ // cause: TypeError: match is not iterable
70
+ // at timestamptzToSqlite (file:///.../powersync/packages/jpgwire/dist/util.js:140:50)
71
+ // at PgType.decode (file:///.../powersync/packages/jpgwire/dist/pgwire_types.js:25:24)
72
+ // at PgConnection._recvDataRow (file:///.../powersync/packages/jpgwire/dist/util.js:88:22)
73
+ // at PgConnection._recvMessages (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:656:30)
74
+ // at PgConnection._ioloopAttempt (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:563:20)
75
+ // at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
76
+ // at async PgConnection._ioloop (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:517:14),
77
+ // [Symbol(pg.ErrorCode)]: 'conn_ended',
78
+ // [Symbol(pg.ErrorResponse)]: undefined
79
+ // }
80
+ // Without this additional log, the cause would not be visible in the logs.
81
+ this.logger.error(`cause`, e.cause);
62
82
  }
63
- });
64
- this.logger.error(`Replication failed`, e);
83
+ // Report the error if relevant, before retrying
84
+ container.reporter.captureException(e, {
85
+ metadata: {
86
+ replication_slot: this.slotName
87
+ }
88
+ });
89
+ // This sets the retry delay
90
+ this.rateLimiter.reportError(e);
91
+ }
65
92
 
66
93
  if (e instanceof MissingReplicationSlotError) {
67
94
  // This stops replication on this slot and restarts with a new slot
68
95
  await this.options.storage.factory.restartReplication(this.storage.group_id);
69
96
  }
97
+
98
+ // No need to rethrow - the error is already logged, and retry behavior is the same on error
70
99
  } finally {
71
100
  this.abortController.abort();
72
101
  }
73
102
  }
74
103
 
75
- async replicateLoop() {
76
- while (!this.isStopped) {
77
- await this.replicateOnce();
78
-
79
- if (!this.isStopped) {
80
- await new Promise((resolve) => setTimeout(resolve, 5000));
81
- }
82
- }
83
- }
84
-
85
104
  async replicateOnce() {
86
105
  // New connections on every iteration (every error with retry),
87
106
  // otherwise we risk repeating errors related to the connection,
@@ -92,6 +111,7 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
92
111
  maxSize: 2,
93
112
  applicationName: getApplicationName()
94
113
  });
114
+ this.connectionManager = connectionManager;
95
115
  try {
96
116
  await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
97
117
  if (this.isStopped) {
@@ -106,48 +126,8 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
106
126
  });
107
127
  this.lastStream = stream;
108
128
  await stream.replicate();
109
- } catch (e) {
110
- if (this.isStopped && e instanceof ReplicationAbortedError) {
111
- // Ignore aborted errors
112
- return;
113
- }
114
- this.logger.error(`Replication error`, e);
115
- if (e.cause != null) {
116
- // Example:
117
- // PgError.conn_ended: Unable to do postgres query on ended connection
118
- // at PgConnection.stream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:315:13)
119
- // at stream.next (<anonymous>)
120
- // at PgResult.fromStream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:1174:22)
121
- // at PgConnection.query (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:311:21)
122
- // at WalStream.startInitialReplication (file:///.../powersync/powersync-service/lib/replication/WalStream.js:266:22)
123
- // ...
124
- // cause: TypeError: match is not iterable
125
- // at timestamptzToSqlite (file:///.../powersync/packages/jpgwire/dist/util.js:140:50)
126
- // at PgType.decode (file:///.../powersync/packages/jpgwire/dist/pgwire_types.js:25:24)
127
- // at PgConnection._recvDataRow (file:///.../powersync/packages/jpgwire/dist/util.js:88:22)
128
- // at PgConnection._recvMessages (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:656:30)
129
- // at PgConnection._ioloopAttempt (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:563:20)
130
- // at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
131
- // at async PgConnection._ioloop (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:517:14),
132
- // [Symbol(pg.ErrorCode)]: 'conn_ended',
133
- // [Symbol(pg.ErrorResponse)]: undefined
134
- // }
135
- // Without this additional log, the cause would not be visible in the logs.
136
- this.logger.error(`cause`, e.cause);
137
- }
138
- if (e instanceof MissingReplicationSlotError) {
139
- throw e;
140
- } else {
141
- // Report the error if relevant, before retrying
142
- container.reporter.captureException(e, {
143
- metadata: {
144
- replication_slot: this.slotName
145
- }
146
- });
147
- // This sets the retry delay
148
- this.rateLimiter?.reportError(e);
149
- }
150
129
  } finally {
130
+ this.connectionManager = null;
151
131
  await connectionManager.end();
152
132
  }
153
133
  }
@@ -1,8 +1,8 @@
1
- import { DatabaseInputRow, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
2
1
  import * as pgwire from '@powersync/service-jpgwire';
3
- import { CustomTypeRegistry } from './registry.js';
2
+ import { DatabaseInputRow, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
4
3
  import semver from 'semver';
5
4
  import { getServerVersion } from '../utils/postgres_version.js';
5
+ import { CustomTypeRegistry } from './registry.js';
6
6
 
7
7
  /**
8
8
  * Resolves descriptions used to decode values for custom postgres types.
@@ -11,11 +11,9 @@ import { getServerVersion } from '../utils/postgres_version.js';
11
11
  */
12
12
  export class PostgresTypeResolver {
13
13
  private cachedVersion: semver.SemVer | null = null;
14
+ readonly registry: CustomTypeRegistry;
14
15
 
15
- constructor(
16
- readonly registry: CustomTypeRegistry,
17
- private readonly pool: pgwire.PgClient
18
- ) {
16
+ constructor(private readonly pool: pgwire.PgClient) {
19
17
  this.registry = new CustomTypeRegistry();
20
18
  }
21
19
 
@@ -188,6 +186,11 @@ WHERE a.attnum > 0
188
186
  return toSyncRulesRow(record);
189
187
  }
190
188
 
189
+ constructRowRecord(columnMap: Record<string, number>, tupleRaw: Record<string, any>): SqliteInputRow {
190
+ const record = this.decodeTupleForTable(columnMap, tupleRaw);
191
+ return toSyncRulesRow(record);
192
+ }
193
+
191
194
  /**
192
195
  * We need a high level of control over how values are decoded, to make sure there is no loss
193
196
  * of precision in the process.
@@ -206,5 +209,23 @@ WHERE a.attnum > 0
206
209
  return result;
207
210
  }
208
211
 
212
+ /**
213
+ * We need a high level of control over how values are decoded, to make sure there is no loss
214
+ * of precision in the process.
215
+ */
216
+ private decodeTupleForTable(columnMap: Record<string, number>, tupleRaw: Record<string, any>): DatabaseInputRow {
217
+ let result: Record<string, any> = {};
218
+ for (let columnName in tupleRaw) {
219
+ const rawval = tupleRaw[columnName];
220
+ const typeOid = columnMap[columnName];
221
+ if (typeof rawval == 'string' && typeOid) {
222
+ result[columnName] = this.registry.decodeDatabaseValue(rawval, typeOid);
223
+ } else {
224
+ result[columnName] = rawval;
225
+ }
226
+ }
227
+ return result;
228
+ }
229
+
209
230
  private static minVersionForMultirange: semver.SemVer = semver.parse('14.0.0')!;
210
231
  }
@@ -1,7 +1,6 @@
1
1
  import * as lib_postgres from '@powersync/lib-service-postgres';
2
2
  import * as service_types from '@powersync/service-types';
3
3
  import * as t from 'ts-codec';
4
- import { CustomTypeRegistry } from './registry.js';
5
4
 
6
5
  // Maintain backwards compatibility by exporting these
7
6
  export const validatePort = lib_postgres.validatePort;
@@ -25,10 +24,7 @@ export type PostgresConnectionConfig = t.Decoded<typeof PostgresConnectionConfig
25
24
  /**
26
25
  * Resolved version of {@link PostgresConnectionConfig}
27
26
  */
28
- export type ResolvedConnectionConfig = PostgresConnectionConfig &
29
- NormalizedPostgresConnectionConfig & {
30
- typeRegistry: CustomTypeRegistry;
31
- };
27
+ export type ResolvedConnectionConfig = PostgresConnectionConfig & NormalizedPostgresConnectionConfig;
32
28
 
33
29
  export function isPostgresConfig(
34
30
  config: service_types.configFile.DataSourceConfig
@@ -551,7 +551,7 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
551
551
  test('test replication - multiranges', async () => {
552
552
  const db = await connectPgPool();
553
553
 
554
- if (!(await new PostgresTypeResolver(new CustomTypeRegistry(), db).supportsMultiRanges())) {
554
+ if (!(await new PostgresTypeResolver(db).supportsMultiRanges())) {
555
555
  // This test requires Postgres 14 or later.
556
556
  return;
557
557
  }
@@ -620,7 +620,7 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
620
620
  * Return all the inserts from the first transaction in the replication stream.
621
621
  */
622
622
  async function getReplicationTx(db: pgwire.PgClient, replicationStream: pgwire.ReplicationStream) {
623
- const typeCache = new PostgresTypeResolver(new CustomTypeRegistry(), db);
623
+ const typeCache = new PostgresTypeResolver(db);
624
624
  await typeCache.fetchTypesForSchema();
625
625
 
626
626
  let transformed: SqliteInputRow[] = [];
@@ -69,7 +69,7 @@ function defineSlowTests(factory: storage.TestStorageFactory) {
69
69
  });
70
70
 
71
71
  async function testRepeatedReplication(testOptions: { compact: boolean; maxBatchSize: number; numBatches: number }) {
72
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
72
+ const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
73
73
  const replicationConnection = await connections.replicationConnection();
74
74
  const pool = connections.pool;
75
75
  await clearTestDb(pool);
@@ -330,7 +330,7 @@ bucket_definitions:
330
330
  await pool.query(`SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE active = FALSE`);
331
331
  i += 1;
332
332
 
333
- const connections = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
333
+ const connections = new PgManager(TEST_CONNECTION_OPTIONS, {});
334
334
  const replicationConnection = await connections.replicationConnection();
335
335
 
336
336
  abortController = new AbortController();
@@ -529,13 +529,24 @@ config:
529
529
  const { pool } = context;
530
530
  await pool.query(`DROP TABLE IF EXISTS test_data`);
531
531
  await pool.query(`CREATE TYPE composite AS (foo bool, bar int4);`);
532
- await pool.query(`CREATE TABLE test_data(id text primary key, description composite);`);
532
+ await pool.query(`CREATE TABLE test_data(id text primary key, description composite, ts timestamptz);`);
533
+
534
+ // Covered by initial replication
535
+ await pool.query(
536
+ `INSERT INTO test_data(id, description, ts) VALUES ('t1', ROW(TRUE, 1)::composite, '2025-11-17T09:11:00Z')`
537
+ );
533
538
 
534
539
  await context.initializeReplication();
535
- await pool.query(`INSERT INTO test_data(id, description) VALUES ('t1', ROW(TRUE, 2)::composite)`);
540
+ // Covered by streaming replication
541
+ await pool.query(
542
+ `INSERT INTO test_data(id, description, ts) VALUES ('t2', ROW(TRUE, 2)::composite, '2025-11-17T09:12:00Z')`
543
+ );
536
544
 
537
545
  const data = await context.getBucketData('1#stream|0[]');
538
- expect(data).toMatchObject([putOp('test_data', { id: 't1', description: '{"foo":1,"bar":2}' })]);
546
+ expect(data).toMatchObject([
547
+ putOp('test_data', { id: 't1', description: '{"foo":1,"bar":1}', ts: '2025-11-17T09:11:00.000000Z' }),
548
+ putOp('test_data', { id: 't2', description: '{"foo":1,"bar":2}', ts: '2025-11-17T09:12:00.000000Z' })
549
+ ]);
539
550
  });
540
551
 
541
552
  test('custom types in primary key', async () => {
@@ -33,7 +33,7 @@ export class WalStreamTestContext implements AsyncDisposable {
33
33
  options?: { doNotClear?: boolean; walStreamOptions?: Partial<WalStreamOptions> }
34
34
  ) {
35
35
  const f = await factory({ doNotClear: options?.doNotClear });
36
- const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, { registry: new CustomTypeRegistry() });
36
+ const connectionManager = new PgManager(TEST_CONNECTION_OPTIONS, {});
37
37
 
38
38
  if (!options?.doNotClear) {
39
39
  await clearTestDb(connectionManager.pool);