@kazurolabs/psql-explorer 0.0.0-stage → 0.3.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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/RELEASES.md +87 -0
  4. package/dist/app.js +39 -0
  5. package/dist/cli.js +307 -0
  6. package/dist/commands/configgen.js +128 -0
  7. package/dist/commands/explore.js +388 -0
  8. package/dist/commands/terminal-prompt.js +68 -0
  9. package/dist/config/load.js +324 -0
  10. package/dist/config/resolve-environment.js +112 -0
  11. package/dist/config/resolvers/env.js +12 -0
  12. package/dist/config/resolvers/file.js +24 -0
  13. package/dist/config/resolvers/groups.js +1 -0
  14. package/dist/config/resolvers/index.js +49 -0
  15. package/dist/config/resolvers/literal.js +7 -0
  16. package/dist/config/resolvers/prompt.js +159 -0
  17. package/dist/config/resolvers/stored.js +13 -0
  18. package/dist/config/resolvers/types.js +18 -0
  19. package/dist/config/resolvers/vault.js +283 -0
  20. package/dist/config/schema.js +24 -0
  21. package/dist/config/source.js +105 -0
  22. package/dist/db/connect.js +55 -0
  23. package/dist/db/identifiers.js +6 -0
  24. package/dist/db/paths.js +14 -0
  25. package/dist/db/store.js +323 -0
  26. package/dist/db/tester.js +190 -0
  27. package/dist/db/validate.js +99 -0
  28. package/dist/environments.js +140 -0
  29. package/dist/explorer/alter.js +493 -0
  30. package/dist/explorer/catalog.js +414 -0
  31. package/dist/explorer/connect-entry.js +42 -0
  32. package/dist/explorer/cypher-parse.js +478 -0
  33. package/dist/explorer/cypher.js +963 -0
  34. package/dist/explorer/ddl.js +502 -0
  35. package/dist/explorer/dependents.js +222 -0
  36. package/dist/explorer/design-load.js +254 -0
  37. package/dist/explorer/design.js +45 -0
  38. package/dist/explorer/edit.js +108 -0
  39. package/dist/explorer/explain.js +37 -0
  40. package/dist/explorer/filter.js +121 -0
  41. package/dist/explorer/format.js +134 -0
  42. package/dist/explorer/graph.js +679 -0
  43. package/dist/explorer/gucs.js +31 -0
  44. package/dist/explorer/profile.js +102 -0
  45. package/dist/explorer/references.js +95 -0
  46. package/dist/explorer/rows.js +64 -0
  47. package/dist/explorer/schema-diff.js +360 -0
  48. package/dist/explorer/search.js +309 -0
  49. package/dist/explorer/security.js +152 -0
  50. package/dist/explorer/seed.js +1376 -0
  51. package/dist/explorer/session.js +122 -0
  52. package/dist/explorer/stats.js +135 -0
  53. package/dist/explorer/timeout.js +13 -0
  54. package/dist/prompts.js +52 -0
  55. package/dist/screens/environments.js +229 -0
  56. package/dist/screens/explorer.js +145 -0
  57. package/dist/screens/header.js +8 -0
  58. package/dist/screens/index.js +13 -0
  59. package/dist/screens/new-connection.js +178 -0
  60. package/dist/screens/panes/common.js +35 -0
  61. package/dist/screens/panes/history.js +36 -0
  62. package/dist/screens/panes/info.js +61 -0
  63. package/dist/screens/panes/query.js +113 -0
  64. package/dist/screens/panes/table.js +178 -0
  65. package/dist/screens/panes/tables.js +142 -0
  66. package/dist/screens/prompt-modal.js +18 -0
  67. package/dist/screens/record-view.js +42 -0
  68. package/dist/screens/result-grid.js +126 -0
  69. package/dist/screens/scroll.js +12 -0
  70. package/dist/screens/splash.js +78 -0
  71. package/dist/screens/terminal.js +25 -0
  72. package/dist/screens/types.js +1 -0
  73. package/dist/services.js +1 -0
  74. package/dist/startup.js +84 -0
  75. package/dist/web/server.js +988 -0
  76. package/package.json +69 -5
  77. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  78. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  79. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  80. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  81. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  82. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  83. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  84. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  85. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  86. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  87. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  88. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  89. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  90. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  91. package/web/dist/assets/button-ClA70PLR.js +1 -0
  92. package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
  93. package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
  94. package/web/dist/assets/copy-CCXzeW-s.js +1 -0
  95. package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
  96. package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
  97. package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
  98. package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
  99. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  100. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  101. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  102. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  103. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  104. package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
  105. package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
  106. package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
  107. package/web/dist/assets/index-CDcnUacl.js +10 -0
  108. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  109. package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
  110. package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
  111. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  112. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  113. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  114. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  115. package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
  116. package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
  117. package/web/dist/assets/route-DO6IZ7of.js +15 -0
  118. package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
  119. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  120. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  121. package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
  122. package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
  123. package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
  124. package/web/dist/assets/table-DfMz2f8M.js +1 -0
  125. package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
  126. package/web/dist/assets/zone-D9dXk8tn.js +5 -0
  127. package/web/dist/index.html +26 -0
