@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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +67 -0
- package/README.md +3 -0
- package/dist/api/PostgresRouteAPIAdapter.d.ts +22 -0
- package/dist/api/PostgresRouteAPIAdapter.js +273 -0
- package/dist/api/PostgresRouteAPIAdapter.js.map +1 -0
- package/dist/auth/SupabaseKeyCollector.d.ts +22 -0
- package/dist/auth/SupabaseKeyCollector.js +64 -0
- package/dist/auth/SupabaseKeyCollector.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/module/PostgresModule.d.ts +14 -0
- package/dist/module/PostgresModule.js +108 -0
- package/dist/module/PostgresModule.js.map +1 -0
- package/dist/replication/ConnectionManagerFactory.d.ts +10 -0
- package/dist/replication/ConnectionManagerFactory.js +21 -0
- package/dist/replication/ConnectionManagerFactory.js.map +1 -0
- package/dist/replication/PgManager.d.ts +25 -0
- package/dist/replication/PgManager.js +60 -0
- package/dist/replication/PgManager.js.map +1 -0
- package/dist/replication/PgRelation.d.ts +6 -0
- package/dist/replication/PgRelation.js +27 -0
- package/dist/replication/PgRelation.js.map +1 -0
- package/dist/replication/PostgresErrorRateLimiter.d.ts +11 -0
- package/dist/replication/PostgresErrorRateLimiter.js +43 -0
- package/dist/replication/PostgresErrorRateLimiter.js.map +1 -0
- package/dist/replication/WalStream.d.ts +53 -0
- package/dist/replication/WalStream.js +536 -0
- package/dist/replication/WalStream.js.map +1 -0
- package/dist/replication/WalStreamReplicationJob.d.ts +27 -0
- package/dist/replication/WalStreamReplicationJob.js +131 -0
- package/dist/replication/WalStreamReplicationJob.js.map +1 -0
- package/dist/replication/WalStreamReplicator.d.ts +13 -0
- package/dist/replication/WalStreamReplicator.js +36 -0
- package/dist/replication/WalStreamReplicator.js.map +1 -0
- package/dist/replication/replication-index.d.ts +5 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -0
- package/dist/replication/replication-utils.d.ts +32 -0
- package/dist/replication/replication-utils.js +272 -0
- package/dist/replication/replication-utils.js.map +1 -0
- package/dist/types/types.d.ts +76 -0
- package/dist/types/types.js +110 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/migration_lib.d.ts +11 -0
- package/dist/utils/migration_lib.js +64 -0
- package/dist/utils/migration_lib.js.map +1 -0
- package/dist/utils/pgwire_utils.d.ts +16 -0
- package/dist/utils/pgwire_utils.js +70 -0
- package/dist/utils/pgwire_utils.js.map +1 -0
- package/dist/utils/populate_test_data.d.ts +8 -0
- package/dist/utils/populate_test_data.js +65 -0
- package/dist/utils/populate_test_data.js.map +1 -0
- package/package.json +49 -0
- package/src/api/PostgresRouteAPIAdapter.ts +307 -0
- package/src/auth/SupabaseKeyCollector.ts +70 -0
- package/src/index.ts +5 -0
- package/src/module/PostgresModule.ts +122 -0
- package/src/replication/ConnectionManagerFactory.ts +28 -0
- package/src/replication/PgManager.ts +70 -0
- package/src/replication/PgRelation.ts +31 -0
- package/src/replication/PostgresErrorRateLimiter.ts +44 -0
- package/src/replication/WalStream.ts +639 -0
- package/src/replication/WalStreamReplicationJob.ts +142 -0
- package/src/replication/WalStreamReplicator.ts +45 -0
- package/src/replication/replication-index.ts +5 -0
- package/src/replication/replication-utils.ts +329 -0
- package/src/types/types.ts +159 -0
- package/src/utils/migration_lib.ts +79 -0
- package/src/utils/pgwire_utils.ts +73 -0
- package/src/utils/populate_test_data.ts +77 -0
- package/test/src/__snapshots__/pg_test.test.ts.snap +256 -0
- package/test/src/env.ts +7 -0
- package/test/src/large_batch.test.ts +195 -0
- package/test/src/pg_test.test.ts +450 -0
- package/test/src/schema_changes.test.ts +543 -0
- package/test/src/setup.ts +7 -0
- package/test/src/slow_tests.test.ts +335 -0
- package/test/src/util.ts +105 -0
- package/test/src/validation.test.ts +64 -0
- package/test/src/wal_stream.test.ts +319 -0
- package/test/src/wal_stream_utils.ts +121 -0
- package/test/tsconfig.json +28 -0
- package/tsconfig.json +31 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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,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
|
+
}
|