@powersync/service-module-postgres 0.0.0-dev-20240918092408

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 (87) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +67 -0
  3. package/README.md +3 -0
  4. package/dist/api/PostgresRouteAPIAdapter.d.ts +22 -0
  5. package/dist/api/PostgresRouteAPIAdapter.js +273 -0
  6. package/dist/api/PostgresRouteAPIAdapter.js.map +1 -0
  7. package/dist/auth/SupabaseKeyCollector.d.ts +22 -0
  8. package/dist/auth/SupabaseKeyCollector.js +64 -0
  9. package/dist/auth/SupabaseKeyCollector.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/module/PostgresModule.d.ts +14 -0
  14. package/dist/module/PostgresModule.js +108 -0
  15. package/dist/module/PostgresModule.js.map +1 -0
  16. package/dist/replication/ConnectionManagerFactory.d.ts +10 -0
  17. package/dist/replication/ConnectionManagerFactory.js +21 -0
  18. package/dist/replication/ConnectionManagerFactory.js.map +1 -0
  19. package/dist/replication/PgManager.d.ts +25 -0
  20. package/dist/replication/PgManager.js +60 -0
  21. package/dist/replication/PgManager.js.map +1 -0
  22. package/dist/replication/PgRelation.d.ts +6 -0
  23. package/dist/replication/PgRelation.js +27 -0
  24. package/dist/replication/PgRelation.js.map +1 -0
  25. package/dist/replication/PostgresErrorRateLimiter.d.ts +11 -0
  26. package/dist/replication/PostgresErrorRateLimiter.js +43 -0
  27. package/dist/replication/PostgresErrorRateLimiter.js.map +1 -0
  28. package/dist/replication/WalStream.d.ts +53 -0
  29. package/dist/replication/WalStream.js +536 -0
  30. package/dist/replication/WalStream.js.map +1 -0
  31. package/dist/replication/WalStreamReplicationJob.d.ts +27 -0
  32. package/dist/replication/WalStreamReplicationJob.js +131 -0
  33. package/dist/replication/WalStreamReplicationJob.js.map +1 -0
  34. package/dist/replication/WalStreamReplicator.d.ts +13 -0
  35. package/dist/replication/WalStreamReplicator.js +36 -0
  36. package/dist/replication/WalStreamReplicator.js.map +1 -0
  37. package/dist/replication/replication-index.d.ts +5 -0
  38. package/dist/replication/replication-index.js +6 -0
  39. package/dist/replication/replication-index.js.map +1 -0
  40. package/dist/replication/replication-utils.d.ts +32 -0
  41. package/dist/replication/replication-utils.js +272 -0
  42. package/dist/replication/replication-utils.js.map +1 -0
  43. package/dist/types/types.d.ts +76 -0
  44. package/dist/types/types.js +110 -0
  45. package/dist/types/types.js.map +1 -0
  46. package/dist/utils/migration_lib.d.ts +11 -0
  47. package/dist/utils/migration_lib.js +64 -0
  48. package/dist/utils/migration_lib.js.map +1 -0
  49. package/dist/utils/pgwire_utils.d.ts +16 -0
  50. package/dist/utils/pgwire_utils.js +70 -0
  51. package/dist/utils/pgwire_utils.js.map +1 -0
  52. package/dist/utils/populate_test_data.d.ts +8 -0
  53. package/dist/utils/populate_test_data.js +65 -0
  54. package/dist/utils/populate_test_data.js.map +1 -0
  55. package/package.json +49 -0
  56. package/src/api/PostgresRouteAPIAdapter.ts +307 -0
  57. package/src/auth/SupabaseKeyCollector.ts +70 -0
  58. package/src/index.ts +5 -0
  59. package/src/module/PostgresModule.ts +122 -0
  60. package/src/replication/ConnectionManagerFactory.ts +28 -0
  61. package/src/replication/PgManager.ts +70 -0
  62. package/src/replication/PgRelation.ts +31 -0
  63. package/src/replication/PostgresErrorRateLimiter.ts +44 -0
  64. package/src/replication/WalStream.ts +639 -0
  65. package/src/replication/WalStreamReplicationJob.ts +142 -0
  66. package/src/replication/WalStreamReplicator.ts +45 -0
  67. package/src/replication/replication-index.ts +5 -0
  68. package/src/replication/replication-utils.ts +329 -0
  69. package/src/types/types.ts +159 -0
  70. package/src/utils/migration_lib.ts +79 -0
  71. package/src/utils/pgwire_utils.ts +73 -0
  72. package/src/utils/populate_test_data.ts +77 -0
  73. package/test/src/__snapshots__/pg_test.test.ts.snap +256 -0
  74. package/test/src/env.ts +7 -0
  75. package/test/src/large_batch.test.ts +195 -0
  76. package/test/src/pg_test.test.ts +450 -0
  77. package/test/src/schema_changes.test.ts +543 -0
  78. package/test/src/setup.ts +7 -0
  79. package/test/src/slow_tests.test.ts +335 -0
  80. package/test/src/util.ts +105 -0
  81. package/test/src/validation.test.ts +64 -0
  82. package/test/src/wal_stream.test.ts +319 -0
  83. package/test/src/wal_stream_utils.ts +121 -0
  84. package/test/tsconfig.json +28 -0
  85. package/tsconfig.json +31 -0
  86. package/tsconfig.tsbuildinfo +1 -0
  87. package/vitest.config.ts +9 -0