@@ -0,0 +1,323 @@
1
+ import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs';
2
+ import { databaseFile } from './paths.js';
3
+ // node:sqlite (Node 22.13+) prints an ExperimentalWarning when it loads, which would land on top of
4
+ // the TUI. Drop only that warning, and load the module afterwards: a static import would be
5
+ // evaluated before this filter could be installed.
6
+ const emitWarning = process.emitWarning.bind(process);
7
+ process.emitWarning = ((warning, ...rest) => {
8
+ const [options] = rest;
9
+ const type = typeof options === 'string' ? options : options?.type;
10
+ const message = typeof warning === 'string' ? warning : warning.message;
11
+ const name = typeof warning === 'string' ? type : warning.name;
12
+ if (name === 'ExperimentalWarning' && message.includes('SQLite'))
13
+ return;
14
+ emitWarning(warning, ...rest);
15
+ });
16
+ const { DatabaseSync: Database } = process.getBuiltinModule('node:sqlite');
17
+ export class StoreError extends Error {
18
+ name = 'StoreError';
19
+ }
20
+ export class DuplicateNameError extends StoreError {
21
+ connectionName;
22
+ name = 'DuplicateNameError';
23
+ constructor(connectionName) {
24
+ super(`A connection named '${connectionName}' already exists`);
25
+ this.connectionName = connectionName;
26
+ }
27
+ }
28
+ export class ConnectionNotFoundError extends StoreError {
29
+ id;
30
+ name = 'ConnectionNotFoundError';
31
+ constructor(id) {
32
+ super(`Connection ${id} does not exist`);
33
+ this.id = id;
34
+ }
35
+ }
36
+ // Thrown by openStore when the file or its directory can't be created, opened or migrated.
37
+ export class StoreOpenError extends StoreError {
38
+ file;
39
+ name = 'StoreOpenError';
40
+ constructor(file, cause) {
41
+ super(`Could not open the local database at ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
42
+ this.file = file;
43
+ }
44
+ }
45
+ // Ordered schema migrations: migrations[i] takes the file from user_version i to i + 1. Only ever
46
+ // append to this list; never edit a migration that has shipped.
47
+ const migrations = [
48
+ `CREATE TABLE connections (
49
+ id INTEGER PRIMARY KEY,
50
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
51
+ type TEXT NOT NULL,
52
+ host TEXT NOT NULL,
53
+ port INTEGER NOT NULL,
54
+ database TEXT NOT NULL,
55
+ username TEXT NOT NULL,
56
+ password TEXT NOT NULL,
57
+ schema TEXT NOT NULL,
58
+ ssl INTEGER NOT NULL DEFAULT 0,
59
+ last_test_status TEXT NULL CHECK (last_test_status IN ('ok', 'failed')),
60
+ last_test_message TEXT NULL,
61
+ last_tested_at TEXT NULL,
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL
64
+ )`,
65
+ `CREATE TABLE settings (
66
+ key TEXT PRIMARY KEY,
67
+ value TEXT NOT NULL
68
+ )`,
69
+ // Values the user keeps for config fields written as `source: stored`.
70
+ `CREATE TABLE secrets (
71
+ key TEXT PRIMARY KEY,
72
+ value TEXT NOT NULL,
73
+ updated_at TEXT NOT NULL
74
+ )`,
75
+ `CREATE TABLE history (
76
+ id INTEGER PRIMARY KEY,
77
+ at TEXT NOT NULL,
78
+ env_name TEXT NOT NULL,
79
+ sql TEXT NOT NULL,
80
+ duration_ms INTEGER NOT NULL,
81
+ row_count INTEGER NULL,
82
+ error TEXT NULL
83
+ );
84
+ CREATE INDEX history_env ON history (env_name, id)`,
85
+ // Queries kept by name, per environment, for the web UI.
86
+ `CREATE TABLE saved_queries (
87
+ id INTEGER PRIMARY KEY,
88
+ env_name TEXT NOT NULL,
89
+ name TEXT NOT NULL,
90
+ folder TEXT NOT NULL DEFAULT '',
91
+ sql TEXT NOT NULL,
92
+ pinned INTEGER NOT NULL DEFAULT 0,
93
+ created_at TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL
95
+ );
96
+ CREATE INDEX saved_queries_env ON saved_queries (env_name)`,
97
+ ];
98
+ export const schemaVersion = migrations.length;
99
+ function migrate(db) {
100
+ const { user_version: current } = db.prepare('PRAGMA user_version').get();
101
+ if (current > migrations.length) {
102
+ throw new Error(`it was created by a newer version of psql-explorer (schema version ${current}, this version knows ${migrations.length})`);
103
+ }
104
+ for (let version = current; version < migrations.length; version++) {
105
+ db.exec('BEGIN IMMEDIATE');
106
+ try {
107
+ db.exec(migrations[version]);
108
+ db.exec(`PRAGMA user_version = ${version + 1}`);
109
+ db.exec('COMMIT');
110
+ }
111
+ catch (error) {
112
+ db.exec('ROLLBACK');
113
+ throw error;
114
+ }
115
+ }
116
+ }
117
+ const toSaved = (row) => ({
118
+ id: row.id,
119
+ envName: row.env_name,
120
+ name: row.name,
121
+ folder: row.folder,
122
+ sql: row.sql,
123
+ pinned: row.pinned === 1,
124
+ createdAt: row.created_at,
125
+ updatedAt: row.updated_at,
126
+ });
127
+ const toHistory = (row) => ({
128
+ id: row.id,
129
+ at: row.at,
130
+ envName: row.env_name,
131
+ sql: row.sql,
132
+ durationMs: row.duration_ms,
133
+ rowCount: row.row_count,
134
+ error: row.error,
135
+ });
136
+ const toConnection = (row) => ({
137
+ id: row.id,
138
+ name: row.name,
139
+ type: 'postgres',
140
+ host: row.host,
141
+ port: row.port,
142
+ database: row.database,
143
+ username: row.username,
144
+ password: row.password,
145
+ schema: row.schema,
146
+ ssl: row.ssl === 1,
147
+ lastTest: row.last_test_status && row.last_tested_at
148
+ ? {
149
+ status: row.last_test_status,
150
+ message: row.last_test_message,
151
+ testedAt: row.last_tested_at,
152
+ }
153
+ : null,
154
+ createdAt: row.created_at,
155
+ updatedAt: row.updated_at,
156
+ });
157
+ // SQLITE_CONSTRAINT_UNIQUE; name is the only unique column besides the primary key.
158
+ const isUniqueViolation = (error) => error?.errcode === 2067;
159
+ const inputParams = (input) => ({
160
+ name: input.name,
161
+ host: input.host,
162
+ port: input.port,
163
+ database: input.database,
164
+ username: input.username,
165
+ password: input.password,
166
+ schema: input.schema,
167
+ ssl: input.ssl ? 1 : 0,
168
+ });
169
+ // Opens (creating if needed) the store in `dir`: the directory is created owner-only, the file is
170
+ // kept at 0600 because it holds passwords, and pending migrations are applied.
171
+ export function openStore(dir) {
172
+ const file = databaseFile(dir);
173
+ let db;
174
+ try {
175
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
176
+ // Create the file ourselves so it is never readable by others, even briefly.
177
+ closeSync(openSync(file, 'a', 0o600));
178
+ chmodSync(file, 0o600);
179
+ db = new Database(file);
180
+ db.exec('PRAGMA foreign_keys = ON');
181
+ migrate(db);
182
+ }
183
+ catch (error) {
184
+ db?.close();
185
+ throw new StoreOpenError(file, error);
186
+ }
187
+ const database = db;
188
+ let closed = false;
189
+ const now = () => new Date().toISOString();
190
+ const select = database.prepare('SELECT * FROM connections WHERE id = ?');
191
+ const get = (id) => {
192
+ const row = select.get(id);
193
+ return row ? toConnection(row) : undefined;
194
+ };
195
+ const mustGet = (id) => {
196
+ const connection = get(id);
197
+ if (!connection)
198
+ throw new ConnectionNotFoundError(id);
199
+ return connection;
200
+ };
201
+ const write = (input, run) => {
202
+ try {
203
+ return run();
204
+ }
205
+ catch (error) {
206
+ if (isUniqueViolation(error))
207
+ throw new DuplicateNameError(input.name);
208
+ throw error;
209
+ }
210
+ };
211
+ const insert = database.prepare(`INSERT INTO connections (name, type, host, port, database, username, password, schema, ssl, created_at, updated_at)
212
+ VALUES (:name, 'postgres', :host, :port, :database, :username, :password, :schema, :ssl, :now, :now)`);
213
+ const change = database.prepare(`UPDATE connections SET name = :name, host = :host, port = :port, database = :database,
214
+ username = :username, password = :password, schema = :schema, ssl = :ssl,
215
+ last_test_status = NULL, last_test_message = NULL, last_tested_at = NULL, updated_at = :now
216
+ WHERE id = :id`);
217
+ const test = database.prepare(`UPDATE connections SET last_test_status = :status, last_test_message = :message,
218
+ last_tested_at = :testedAt WHERE id = :id`);
219
+ const remove = database.prepare('DELETE FROM connections WHERE id = ?');
220
+ const all = database.prepare('SELECT * FROM connections ORDER BY name COLLATE NOCASE, id');
221
+ const insertHistory = database.prepare(`INSERT INTO history (at, env_name, sql, duration_ms, row_count, error)
222
+ VALUES (:at, :envName, :sql, :durationMs, :rowCount, :error)`);
223
+ const selectHistory = database.prepare('SELECT * FROM history WHERE id = ?');
224
+ // The latest run of each distinct statement.
225
+ const historyByEnv = database.prepare(`SELECT * FROM history WHERE id IN (
226
+ SELECT MAX(id) FROM history WHERE env_name = ? GROUP BY sql
227
+ ) ORDER BY id DESC LIMIT ?`);
228
+ const insertSaved = database.prepare(`INSERT INTO saved_queries (env_name, name, folder, sql, pinned, created_at, updated_at)
229
+ VALUES (:envName, :name, :folder, :sql, :pinned, :now, :now)`);
230
+ const selectSaved = database.prepare('SELECT * FROM saved_queries WHERE id = ?');
231
+ const savedByEnv = database.prepare(`SELECT * FROM saved_queries WHERE env_name = ?
232
+ ORDER BY pinned DESC, folder COLLATE NOCASE, name COLLATE NOCASE, id`);
233
+ const changeSaved = database.prepare(`UPDATE saved_queries SET name = :name, folder = :folder, sql = :sql, pinned = :pinned,
234
+ updated_at = :now WHERE id = :id`);
235
+ const removeSaved = database.prepare('DELETE FROM saved_queries WHERE id = ?');
236
+ const selectSetting = database.prepare('SELECT value FROM settings WHERE key = ?');
237
+ const upsertSetting = database.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value');
238
+ const selectSecret = database.prepare('SELECT value FROM secrets WHERE key = ?');
239
+ const upsertSecret = database.prepare('INSERT INTO secrets (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at');
240
+ const removeSecret = database.prepare('DELETE FROM secrets WHERE key = ?');
241
+ return {
242
+ file,
243
+ list: () => all.all().map(toConnection),
244
+ get,
245
+ create: (input) => write(input, () => {
246
+ const { lastInsertRowid } = insert.run({ ...inputParams(input), now: now() });
247
+ return mustGet(Number(lastInsertRowid));
248
+ }),
249
+ update: (id, input) => write(input, () => {
250
+ const { changes } = change.run({ ...inputParams(input), now: now(), id });
251
+ if (Number(changes) === 0)
252
+ throw new ConnectionNotFoundError(id);
253
+ return mustGet(id);
254
+ }),
255
+ delete: (id) => Number(remove.run(id).changes) > 0,
256
+ recordTest: (id, result) => {
257
+ const { changes } = test.run({
258
+ id,
259
+ status: result.status,
260
+ message: result.message ?? null,
261
+ testedAt: result.testedAt ?? now(),
262
+ });
263
+ if (Number(changes) === 0)
264
+ throw new ConnectionNotFoundError(id);
265
+ return mustGet(id);
266
+ },
267
+ recordQuery: (entry) => {
268
+ const { lastInsertRowid } = insertHistory.run({
269
+ at: entry.at ?? now(),
270
+ envName: entry.envName,
271
+ sql: entry.sql,
272
+ durationMs: Math.round(entry.durationMs),
273
+ rowCount: entry.rowCount ?? null,
274
+ error: entry.error ?? null,
275
+ });
276
+ return toHistory(selectHistory.get(Number(lastInsertRowid)));
277
+ },
278
+ historyFor: (envName, limit = 200) => historyByEnv.all(envName, limit).map(toHistory),
279
+ savedQueries: (envName) => savedByEnv.all(envName).map(toSaved),
280
+ saveQuery: (input) => {
281
+ const { lastInsertRowid } = insertSaved.run({
282
+ envName: input.envName,
283
+ name: input.name,
284
+ folder: input.folder ?? '',
285
+ sql: input.sql,
286
+ pinned: input.pinned ? 1 : 0,
287
+ now: now(),
288
+ });
289
+ return toSaved(selectSaved.get(Number(lastInsertRowid)));
290
+ },
291
+ updateSavedQuery: (id, changes) => {
292
+ const row = selectSaved.get(id);
293
+ if (!row)
294
+ return undefined;
295
+ changeSaved.run({
296
+ id,
297
+ name: changes.name ?? row.name,
298
+ folder: changes.folder ?? row.folder,
299
+ sql: changes.sql ?? row.sql,
300
+ pinned: (changes.pinned ?? row.pinned === 1) ? 1 : 0,
301
+ now: now(),
302
+ });
303
+ return toSaved(selectSaved.get(id));
304
+ },
305
+ deleteSavedQuery: (id) => Number(removeSaved.run(id).changes) > 0,
306
+ getSetting: (key) => selectSetting.get(key)?.value,
307
+ setSetting: (key, value) => {
308
+ upsertSetting.run(key, value);
309
+ },
310
+ getSecret: (key) => selectSecret.get(key)?.value,
311
+ setSecret: (key, value) => {
312
+ upsertSecret.run(key, value, now());
313
+ },
314
+ deleteSecret: (key) => Number(removeSecret.run(key).changes) > 0,
315
+ close: () => {
316
+ // DatabaseSync#isOpen only exists from Node 22.15; engines allow 22.13.
317
+ if (closed)
318
+ return;
319
+ closed = true;
320
+ database.close();
321
+ },
322
+ };
323
+ }
@@ -0,0 +1,190 @@
1
+ import { createClient, DEFAULT_TIMEOUT_MS, sslOptions, } from './connect.js';
2
+ export { sslOptions };
3
+ /** The tester config for a saved or entered connection (SSL on means mode "require"). */
4
+ export const testerConfigFor = (input) => ({
5
+ host: input.host,
6
+ port: input.port,
7
+ database: input.database,
8
+ username: input.username,
9
+ password: input.password,
10
+ schema: input.schema,
11
+ tls: { mode: input.ssl ? 'require' : 'disable' },
12
+ });
13
+ export { DEFAULT_TIMEOUT_MS };
14
+ const CLEANUP_GRACE_MS = 1000;
15
+ class SchemaMissingError extends Error {
16
+ }
17
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
18
+ /**
19
+ * Connects to the server described by `config` with a throwaway client, reads the server version
20
+ * and checks the schema exists. Never throws: every failure becomes `{ok: false}`. The client is
21
+ * always ended, and the whole test gives up after `timeoutMs`.
22
+ */
23
+ export const testConnection = async (config, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => {
24
+ const client = createClient(config, { timeoutMs, statementTimeoutMs: timeoutMs });
25
+ const work = probe(client, config.schema);
26
+ let driverError;
27
+ work.catch((error) => {
28
+ driverError = error;
29
+ });
30
+ // Whatever happens, end the client once the work has settled.
31
+ const cleanup = work.then(() => dispose(client), () => dispose(client));
32
+ let timer;
33
+ const timedOut = new Promise((resolve) => {
34
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
35
+ });
36
+ try {
37
+ const outcome = await Promise.race([
38
+ work.then((serverVersion) => ({ serverVersion }), (error) => ({ error })),
39
+ timedOut,
40
+ ]);
41
+ if (outcome === 'timeout') {
42
+ // The driver's own timeouts (same length) end the work almost at
43
+ // once; give its cleanup a short, bounded moment so no socket is left.
44
+ await Promise.race([cleanup, delay(CLEANUP_GRACE_MS)]);
45
+ return failure('timeout', timeoutReason(timeoutMs), driverError === undefined ? `No response within ${timeoutMs} ms` : rawMessage(driverError));
46
+ }
47
+ // Do not report until the connection is really closed.
48
+ await cleanup;
49
+ if ('error' in outcome)
50
+ return describeError(outcome.error, config, timeoutMs);
51
+ return { ok: true, serverVersion: outcome.serverVersion };
52
+ }
53
+ finally {
54
+ clearTimeout(timer);
55
+ }
56
+ };
57
+ async function probe(client, schema) {
58
+ await client.connect();
59
+ const versionRows = (await client.query('SHOW server_version')).rows;
60
+ // pg_namespace sees every schema, unlike information_schema.schemata,
61
+ // which hides schemas the user has no privileges on.
62
+ const schemaRows = (await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [schema])).rows;
63
+ if (schemaRows.length === 0) {
64
+ throw new SchemaMissingError(`schema "${schema}" does not exist`);
65
+ }
66
+ return shortVersion(versionRows[0]?.server_version ?? '');
67
+ }
68
+ /** "16.4 (Debian 16.4-1.pgdg120+1)" → "16.4" */
69
+ export const shortVersion = (raw) => raw.trim().split(/\s+/)[0] ?? raw;
70
+ async function dispose(client) {
71
+ try {
72
+ await client.end();
73
+ }
74
+ catch {
75
+ // Cleanup failures must not mask the test result.
76
+ }
77
+ }
78
+ export function describeError(error, config, timeoutMs) {
79
+ const detail = rawMessage(error);
80
+ if (error instanceof SchemaMissingError) {
81
+ return failure('schema-missing', `Schema '${config.schema}' not found`, detail);
82
+ }
83
+ const code = errorCode(error);
84
+ const message = detail.toLowerCase();
85
+ if (code === 'ECONNREFUSED') {
86
+ return failure('refused', `Connection refused at ${config.host}:${config.port}`, detail);
87
+ }
88
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
89
+ return failure('host-not-found', `Host not found: ${config.host}`, detail);
90
+ }
91
+ if (code === '28P01') {
92
+ return failure('auth', `Password authentication failed for user '${config.username}'`, detail);
93
+ }
94
+ if (code === '3D000') {
95
+ return failure('database-missing', `Database '${config.database}' does not exist`, detail);
96
+ }
97
+ if (message.includes('does not support ssl')) {
98
+ return failure('ssl-unsupported', 'The server does not support SSL', detail);
99
+ }
100
+ // pg_hba.conf rejects unencrypted connections with "... no encryption".
101
+ if (config.tls.mode === 'disable' &&
102
+ (code === '28000' || code === '28P01') &&
103
+ message.includes('no encryption')) {
104
+ return failure('ssl-required', 'The server requires SSL', detail);
105
+ }
106
+ const tlsFailure = describeTlsError(code, message, detail);
107
+ if (tlsFailure)
108
+ return tlsFailure;
109
+ if (code === 'ETIMEDOUT' ||
110
+ message.includes('timeout expired') ||
111
+ message.includes('connection timeout') ||
112
+ message.includes('query read timeout')) {
113
+ return failure('timeout', timeoutReason(timeoutMs), detail);
114
+ }
115
+ return failure('unknown', 'Connection failed', detail);
116
+ }
117
+ const untrustedCodes = new Set([
118
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
119
+ 'SELF_SIGNED_CERT_IN_CHAIN',
120
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
121
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
122
+ 'CERT_UNTRUSTED',
123
+ ]);
124
+ // Certificate problems, on either side of the handshake. `message` is lower-cased.
125
+ function describeTlsError(code, message, detail) {
126
+ if (code === 'CERT_HAS_EXPIRED') {
127
+ return failure('cert-untrusted', "The server's certificate has expired", detail);
128
+ }
129
+ if (code !== undefined && untrustedCodes.has(code)) {
130
+ return failure('cert-untrusted', "The server's certificate is not trusted", detail);
131
+ }
132
+ if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
133
+ return failure('cert-hostname', "The server's certificate does not match the host", detail);
134
+ }
135
+ // A bad passphrase or unreadable key fails while the client's TLS context is built.
136
+ if (code === 'ERR_MISSING_PASSPHRASE' ||
137
+ code?.startsWith('ERR_OSSL_BAD_DECRYPT') ||
138
+ code?.startsWith('ERR_OSSL_PEM') ||
139
+ message.includes('bad decrypt') ||
140
+ message.includes('passphrase') ||
141
+ message.includes('pem routines')) {
142
+ return failure('key-passphrase', 'The client key could not be read (check its passphrase)', detail);
143
+ }
144
+ if (message.includes('certificate required') ||
145
+ message.includes('requires a valid client certificate')) {
146
+ return failure('client-cert-required', 'The server requires a client certificate', detail);
147
+ }
148
+ if (message.includes('unknown ca') ||
149
+ message.includes('bad certificate') ||
150
+ message.includes('certificate unknown') ||
151
+ message.includes('certificate authentication failed') ||
152
+ message.includes('certificate verify failed')) {
153
+ return failure('client-cert-rejected', 'The server rejected the client certificate', detail);
154
+ }
155
+ return undefined;
156
+ }
157
+ function errorCode(error) {
158
+ if (typeof error !== 'object' || error === null)
159
+ return undefined;
160
+ const { code, errors } = error;
161
+ if (typeof code === 'string')
162
+ return code;
163
+ // Node reports a host with several addresses (e.g. localhost → ::1 and
164
+ // 127.0.0.1) as an AggregateError of the per-address errors.
165
+ if (Array.isArray(errors)) {
166
+ for (const inner of errors) {
167
+ const innerCode = errorCode(inner);
168
+ if (innerCode)
169
+ return innerCode;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+ function rawMessage(error) {
175
+ if (error instanceof Error) {
176
+ const { errors } = error;
177
+ if (!error.message && Array.isArray(errors) && errors.length > 0) {
178
+ return errors.map(rawMessage).join('; ');
179
+ }
180
+ return error.message;
181
+ }
182
+ return String(error);
183
+ }
184
+ function timeoutReason(timeoutMs) {
185
+ const seconds = Number((timeoutMs / 1000).toFixed(1));
186
+ return `Timed out after ${seconds} s`;
187
+ }
188
+ function failure(kind, reason, detail) {
189
+ return { ok: false, kind, reason, detail };
190
+ }
@@ -0,0 +1,99 @@
1
+ import { isIP } from 'node:net';
2
+ export const defaultSchema = 'public';
3
+ export const defaultPort = '5432';
4
+ // Postgres truncates identifiers longer than 63 bytes (NAMEDATALEN - 1).
5
+ const maxIdentifierBytes = 63;
6
+ const maxNameLength = 64;
7
+ const maxHostLength = 253;
8
+ const hostLabel = /^[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?$/;
9
+ const isHostname = (host) => host.length <= maxHostLength &&
10
+ host
11
+ .replace(/\.$/, '')
12
+ .split('.')
13
+ .every((l) => hostLabel.test(l));
14
+ const hasControlCharacter = (value) => [...value].some((c) => c.charCodeAt(0) < 0x20);
15
+ /** Per-field rules. Each returns an error message, or undefined when the value is valid. */
16
+ export function identifierError(label, value) {
17
+ if (!value)
18
+ return `${label} is required`;
19
+ if (hasControlCharacter(value))
20
+ return `${label} cannot contain control characters`;
21
+ if (Buffer.byteLength(value) > maxIdentifierBytes) {
22
+ return `${label} must be at most ${maxIdentifierBytes} bytes`;
23
+ }
24
+ return undefined;
25
+ }
26
+ export function hostError(host) {
27
+ if (!host)
28
+ return 'Host is required';
29
+ if (!isHostname(host) && !isIP(host))
30
+ return 'Host must be a hostname or IP address';
31
+ return undefined;
32
+ }
33
+ export function portError(portText) {
34
+ if (!portText)
35
+ return 'Port is required';
36
+ const port = Number(portText);
37
+ if (!/^\d+$/.test(portText) || port < 1 || port > 65_535) {
38
+ return 'Port must be a whole number from 1 to 65535';
39
+ }
40
+ return undefined;
41
+ }
42
+ export function passwordError(password) {
43
+ if (!password)
44
+ return 'Password is required';
45
+ if (password.includes('\0'))
46
+ return 'Password cannot contain a null character';
47
+ return undefined;
48
+ }
49
+ // Validates the form and, when it is valid, returns the connection to save. Name, host, database
50
+ // and username are trimmed; the password is kept exactly as typed. A blank schema means "public".
51
+ export function validateConnection(form) {
52
+ const name = form.name.trim();
53
+ const host = form.host.trim();
54
+ const database = form.database.trim();
55
+ const username = form.username.trim();
56
+ const schema = form.schema.trim() || defaultSchema;
57
+ const portText = form.port.trim();
58
+ const port = Number(portText);
59
+ const errors = {};
60
+ if (!name)
61
+ errors.name = 'Name is required';
62
+ else if (name.length > maxNameLength)
63
+ errors.name = `Name must be at most ${maxNameLength} characters`;
64
+ else if (hasControlCharacter(name))
65
+ errors.name = 'Name cannot contain control characters';
66
+ const hostMessage = hostError(host);
67
+ if (hostMessage)
68
+ errors.host = hostMessage;
69
+ const portMessage = portError(portText);
70
+ if (portMessage)
71
+ errors.port = portMessage;
72
+ const databaseError = identifierError('Database', database);
73
+ if (databaseError)
74
+ errors.database = databaseError;
75
+ const usernameError = identifierError('Username', username);
76
+ if (usernameError)
77
+ errors.username = usernameError;
78
+ const passwordMessage = passwordError(form.password);
79
+ if (passwordMessage)
80
+ errors.password = passwordMessage;
81
+ const schemaError = form.schema.trim() ? identifierError('Schema', schema) : undefined;
82
+ if (schemaError)
83
+ errors.schema = schemaError;
84
+ if (Object.keys(errors).length > 0)
85
+ return { ok: false, errors };
86
+ return {
87
+ ok: true,
88
+ input: {
89
+ name,
90
+ host,
91
+ port,
92
+ database,
93
+ username,
94
+ password: form.password,
95
+ schema,
96
+ ssl: form.ssl ?? false,
97
+ },
98
+ };
99
+ }