@@ -0,0 +1,307 @@
1
+ import { api, ParseSyncRulesOptions } from '@powersync/service-core';
2
+ import * as pgwire from '@powersync/service-jpgwire';
3
+
4
+ import * as sync_rules from '@powersync/service-sync-rules';
5
+ import * as service_types from '@powersync/service-types';
6
+ import * as replication_utils from '../replication/replication-utils.js';
7
+ import * as types from '../types/types.js';
8
+ import * as pg_utils from '../utils/pgwire_utils.js';
9
+ import { getDebugTableInfo } from '../replication/replication-utils.js';
10
+ import { PUBLICATION_NAME } from '../replication/WalStream.js';
11
+
12
+ export class PostgresRouteAPIAdapter implements api.RouteAPI {
13
+ protected pool: pgwire.PgClient;
14
+
15
+ connectionTag: string;
16
+ // TODO this should probably be configurable one day
17
+ publicationName = PUBLICATION_NAME;
18
+
19
+ constructor(protected config: types.ResolvedConnectionConfig) {
20
+ this.pool = pgwire.connectPgWirePool(config, {
21
+ idleTimeout: 30_000
22
+ });
23
+ this.connectionTag = config.tag ?? sync_rules.DEFAULT_TAG;
24
+ }
25
+
26
+ getParseSyncRulesOptions(): ParseSyncRulesOptions {
27
+ return {
28
+ defaultSchema: 'public'
29
+ };
30
+ }
31
+
32
+ async shutdown(): Promise<void> {
33
+ await this.pool.end();
34
+ }
35
+
36
+ async getSourceConfig(): Promise<service_types.configFile.DataSourceConfig> {
37
+ return this.config;
38
+ }
39
+
40
+ async getConnectionStatus(): Promise<service_types.ConnectionStatusV2> {
41
+ const base = {
42
+ id: this.config.id,
43
+ uri: types.baseUri(this.config)
44
+ };
45
+
46
+ try {
47
+ await pg_utils.retriedQuery(this.pool, `SELECT 'PowerSync connection test'`);
48
+ } catch (e) {
49
+ return {
50
+ ...base,
51
+ connected: false,
52
+ errors: [{ level: 'fatal', message: e.message }]
53
+ };
54
+ }
55
+
56
+ try {
57
+ await replication_utils.checkSourceConfiguration(this.pool, this.publicationName);
58
+ } catch (e) {
59
+ return {
60
+ ...base,
61
+ connected: true,
62
+ errors: [{ level: 'fatal', message: e.message }]
63
+ };
64
+ }
65
+
66
+ return {
67
+ ...base,
68
+ connected: true,
69
+ errors: []
70
+ };
71
+ }
72
+
73
+ async executeQuery(query: string, params: any[]): Promise<service_types.internal_routes.ExecuteSqlResponse> {
74
+ if (!this.config.debug_api) {
75
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
76
+ results: {
77
+ columns: [],
78
+ rows: []
79
+ },
80
+ success: false,
81
+ error: 'SQL querying is not enabled'
82
+ });
83
+ }
84
+
85
+ try {
86
+ const result = await this.pool.query({
87
+ statement: query,
88
+ params: params.map(pg_utils.autoParameter)
89
+ });
90
+
91
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
92
+ success: true,
93
+ results: {
94
+ columns: result.columns.map((c) => c.name),
95
+ rows: result.rows.map((row) => {
96
+ return row.map((value) => {
97
+ const sqlValue = sync_rules.toSyncRulesValue(value);
98
+ if (typeof sqlValue == 'bigint') {
99
+ return Number(value);
100
+ } else if (sync_rules.isJsonValue(sqlValue)) {
101
+ return sqlValue;
102
+ } else {
103
+ return null;
104
+ }
105
+ });
106
+ })
107
+ }
108
+ });
109
+ } catch (e) {
110
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
111
+ results: {
112
+ columns: [],
113
+ rows: []
114
+ },
115
+ success: false,
116
+ error: e.message
117
+ });
118
+ }
119
+ }
120
+
121
+ async getDebugTablesInfo(
122
+ tablePatterns: sync_rules.TablePattern[],
123
+ sqlSyncRules: sync_rules.SqlSyncRules
124
+ ): Promise<api.PatternResult[]> {
125
+ let result: api.PatternResult[] = [];
126
+
127
+ for (let tablePattern of tablePatterns) {
128
+ const schema = tablePattern.schema;
129
+
130
+ let patternResult: api.PatternResult = {
131
+ schema: schema,
132
+ pattern: tablePattern.tablePattern,
133
+ wildcard: tablePattern.isWildcard
134
+ };
135
+ result.push(patternResult);
136
+
137
+ if (tablePattern.isWildcard) {
138
+ patternResult.tables = [];
139
+ const prefix = tablePattern.tablePrefix;
140
+ const results = await pg_utils.retriedQuery(this.pool, {
141
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
142
+ FROM pg_class c
143
+ JOIN pg_namespace n ON n.oid = c.relnamespace
144
+ WHERE n.nspname = $1
145
+ AND c.relkind = 'r'
146
+ AND c.relname LIKE $2`,
147
+ params: [
148
+ { type: 'varchar', value: schema },
149
+ { type: 'varchar', value: tablePattern.tablePattern }
150
+ ]
151
+ });
152
+
153
+ for (let row of pgwire.pgwireRows(results)) {
154
+ const name = row.table_name as string;
155
+ const relationId = row.relid as number;
156
+ if (!name.startsWith(prefix)) {
157
+ continue;
158
+ }
159
+ const details = await this.getDebugTableInfo(tablePattern, name, relationId, sqlSyncRules);
160
+ patternResult.tables.push(details);
161
+ }
162
+ } else {
163
+ const results = await pg_utils.retriedQuery(this.pool, {
164
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
165
+ FROM pg_class c
166
+ JOIN pg_namespace n ON n.oid = c.relnamespace
167
+ WHERE n.nspname = $1
168
+ AND c.relkind = 'r'
169
+ AND c.relname = $2`,
170
+ params: [
171
+ { type: 'varchar', value: schema },
172
+ { type: 'varchar', value: tablePattern.tablePattern }
173
+ ]
174
+ });
175
+ if (results.rows.length == 0) {
176
+ // Table not found
177
+ patternResult.table = await this.getDebugTableInfo(tablePattern, tablePattern.name, null, sqlSyncRules);
178
+ } else {
179
+ const row = pgwire.pgwireRows(results)[0];
180
+ const name = row.table_name as string;
181
+ const relationId = row.relid as number;
182
+ patternResult.table = await this.getDebugTableInfo(tablePattern, name, relationId, sqlSyncRules);
183
+ }
184
+ }
185
+ }
186
+ return result;
187
+ }
188
+
189
+ protected async getDebugTableInfo(
190
+ tablePattern: sync_rules.TablePattern,
191
+ name: string,
192
+ relationId: number | null,
193
+ syncRules: sync_rules.SqlSyncRules
194
+ ): Promise<service_types.TableInfo> {
195
+ return getDebugTableInfo({
196
+ db: this.pool,
197
+ name: name,
198
+ publicationName: this.publicationName,
199
+ connectionTag: this.connectionTag,
200
+ tablePattern: tablePattern,
201
+ relationId: relationId,
202
+ syncRules: syncRules
203
+ });
204
+ }
205
+
206
+ async getReplicationLag(syncRulesId: string): Promise<number> {
207
+ const results = await pg_utils.retriedQuery(this.pool, {
208
+ statement: `SELECT
209
+ slot_name,
210
+ confirmed_flush_lsn,
211
+ pg_current_wal_lsn(),
212
+ (pg_current_wal_lsn() - confirmed_flush_lsn) AS lsn_distance
213
+ FROM pg_replication_slots WHERE slot_name = $1 LIMIT 1;`,
214
+ params: [{ type: 'varchar', value: syncRulesId }]
215
+ });
216
+ const [row] = pgwire.pgwireRows(results);
217
+ if (row) {
218
+ return Number(row.lsn_distance);
219
+ }
220
+
221
+ throw new Error(`Could not determine replication lag for slot ${syncRulesId}`);
222
+ }
223
+
224
+ async getReplicationHead(): Promise<string> {
225
+ const [{ lsn }] = pgwire.pgwireRows(
226
+ await pg_utils.retriedQuery(this.pool, `SELECT pg_logical_emit_message(false, 'powersync', 'ping') as lsn`)
227
+ );
228
+ return String(lsn);
229
+ }
230
+
231
+ async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
232
+ // https://github.com/Borvik/vscode-postgres/blob/88ec5ed061a0c9bced6c5d4ec122d0759c3f3247/src/language/server.ts
233
+ const results = await pg_utils.retriedQuery(
234
+ this.pool,
235
+ `SELECT
236
+ tbl.schemaname,
237
+ tbl.tablename,
238
+ tbl.quoted_name,
239
+ json_agg(a ORDER BY attnum) as columns
240
+ FROM
241
+ (
242
+ SELECT
243
+ n.nspname as schemaname,
244
+ c.relname as tablename,
245
+ (quote_ident(n.nspname) || '.' || quote_ident(c.relname)) as quoted_name
246
+ FROM
247
+ pg_catalog.pg_class c
248
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
249
+ WHERE
250
+ c.relkind = 'r'
251
+ AND n.nspname not in ('information_schema', 'pg_catalog', 'pg_toast')
252
+ AND n.nspname not like 'pg_temp_%'
253
+ AND n.nspname not like 'pg_toast_temp_%'
254
+ AND c.relnatts > 0
255
+ AND has_schema_privilege(n.oid, 'USAGE') = true
256
+ AND has_table_privilege(quote_ident(n.nspname) || '.' || quote_ident(c.relname), 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER') = true
257
+ ) as tbl
258
+ LEFT JOIN (
259
+ SELECT
260
+ attrelid,
261
+ attname,
262
+ format_type(atttypid, atttypmod) as data_type,
263
+ (SELECT typname FROM pg_catalog.pg_type WHERE oid = atttypid) as pg_type,
264
+ attnum,
265
+ attisdropped
266
+ FROM
267
+ pg_attribute
268
+ ) as a ON (
269
+ a.attrelid = tbl.quoted_name::regclass
270
+ AND a.attnum > 0
271
+ AND NOT a.attisdropped
272
+ AND has_column_privilege(tbl.quoted_name, a.attname, 'SELECT, INSERT, UPDATE, REFERENCES')
273
+ )
274
+ GROUP BY schemaname, tablename, quoted_name`
275
+ );
276
+ const rows = pgwire.pgwireRows(results);
277
+
278
+ let schemas: Record<string, any> = {};
279
+
280
+ for (let row of rows) {
281
+ const schema = (schemas[row.schemaname] ??= {
282
+ name: row.schemaname,
283
+ tables: []
284
+ });
285
+ const table = {
286
+ name: row.tablename,
287
+ columns: [] as any[]
288
+ };
289
+ schema.tables.push(table);
290
+
291
+ const columnInfo = JSON.parse(row.columns);
292
+ for (let column of columnInfo) {
293
+ let pg_type = column.pg_type as string;
294
+ if (pg_type.startsWith('_')) {
295
+ pg_type = `${pg_type.substring(1)}[]`;
296
+ }
297
+ table.columns.push({
298
+ name: column.attname,
299
+ type: column.data_type,
300
+ pg_type: pg_type
301
+ });
302
+ }
303
+ }
304
+
305
+ return Object.values(schemas);
306
+ }
307
+ }
@@ -0,0 +1,70 @@
1
+ import { auth } from '@powersync/service-core';
2
+ import * as pgwire from '@powersync/service-jpgwire';
3
+ import * as jose from 'jose';
4
+
5
+ import * as types from '../types/types.js';
6
+ import * as pgwire_utils from '../utils/pgwire_utils.js';
7
+
8
+ /**
9
+ * Fetches key from the Supabase database.
10
+ *
11
+ * Unfortunately, despite the JWTs containing a kid, we have no way to lookup that kid
12
+ * before receiving a valid token.
13
+ */
14
+ export class SupabaseKeyCollector implements auth.KeyCollector {
15
+ private pool: pgwire.PgClient;
16
+
17
+ private keyOptions: auth.KeyOptions = {
18
+ requiresAudience: ['authenticated'],
19
+ maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin
20
+ };
21
+
22
+ constructor(connectionConfig: types.ResolvedConnectionConfig) {
23
+ this.pool = pgwire.connectPgWirePool(connectionConfig, {
24
+ // To avoid overloading the source database with open connections,
25
+ // limit to a single connection, and close the connection shortly
26
+ // after using it.
27
+ idleTimeout: 5_000,
28
+ maxSize: 1
29
+ });
30
+ }
31
+
32
+ shutdown() {
33
+ return this.pool.end();
34
+ }
35
+
36
+ async getKeys() {
37
+ let row: { jwt_secret: string };
38
+ try {
39
+ const rows = pgwire.pgwireRows(
40
+ await pgwire_utils.retriedQuery(this.pool, `SELECT current_setting('app.settings.jwt_secret') as jwt_secret`)
41
+ );
42
+ row = rows[0] as any;
43
+ } catch (e) {
44
+ if (e.message?.includes('unrecognized configuration parameter')) {
45
+ throw new jose.errors.JOSEError(`Generate a new JWT secret on Supabase. Cause: ${e.message}`);
46
+ } else {
47
+ throw e;
48
+ }
49
+ }
50
+ const secret = row?.jwt_secret as string | undefined;
51
+ if (secret == null) {
52
+ return {
53
+ keys: [],
54
+ errors: [new jose.errors.JWKSNoMatchingKey()]
55
+ };
56
+ } else {
57
+ const key: jose.JWK = {
58
+ kty: 'oct',
59
+ alg: 'HS256',
60
+ // While the secret is valid base64, the base64-encoded form is the secret value.
61
+ k: Buffer.from(secret, 'utf8').toString('base64url')
62
+ };
63
+ const imported = await auth.KeySpec.importKey(key, this.keyOptions);
64
+ return {
65
+ keys: [imported],
66
+ errors: []
67
+ };
68
+ }
69
+ }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { PostgresModule } from './module/PostgresModule.js';
2
+
3
+ export const module = new PostgresModule();
4
+
5
+ export default module;
@@ -0,0 +1,122 @@
1
+ import {
2
+ api,
3
+ auth,
4
+ ConfigurationFileSyncRulesProvider,
5
+ replication,
6
+ system,
7
+ TearDownOptions
8
+ } from '@powersync/service-core';
9
+ import * as jpgwire from '@powersync/service-jpgwire';
10
+ import * as types from '../types/types.js';
11
+ import { PostgresRouteAPIAdapter } from '../api/PostgresRouteAPIAdapter.js';
12
+ import { SupabaseKeyCollector } from '../auth/SupabaseKeyCollector.js';
13
+ import { WalStreamReplicator } from '../replication/WalStreamReplicator.js';
14
+ import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js';
15
+ import { PostgresErrorRateLimiter } from '../replication/PostgresErrorRateLimiter.js';
16
+ import { cleanUpReplicationSlot } from '../replication/replication-utils.js';
17
+ import { PgManager } from '../replication/PgManager.js';
18
+
19
+ export class PostgresModule extends replication.ReplicationModule<types.PostgresConnectionConfig> {
20
+ constructor() {
21
+ super({
22
+ name: 'Postgres',
23
+ type: types.POSTGRES_CONNECTION_TYPE,
24
+ configSchema: types.PostgresConnectionConfig
25
+ });
26
+ }
27
+
28
+ async initialize(context: system.ServiceContextContainer): Promise<void> {
29
+ await super.initialize(context);
30
+
31
+ // Record replicated bytes using global jpgwire metrics.
32
+ if (context.configuration.base_config.client_auth?.supabase) {
33
+ this.registerSupabaseAuth(context);
34
+ }
35
+
36
+ if (context.metrics) {
37
+ jpgwire.setMetricsRecorder({
38
+ addBytesRead(bytes) {
39
+ context.metrics!.data_replicated_bytes.add(bytes);
40
+ }
41
+ });
42
+ }
43
+ }
44
+
45
+ protected createRouteAPIAdapter(): api.RouteAPI {
46
+ return new PostgresRouteAPIAdapter(this.resolveConfig(this.decodedConfig!));
47
+ }
48
+
49
+ protected createReplicator(context: system.ServiceContext): replication.AbstractReplicator {
50
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
51
+ const syncRuleProvider = new ConfigurationFileSyncRulesProvider(context.configuration.sync_rules);
52
+ const connectionFactory = new ConnectionManagerFactory(normalisedConfig);
53
+
54
+ return new WalStreamReplicator({
55
+ id: this.getDefaultId(normalisedConfig.database),
56
+ syncRuleProvider: syncRuleProvider,
57
+ storageEngine: context.storageEngine,
58
+ connectionFactory: connectionFactory,
59
+ rateLimiter: new PostgresErrorRateLimiter()
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Combines base config with normalized connection settings
65
+ */
66
+ private resolveConfig(config: types.PostgresConnectionConfig): types.ResolvedConnectionConfig {
67
+ return {
68
+ ...config,
69
+ ...types.normalizeConnectionConfig(config)
70
+ };
71
+ }
72
+
73
+ async teardown(options: TearDownOptions): Promise<void> {
74
+ const normalisedConfig = this.resolveConfig(this.decodedConfig!);
75
+ const connectionManager = new PgManager(normalisedConfig, {
76
+ idleTimeout: 30_000,
77
+ maxSize: 1
78
+ });
79
+
80
+ try {
81
+ if (options.syncRules) {
82
+ // TODO: In the future, once we have more replication types, we will need to check if these syncRules are for Postgres
83
+ for (let syncRules of options.syncRules) {
84
+ try {
85
+ await cleanUpReplicationSlot(syncRules.slot_name, connectionManager.pool);
86
+ } catch (e) {
87
+ // Not really much we can do here for failures, most likely the database is no longer accessible
88
+ this.logger.warn(`Failed to fully clean up Postgres replication slot: ${syncRules.slot_name}`, e);
89
+ }
90
+ }
91
+ }
92
+ } finally {
93
+ await connectionManager.end();
94
+ }
95
+ }
96
+
97
+ // TODO: This should rather be done by registering the key collector in some kind of auth engine
98
+ private registerSupabaseAuth(context: system.ServiceContextContainer) {
99
+ const { configuration } = context;
100
+ // Register the Supabase key collector(s)
101
+ configuration.connections
102
+ ?.map((baseConfig) => {
103
+ if (baseConfig.type != types.POSTGRES_CONNECTION_TYPE) {
104
+ return;
105
+ }
106
+ try {
107
+ return this.resolveConfig(types.PostgresConnectionConfig.decode(baseConfig as any));
108
+ } catch (ex) {
109
+ this.logger.warn('Failed to decode configuration.', ex);
110
+ }
111
+ })
112
+ .filter((c) => !!c)
113
+ .forEach((config) => {
114
+ const keyCollector = new SupabaseKeyCollector(config!);
115
+ context.lifeCycleEngine.withLifecycle(keyCollector, {
116
+ // Close the internal pool
117
+ stop: (collector) => collector.shutdown()
118
+ });
119
+ configuration.client_keystore.collector.add(new auth.CachedKeyCollector(keyCollector));
120
+ });
121
+ }
122
+ }
@@ -0,0 +1,28 @@
1
+ import { PgManager } from './PgManager.js';
2
+ import { NormalizedPostgresConnectionConfig } from '../types/types.js';
3
+ import { PgPoolOptions } from '@powersync/service-jpgwire';
4
+ import { logger } from '@powersync/lib-services-framework';
5
+
6
+ export class ConnectionManagerFactory {
7
+ private readonly connectionManagers: PgManager[];
8
+ private readonly dbConnectionConfig: NormalizedPostgresConnectionConfig;
9
+
10
+ constructor(dbConnectionConfig: NormalizedPostgresConnectionConfig) {
11
+ this.dbConnectionConfig = dbConnectionConfig;
12
+ this.connectionManagers = [];
13
+ }
14
+
15
+ create(poolOptions: PgPoolOptions) {
16
+ const manager = new PgManager(this.dbConnectionConfig, poolOptions);
17
+ this.connectionManagers.push(manager);
18
+ return manager;
19
+ }
20
+
21
+ async shutdown() {
22
+ logger.info('Shutting down Postgres connection Managers...');
23
+ for (const manager of this.connectionManagers) {
24
+ await manager.end();
25
+ }
26
+ logger.info('Postgres connection Managers shutdown completed.');
27
+ }
28
+ }
@@ -0,0 +1,70 @@
1
+ import * as pgwire from '@powersync/service-jpgwire';
2
+ import { NormalizedPostgresConnectionConfig } from '../types/types.js';
3
+
4
+ export class PgManager {
5
+ /**
6
+ * Do not use this for any transactions.
7
+ */
8
+ public readonly pool: pgwire.PgClient;
9
+
10
+ private connectionPromises: Promise<pgwire.PgConnection>[] = [];
11
+
12
+ constructor(public options: NormalizedPostgresConnectionConfig, public poolOptions: pgwire.PgPoolOptions) {
13
+ // The pool is lazy - no connections are opened until a query is performed.
14
+ this.pool = pgwire.connectPgWirePool(this.options, poolOptions);
15
+ }
16
+
17
+ public get connectionTag() {
18
+ return this.options.tag;
19
+ }
20
+
21
+ /**
22
+ * Create a new replication connection.
23
+ */
24
+ async replicationConnection(): Promise<pgwire.PgConnection> {
25
+ const p = pgwire.connectPgWire(this.options, { type: 'replication' });
26
+ this.connectionPromises.push(p);
27
+ return await p;
28
+ }
29
+
30
+ /**
31
+ * Create a new standard connection, used for initial snapshot.
32
+ *
33
+ * This connection must not be shared between multiple async contexts.
34
+ */
35
+ async snapshotConnection(): Promise<pgwire.PgConnection> {
36
+ const p = pgwire.connectPgWire(this.options, { type: 'standard' });
37
+ this.connectionPromises.push(p);
38
+ return await p;
39
+ }
40
+
41
+ async end(): Promise<void> {
42
+ for (let result of await Promise.allSettled([
43
+ this.pool.end(),
44
+ ...this.connectionPromises.map(async (promise) => {
45
+ const connection = await promise;
46
+ return await connection.end();
47
+ })
48
+ ])) {
49
+ // Throw the first error, if any
50
+ if (result.status == 'rejected') {
51
+ throw result.reason;
52
+ }
53
+ }
54
+ }
55
+
56
+ async destroy() {
57
+ this.pool.destroy();
58
+ for (let result of await Promise.allSettled([
59
+ ...this.connectionPromises.map(async (promise) => {
60
+ const connection = await promise;
61
+ return connection.destroy();
62
+ })
63
+ ])) {
64
+ // Throw the first error, if any
65
+ if (result.status == 'rejected') {
66
+ throw result.reason;
67
+ }
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,31 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import { PgoutputRelation } from '@powersync/service-jpgwire';
3
+
4
+ export type ReplicationIdentity = 'default' | 'nothing' | 'full' | 'index';
5
+
6
+ export function getReplicaIdColumns(relation: PgoutputRelation): storage.ColumnDescriptor[] {
7
+ if (relation.replicaIdentity == 'nothing') {
8
+ return [];
9
+ } else {
10
+ return relation.columns
11
+ .filter((c) => (c.flags & 0b1) != 0)
12
+ .map((c) => ({ name: c.name, typeId: c.typeOid } satisfies storage.ColumnDescriptor));
13
+ }
14
+ }
15
+ export function getRelId(source: PgoutputRelation): number {
16
+ // Source types are wrong here
17
+ const relId = (source as any).relationOid as number;
18
+ if (!relId) {
19
+ throw new Error(`No relation id!`);
20
+ }
21
+ return relId;
22
+ }
23
+
24
+ export function getPgOutputRelation(source: PgoutputRelation): storage.SourceEntityDescriptor {
25
+ return {
26
+ name: source.name,
27
+ schema: source.schema,
28
+ objectId: getRelId(source),
29
+ replicationColumns: getReplicaIdColumns(source)
30
+ } satisfies storage.SourceEntityDescriptor;
31
+ }
@@ -0,0 +1,44 @@
1
+ import { setTimeout } from 'timers/promises';
2
+ import { ErrorRateLimiter } from '@powersync/service-core';
3
+
4
+ export class PostgresErrorRateLimiter implements ErrorRateLimiter {
5
+ nextAllowed: number = Date.now();
6
+
7
+ async waitUntilAllowed(options?: { signal?: AbortSignal | undefined } | undefined): Promise<void> {
8
+ const delay = Math.max(0, this.nextAllowed - Date.now());
9
+ // Minimum delay between connections, even without errors
10
+ this.setDelay(500);
11
+ await setTimeout(delay, undefined, { signal: options?.signal });
12
+ }
13
+
14
+ mayPing(): boolean {
15
+ return Date.now() >= this.nextAllowed;
16
+ }
17
+
18
+ reportError(e: any): void {
19
+ const message = (e.message as string) ?? '';
20
+ if (message.includes('password authentication failed')) {
21
+ // Wait 15 minutes, to avoid triggering Supabase's fail2ban
22
+ this.setDelay(900_000);
23
+ } else if (message.includes('ENOTFOUND')) {
24
+ // DNS lookup issue - incorrect URI or deleted instance
25
+ this.setDelay(120_000);
26
+ } else if (message.includes('ECONNREFUSED')) {
27
+ // Could be fail2ban or similar
28
+ this.setDelay(120_000);
29
+ } else if (
30
+ message.includes('Unable to do postgres query on ended pool') ||
31
+ message.includes('Postgres unexpectedly closed connection')
32
+ ) {
33
+ // Connection timed out - ignore / immediately retry
34
+ // We don't explicitly set the delay to 0, since there could have been another error that
35
+ // we need to respect.
36
+ } else {
37
+ this.setDelay(30_000);
38
+ }
39
+ }
40
+
41
+ private setDelay(delay: number) {
42
+ this.nextAllowed = Math.max(this.nextAllowed, Date.now() + delay);
43
+ }
44
+ }