transom 0.0.0-stage → 0.1.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.
package/dist/cli.js ADDED
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import http from "http";
5
+ import fs3 from "fs";
6
+ import path2 from "path";
7
+ import crypto from "crypto";
8
+ import { spawn } from "child_process";
9
+ import { fileURLToPath } from "url";
10
+
11
+ // src/postgres.ts
12
+ import pg from "pg";
13
+ import os from "os";
14
+ import fs from "fs";
15
+ for (const oid of [1082, 1083, 1114, 1184, 1266, 20, 1700]) pg.types.setTypeParser(oid, (v) => v);
16
+ var pool = null;
17
+ var current = {};
18
+ var serverVersion = "";
19
+ var q = (id) => pg.escapeIdentifier(id);
20
+ function config(c) {
21
+ const base = c.url ? { connectionString: c.url } : {
22
+ host: c.host || void 0,
23
+ port: c.port || 5432,
24
+ user: c.user || os.userInfo().username,
25
+ password: c.password || void 0,
26
+ database: c.database || "postgres",
27
+ ssl: c.ssl ? { rejectUnauthorized: false } : void 0
28
+ };
29
+ return { ...base, max: 4, connectionTimeoutMillis: 5e3, statement_timeout: 15e3, application_name: "transom" };
30
+ }
31
+ async function connect(c) {
32
+ const next = new pg.Pool(config(c));
33
+ try {
34
+ const { rows: rows2 } = await next.query(
35
+ `select current_database() as database, current_user as user, current_setting('server_version') as version,
36
+ inet_server_addr()::text as addr, current_setting('port') as port`
37
+ );
38
+ await pool?.end().catch(() => {
39
+ });
40
+ pool = next;
41
+ current = c;
42
+ serverVersion = String(rows2[0].version ?? "").split(" ")[0];
43
+ return { ...rows2[0], host: c.url ? new URL(c.url).hostname || "localhost" : c.host || "localhost", databases: await databases() };
44
+ } catch (e) {
45
+ await next.end().catch(() => {
46
+ });
47
+ throw e;
48
+ }
49
+ }
50
+ async function switchDatabase(database) {
51
+ if (current.url) {
52
+ const u = new URL(current.url);
53
+ u.pathname = "/" + encodeURIComponent(database);
54
+ return connect({ ...current, url: u.toString() });
55
+ }
56
+ return connect({ ...current, database });
57
+ }
58
+ function connected() {
59
+ return pool !== null;
60
+ }
61
+ function db() {
62
+ if (!pool) throw Object.assign(new Error("Not connected"), { status: 409 });
63
+ return pool;
64
+ }
65
+ async function databases() {
66
+ const { rows: rows2 } = await db().query(
67
+ `select datname as name, pg_database_size(datname)::bigint::text as size
68
+ from pg_database where not datistemplate and datallowconn order by datname`
69
+ );
70
+ return rows2;
71
+ }
72
+ async function detectLocal() {
73
+ const candidates = [];
74
+ for (const dir of ["/tmp", "/var/run/postgresql"]) {
75
+ if (fs.existsSync(`${dir}/.s.PGSQL.5432`)) candidates.push({ host: dir, port: 5432 });
76
+ }
77
+ candidates.push({ host: "localhost", port: 5432 });
78
+ for (const c of candidates) {
79
+ const probe = new pg.Client({ ...config({ ...c, database: "postgres" }), connectionTimeoutMillis: 1500 });
80
+ try {
81
+ await probe.connect();
82
+ const { rows: rows2 } = await probe.query(
83
+ `select datname as name, pg_database_size(datname)::bigint::text as size
84
+ from pg_database where not datistemplate and datallowconn order by datname`
85
+ );
86
+ const v = await probe.query(`select current_setting('server_version') as version`);
87
+ return {
88
+ found: true,
89
+ host: c.host,
90
+ port: c.port,
91
+ user: os.userInfo().username,
92
+ version: String(v.rows[0].version).split(" ")[0],
93
+ databases: rows2
94
+ };
95
+ } catch {
96
+ } finally {
97
+ await probe.end().catch(() => {
98
+ });
99
+ }
100
+ }
101
+ return { found: false };
102
+ }
103
+ async function tables() {
104
+ const { rows: rows2 } = await db().query(
105
+ `select n.nspname as schema, c.relname as name,
106
+ case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'matview' when 'f' then 'foreign' end as kind,
107
+ -- Small tables (< 8 MB) get an exact count; big ones keep the planner estimate and are marked as such.
108
+ case when c.relkind in ('r','p','m') and pg_total_relation_size(c.oid) < 8388608
109
+ then (xpath('/row/n/text()', query_to_xml(format('select count(*) as n from %I.%I', n.nspname, c.relname), false, true, '')))[1]::text
110
+ when c.reltuples >= 0 then c.reltuples::bigint::text else s.n_live_tup::text end as estimate,
111
+ c.relkind in ('r','p','m') and pg_total_relation_size(c.oid) < 8388608 as exact,
112
+ pg_total_relation_size(c.oid)::bigint::text as size
113
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
114
+ left join pg_stat_all_tables s on s.relid = c.oid
115
+ where c.relkind in ('r','p','v','m','f')
116
+ and n.nspname not in ('pg_catalog','information_schema') and n.nspname not like 'pg_toast%' and n.nspname not like 'pg_temp%'
117
+ and not c.relispartition
118
+ order by n.nspname, c.relname`
119
+ );
120
+ return rows2;
121
+ }
122
+ async function oidOf(schema, table) {
123
+ const { rows: rows2 } = await db().query(`select ($1::text || '.' || $2::text)::regclass::oid as oid`, [q(schema), q(table)]);
124
+ return rows2[0].oid;
125
+ }
126
+ async function describe(schema, table) {
127
+ const oid = await oidOf(schema, table);
128
+ const [columns, outgoing, incoming, indexes, meta] = await Promise.all([
129
+ db().query(
130
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory as category,
131
+ not a.attnotnull as nullable, pg_get_expr(d.adbin, d.adrelid) as default,
132
+ coalesce(a.attnum = any(pk.conkey), false) as pk, col_description(a.attrelid, a.attnum) as comment
133
+ from pg_attribute a
134
+ join pg_type t on t.oid = a.atttypid
135
+ left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
136
+ left join pg_constraint pk on pk.conrelid = a.attrelid and pk.contype = 'p'
137
+ where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped
138
+ order by a.attnum`,
139
+ [oid]
140
+ ),
141
+ // Single-column foreign keys are the ones we can make clickable; composite ones are still listed.
142
+ db().query(
143
+ `select con.conname as name,
144
+ array(select attname::text from pg_attribute where attrelid = con.conrelid and attnum = any(con.conkey) order by array_position(con.conkey, attnum)) as columns,
145
+ fn.nspname as ref_schema, f.relname as ref_table,
146
+ array(select attname::text from pg_attribute where attrelid = con.confrelid and attnum = any(con.confkey) order by array_position(con.confkey, attnum)) as ref_columns
147
+ from pg_constraint con
148
+ join pg_class f on f.oid = con.confrelid join pg_namespace fn on fn.oid = f.relnamespace
149
+ where con.contype = 'f' and con.conrelid = $1 order by con.conname`,
150
+ [oid]
151
+ ),
152
+ db().query(
153
+ `select con.conname as name, sn.nspname as schema, s.relname as table,
154
+ array(select attname::text from pg_attribute where attrelid = con.conrelid and attnum = any(con.conkey) order by array_position(con.conkey, attnum)) as columns,
155
+ array(select attname::text from pg_attribute where attrelid = con.confrelid and attnum = any(con.confkey) order by array_position(con.confkey, attnum)) as ref_columns
156
+ from pg_constraint con
157
+ join pg_class s on s.oid = con.conrelid join pg_namespace sn on sn.oid = s.relnamespace
158
+ where con.contype = 'f' and con.confrelid = $1 order by s.relname, con.conname`,
159
+ [oid]
160
+ ),
161
+ db().query(
162
+ `select i.relname as name, pg_get_indexdef(ix.indexrelid) as definition, ix.indisunique as unique, ix.indisprimary as primary,
163
+ pg_relation_size(ix.indexrelid)::bigint::text as size
164
+ from pg_index ix join pg_class i on i.oid = ix.indexrelid
165
+ where ix.indrelid = $1 order by ix.indisprimary desc, i.relname`,
166
+ [oid]
167
+ ),
168
+ db().query(
169
+ `select c.reltuples::bigint::text as estimate, pg_total_relation_size(c.oid)::bigint::text as size,
170
+ obj_description(c.oid, 'pg_class') as comment,
171
+ case c.relkind when 'v' then 'view' when 'm' then 'matview' when 'f' then 'foreign' else 'table' end as kind
172
+ from pg_class c where c.oid = $1`,
173
+ [oid]
174
+ )
175
+ ]);
176
+ const m = meta.rows[0];
177
+ let count = String(Math.max(Number(m.estimate), 0));
178
+ let exact = false;
179
+ const estimate = Number(m.estimate);
180
+ if (estimate >= 0 && estimate < 2e5 || estimate < 0 && Number(m.size) < 64 * 1024 * 1024) {
181
+ const r = await db().query(`select count(*)::text as n from ${q(schema)}.${q(table)}`);
182
+ count = r.rows[0].n;
183
+ exact = true;
184
+ }
185
+ return {
186
+ schema,
187
+ name: table,
188
+ kind: m.kind,
189
+ comment: m.comment,
190
+ size: m.size,
191
+ count,
192
+ exact,
193
+ columns: columns.rows,
194
+ outgoing: outgoing.rows,
195
+ incoming: incoming.rows,
196
+ indexes: indexes.rows
197
+ };
198
+ }
199
+ var OPS = {
200
+ eq: "=",
201
+ neq: "<>",
202
+ gt: ">",
203
+ gte: ">=",
204
+ lt: "<",
205
+ lte: "<="
206
+ };
207
+ async function rows(opts) {
208
+ const { schema, table } = opts;
209
+ const limit = Math.min(Math.max(Number(opts.limit) || 100, 1), 1e3);
210
+ const offset = Math.max(Number(opts.offset) || 0, 0);
211
+ const cols = new Set(
212
+ (await db().query(
213
+ `select attname from pg_attribute where attrelid = $1 and attnum > 0 and not attisdropped`,
214
+ [await oidOf(schema, table)]
215
+ )).rows.map((r) => r.attname)
216
+ );
217
+ const params = [];
218
+ const where = [];
219
+ for (const f of opts.filters ?? []) {
220
+ if (!cols.has(f.column)) throw Object.assign(new Error(`Unknown column ${f.column}`), { status: 400 });
221
+ const c = q(f.column);
222
+ if (f.op === "null") where.push(`${c} is null`);
223
+ else if (f.op === "notnull") where.push(`${c} is not null`);
224
+ else if (f.op === "contains") {
225
+ params.push(`%${f.value ?? ""}%`);
226
+ where.push(`${c}::text ilike $${params.length}`);
227
+ } else if (OPS[f.op]) {
228
+ params.push(f.value ?? "");
229
+ where.push(`${c} ${OPS[f.op]} $${params.length}`);
230
+ } else throw Object.assign(new Error(`Unknown operator ${f.op}`), { status: 400 });
231
+ }
232
+ let order = "";
233
+ if (opts.sort && cols.has(opts.sort.column)) order = ` order by ${q(opts.sort.column)} ${opts.sort.dir === "desc" ? "desc" : "asc"} nulls last`;
234
+ const whereSql = where.length ? ` where ${where.join(" and ")}` : "";
235
+ const from = `${q(schema)}.${q(table)}`;
236
+ const started = performance.now();
237
+ const res = await db().query({
238
+ text: `select * from ${from}${whereSql}${order} limit ${limit + 1} offset ${offset}`,
239
+ values: params
240
+ });
241
+ const filteredCount = where.length ? (await db().query(`select count(*)::text as n from ${from}${whereSql}`, params)).rows[0].n : null;
242
+ return {
243
+ fields: res.fields.map((f) => f.name),
244
+ rows: res.rows.slice(0, limit).map(serializeRow),
245
+ hasMore: res.rows.length > limit,
246
+ filteredCount,
247
+ ms: Math.round(performance.now() - started)
248
+ };
249
+ }
250
+ async function relations() {
251
+ const { rows: rows2 } = await db().query(
252
+ `select con.conname as name, sn.nspname as schema, s.relname as table, fn.nspname as ref_schema, f.relname as ref_table,
253
+ array(select attname::text from pg_attribute where attrelid = con.conrelid and attnum = any(con.conkey) order by array_position(con.conkey, attnum)) as columns,
254
+ array(select attname::text from pg_attribute where attrelid = con.confrelid and attnum = any(con.confkey) order by array_position(con.confkey, attnum)) as ref_columns
255
+ from pg_constraint con
256
+ join pg_class s on s.oid = con.conrelid join pg_namespace sn on sn.oid = s.relnamespace
257
+ join pg_class f on f.oid = con.confrelid join pg_namespace fn on fn.oid = f.relnamespace
258
+ where con.contype = 'f' and con.conparentid = 0 and not s.relispartition
259
+ and sn.nspname not in ('pg_catalog','information_schema') and sn.nspname not like 'pg_toast%' and sn.nspname not like 'pg_temp%'
260
+ order by sn.nspname, s.relname, con.conname`
261
+ );
262
+ return rows2;
263
+ }
264
+ var NUMBERS = /* @__PURE__ */ new Set(["int2", "int4", "int8", "float4", "float8", "numeric", "money", "oid"]);
265
+ var EXACT_BYTES = 32 * 1024 * 1024;
266
+ var SAMPLE_BYTES = 16 * 1024 * 1024;
267
+ var BUCKETS = 24;
268
+ var CHARTED = 60;
269
+ async function profile(schema, table) {
270
+ const started = performance.now();
271
+ const oid = await oidOf(schema, table);
272
+ const { rows: [meta] } = await db().query(
273
+ `select c.relkind as kind, c.reltuples::bigint::text as estimate,
274
+ (case when c.relkind = 'p' then (select coalesce(sum(pg_table_size(relid)), 0) from pg_partition_tree(c.oid))
275
+ else pg_table_size(c.oid) end)::bigint::text as bytes
276
+ from pg_class c where c.oid = $1`,
277
+ [oid]
278
+ );
279
+ const { rows: cols } = await db().query(
280
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory as category, t.typname
281
+ from pg_attribute a join pg_type t on t.oid = a.atttypid
282
+ where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum`,
283
+ [oid]
284
+ );
285
+ const { rows: fkRows } = await db().query(
286
+ `select a.attname as name from pg_constraint con join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
287
+ where con.conrelid = $1 and con.contype = 'f' and array_length(con.conkey, 1) = 1`,
288
+ [oid]
289
+ );
290
+ const fkCols = new Set(fkRows.map((f) => f.name));
291
+ const { rows: keyRows } = await db().query(
292
+ `select a.attname as name from pg_index ix join pg_attribute a on a.attrelid = ix.indrelid and a.attnum = ix.indkey[0]
293
+ where ix.indrelid = $1 and ix.indisunique and ix.indnatts = 1`,
294
+ [oid]
295
+ );
296
+ const keyCols = new Set(keyRows.map((k) => k.name));
297
+ const rel = `${q(schema)}.${q(table)}`;
298
+ const bytes = Number(meta.bytes);
299
+ const estimate = Number(meta.estimate);
300
+ const sampleable = ["r", "m", "p"].includes(meta.kind);
301
+ let method = sampleable ? "exact" : "head";
302
+ let percent = 100;
303
+ if (sampleable && (bytes > EXACT_BYTES || estimate > 2e5)) {
304
+ percent = Math.max(0.01, SAMPLE_BYTES / Math.max(bytes, 1) * 100);
305
+ if (percent < 100) method = "sample";
306
+ else percent = 100;
307
+ }
308
+ const source = () => method === "head" ? `(select * from ${rel} limit 100000) h` : method === "sample" ? `${rel} tablesample system (${percent.toFixed(4)}) repeatable (7)` : rel;
309
+ const finite = (x) => `${x} > '-Infinity'::float8 and ${x} < 'Infinity'::float8`;
310
+ const axis = cols.map((c) => {
311
+ const col = q(c.name);
312
+ if (c.category === "N" && !NUMBERS.has(c.typname)) return null;
313
+ if (c.category === "N") return c.typname === "money" ? `${col}::numeric::float8` : `${col}::text::float8`;
314
+ if (c.category === "D" || c.category === "T") return `extract(epoch from ${col})::float8`;
315
+ return null;
316
+ });
317
+ const select = ["count(*)::bigint::text as n"];
318
+ cols.forEach((c, i) => {
319
+ const col = q(c.name);
320
+ const key = ["S", "A", "U"].includes(c.category) ? `md5(${col}::text)` : `${col}::text`;
321
+ select.push(`count(${col})::bigint::text as n${i}`, `count(distinct ${key})::bigint::text as d${i}`);
322
+ const x = axis[i];
323
+ if (x) select.push(`(min(${col}) filter (where ${finite(x)}))::text as lo${i}`, `(max(${col}) filter (where ${finite(x)}))::text as hi${i}`, `min(${x}) filter (where ${finite(x)}) as xl${i}`, `max(${x}) filter (where ${finite(x)}) as xh${i}`);
324
+ if (c.category === "S") select.push(`min(length(${col}::text)) as ll${i}`, `max(length(${col}::text)) as lh${i}`);
325
+ });
326
+ let r;
327
+ try {
328
+ r = (await db().query(`select ${select.join(", ")} from ${source()}`)).rows[0];
329
+ } catch (e) {
330
+ if (e.code !== "57014" || method !== "exact" || !sampleable) throw friendlyTimeout(e);
331
+ method = "sample";
332
+ percent = 10;
333
+ r = (await db().query(`select ${select.join(", ")} from ${source()}`).catch((e2) => {
334
+ throw friendlyTimeout(e2);
335
+ })).rows[0];
336
+ }
337
+ const scanned = Number(r.n);
338
+ const columns = await Promise.all(cols.map(async (c, i) => {
339
+ const nonNull = Number(r[`n${i}`]);
340
+ const distinct = Number(r[`d${i}`]);
341
+ const x = axis[i];
342
+ const xl = x ? r[`xl${i}`] : null;
343
+ const xh = x ? r[`xh${i}`] : null;
344
+ const out = {
345
+ name: c.name,
346
+ type: c.type,
347
+ category: c.category,
348
+ nonNull,
349
+ distinct,
350
+ min: x ? r[`lo${i}`] : null,
351
+ max: x ? r[`hi${i}`] : null,
352
+ minLength: c.category === "S" ? r[`ll${i}`] : null,
353
+ maxLength: c.category === "S" ? r[`lh${i}`] : null,
354
+ hist: null,
355
+ top: null
356
+ };
357
+ if (i >= CHARTED || nonNull === 0) return out;
358
+ const col = q(c.name);
359
+ try {
360
+ const unique = distinct === nonNull;
361
+ const sequence = unique && ["int2", "int4", "int8"].includes(c.typname) && xl !== null && xh !== null && xh - xl + 1 === distinct;
362
+ const blob = c.category === "S" && (out.maxLength ?? 0) > 200;
363
+ if (x && !fkCols.has(c.name) && !keyCols.has(c.name) && !sequence && xl !== null && xh !== null && xl < xh && distinct > 12) {
364
+ const n = ["int2", "int4", "int8"].includes(c.typname) && xh - xl < BUCKETS ? xh - xl + 1 : BUCKETS;
365
+ const top = n === BUCKETS ? xh : xh + 1;
366
+ const { rows: rows2 } = await db().query(
367
+ `select least(width_bucket(${x}, $1, $2, ${n}), ${n}) as b, count(*)::int as n
368
+ from ${source()} where ${finite(x)} group by 1 order by 1`,
369
+ [xl, top]
370
+ );
371
+ const counts = Array(n).fill(0);
372
+ for (const row of rows2) counts[row.b - 1] = row.n;
373
+ out.hist = { lo: xl, hi: top, counts };
374
+ } else if (!blob && !unique && (fkCols.has(c.name) || distinct <= 12 || distinct < nonNull * 0.9)) {
375
+ const { rows: rows2 } = await db().query(
376
+ `select min(left(${col}::text, 120)) as value, count(*)::int as count
377
+ from ${source()} where ${col} is not null group by md5(${col}::text) order by 2 desc, 1 limit 5`
378
+ );
379
+ out.top = rows2;
380
+ }
381
+ } catch {
382
+ }
383
+ return out;
384
+ }));
385
+ return {
386
+ method,
387
+ percent: method === "sample" ? percent : 100,
388
+ scanned,
389
+ estimate: method === "exact" ? scanned : estimate >= 0 ? estimate : null,
390
+ charted: Math.min(cols.length, CHARTED),
391
+ ms: Math.round(performance.now() - started),
392
+ columns
393
+ };
394
+ }
395
+ function friendlyTimeout(e) {
396
+ if (e?.code === "57014") return Object.assign(new Error("Profiling stopped after 15 seconds."), { status: 408, hint: "This view or table is too slow to read in full. Query it in SQL with a LIMIT instead." });
397
+ return e;
398
+ }
399
+ async function references(schema, table, row) {
400
+ const { incoming } = await describe(schema, table);
401
+ return Promise.all(
402
+ incoming.filter((fk) => fk.columns.length === 1).map(async (fk) => {
403
+ const value = row[fk.ref_columns[0]];
404
+ if (value === null || value === void 0) return { ...fk, count: "0" };
405
+ const r = await db().query(
406
+ `select count(*)::text as n from ${q(fk.schema)}.${q(fk.table)} where ${q(fk.columns[0])} = $1`,
407
+ [value]
408
+ );
409
+ return { ...fk, count: r.rows[0].n };
410
+ })
411
+ );
412
+ }
413
+ var ESCAPES = /\b(set_config|query_to_xml\w*|cursor_to_xml\w*|dblink\w*|ts_stat|ts_rewrite)\s*\(|^\s*do\b|\bu&/i;
414
+ var stripLiterals = (sql) => sql.replace(/--[^\n]*|\/\*[\s\S]*?\*\//g, " ").replace(/\$(\w*)\$[\s\S]*?\$\1\$|'(?:[^']|'')*'/g, "''").replace(/"/g, "");
415
+ function guard(sql) {
416
+ const m = stripLiterals(sql).match(ESCAPES);
417
+ if (m) throw Object.assign(new Error(`${m[0].replace(/\s*\($/, "").trim()} isn't allowed in the read-only SQL tab.`), { status: 400 });
418
+ }
419
+ var NUMERIC_TYPES = /* @__PURE__ */ new Set([21, 23, 20, 26, 700, 701, 790, 1700]);
420
+ async function query(sql) {
421
+ guard(sql);
422
+ const client = await db().connect();
423
+ const started = performance.now();
424
+ try {
425
+ await client.query(`begin read only; set local statement_timeout = '30s'`);
426
+ const { rows: [r] } = await client.query(
427
+ `select rolsuper, exists (select 1 from pg_roles where rolname = 'pg_read_all_data') and pg_has_role('pg_read_all_data', 'member') as can_drop
428
+ from pg_roles where rolname = current_user`
429
+ );
430
+ if (r?.can_drop) await client.query("set local role pg_read_all_data");
431
+ else if (r?.rolsuper) throw Object.assign(new Error("The SQL tab needs Postgres 14 or later when connected as a superuser, so queries can run without superuser rights."), { status: 400 });
432
+ const last = await client.query({ text: sql, rowMode: "array", queryMode: "extended" });
433
+ return {
434
+ command: last?.command ?? null,
435
+ fields: last?.fields?.map((f) => f.name) ?? [],
436
+ numeric: last?.fields?.map((f) => NUMERIC_TYPES.has(f.dataTypeID)) ?? [],
437
+ rows: (last?.rows ?? []).slice(0, 5e3).map((r2) => r2.map(serialize)),
438
+ rowCount: last?.rowCount ?? 0,
439
+ truncated: (last?.rows?.length ?? 0) > 5e3,
440
+ ms: Math.round(performance.now() - started)
441
+ };
442
+ } finally {
443
+ await client.query("rollback").catch(() => {
444
+ });
445
+ client.release();
446
+ }
447
+ }
448
+ function serialize(v) {
449
+ if (Buffer.isBuffer(v)) return "\\x" + v.toString("hex").slice(0, 200) + (v.length > 100 ? "\u2026" : "");
450
+ if (v instanceof Date) return v.toISOString();
451
+ return v;
452
+ }
453
+ function serializeRow(r) {
454
+ const out = {};
455
+ for (const k in r) out[k] = serialize(r[k]);
456
+ return out;
457
+ }
458
+ function describeConnection() {
459
+ const c = current;
460
+ if (c.url) {
461
+ const u = new URL(c.url);
462
+ return { host: u.hostname, port: u.port || "5432", user: decodeURIComponent(u.username), database: u.pathname.slice(1), version: serverVersion };
463
+ }
464
+ return { host: c.host || "localhost", port: String(c.port || 5432), user: c.user || os.userInfo().username, database: c.database || "postgres", version: serverVersion };
465
+ }
466
+ async function disconnect() {
467
+ await pool?.end().catch(() => {
468
+ });
469
+ pool = null;
470
+ }
471
+
472
+ // src/settings.ts
473
+ import fs2 from "fs";
474
+ import os2 from "os";
475
+ import path from "path";
476
+ var DEFAULTS = { mode: "system", palette: "paper", sidebar: "solid", radius: "none", density: "comfortable", font: "figtree", rail: "expanded" };
477
+ var ALLOWED = {
478
+ mode: ["system", "light", "dark"],
479
+ palette: ["paper", "slate", "moss", "plum"],
480
+ sidebar: ["solid", "light", "minimal"],
481
+ radius: ["none", "soft", "round"],
482
+ density: ["comfortable", "compact"],
483
+ font: ["figtree", "inter", "system"],
484
+ rail: ["expanded", "collapsed"]
485
+ };
486
+ var file = path.join(process.env.XDG_CONFIG_HOME || path.join(os2.homedir(), ".config"), "transom", "settings.json");
487
+ function sanitize(raw) {
488
+ const out = { ...DEFAULTS };
489
+ if (raw && typeof raw === "object")
490
+ for (const [k, allowed] of Object.entries(ALLOWED)) {
491
+ const v = raw[k];
492
+ if (typeof v === "string" && allowed.includes(v)) out[k] = v;
493
+ }
494
+ return out;
495
+ }
496
+ function readSettings() {
497
+ try {
498
+ return sanitize(JSON.parse(fs2.readFileSync(file, "utf8")));
499
+ } catch {
500
+ return { ...DEFAULTS };
501
+ }
502
+ }
503
+ function writeSettings(raw) {
504
+ const s = sanitize(raw);
505
+ fs2.mkdirSync(path.dirname(file), { recursive: true });
506
+ fs2.writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
507
+ return s;
508
+ }
509
+
510
+ // src/cli.ts
511
+ var args = process.argv.slice(2);
512
+ var flag = (name) => args.includes(`--${name}`);
513
+ var opt = (name) => {
514
+ const i = args.indexOf(`--${name}`);
515
+ return i >= 0 ? args[i + 1] : void 0;
516
+ };
517
+ if (flag("help") || flag("h")) {
518
+ console.log(`
519
+ transom \u2014 a calm Postgres viewer in your browser
520
+
521
+ Usage
522
+ npx transom auto-detect local Postgres and open the browser
523
+ npx transom postgres://user@host/db connect straight to a database
524
+
525
+ Options
526
+ --port <n> port to listen on (default: a free one)
527
+ --no-open don't open the browser
528
+ --help show this help
529
+ `);
530
+ process.exit(0);
531
+ }
532
+ var dev = flag("dev");
533
+ var token = dev ? "dev" : crypto.randomBytes(18).toString("base64url");
534
+ var uiDir = path2.join(path2.dirname(fileURLToPath(import.meta.url)), "ui");
535
+ var initialUrl = args.find((a) => /^postgres(ql)?:\/\//.test(a));
536
+ var MIME = {
537
+ ".html": "text/html; charset=utf-8",
538
+ ".js": "text/javascript",
539
+ ".css": "text/css",
540
+ ".svg": "image/svg+xml",
541
+ ".woff2": "font/woff2",
542
+ ".woff": "font/woff",
543
+ ".png": "image/png",
544
+ ".ico": "image/x-icon",
545
+ ".json": "application/json"
546
+ };
547
+ var routes = {
548
+ "GET /api/detect": () => detectLocal(),
549
+ "GET /api/session": async () => connected() ? { connected: true, connection: describeConnection(), databases: await databases() } : { connected: false },
550
+ "POST /api/connect": (b) => connect(b),
551
+ "POST /api/disconnect": () => disconnect(),
552
+ "POST /api/database": (b) => switchDatabase(String(b.database)),
553
+ "GET /api/tables": () => tables(),
554
+ "GET /api/table": (_b, u) => describe(u.searchParams.get("schema"), u.searchParams.get("name")),
555
+ "POST /api/rows": (b) => rows(b),
556
+ "POST /api/references": (b) => references(b.schema, b.table, b.row),
557
+ "GET /api/relations": () => relations(),
558
+ "POST /api/profile": (b) => profile(String(b.schema), String(b.table)),
559
+ "POST /api/query": (b) => query(String(b.sql ?? "")),
560
+ "GET /api/settings": async () => readSettings(),
561
+ "POST /api/settings": async (b) => writeSettings(b)
562
+ };
563
+ function send(res, status, data) {
564
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
565
+ res.end(JSON.stringify(data));
566
+ }
567
+ var server = http.createServer(async (req, res) => {
568
+ const url = new URL(req.url ?? "/", "http://localhost");
569
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
570
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(host)) return send(res, 403, { error: "Forbidden host" });
571
+ if (url.pathname.startsWith("/api/")) {
572
+ if (req.headers["x-transom-token"] !== token) return send(res, 401, { error: "Missing or wrong session token. Reopen the link printed in your terminal." });
573
+ const handler = routes[`${req.method} ${url.pathname}`];
574
+ if (!handler) return send(res, 404, { error: "Not found" });
575
+ let body = {};
576
+ if (req.method === "POST") {
577
+ const chunks = [];
578
+ for await (const c of req) chunks.push(c);
579
+ try {
580
+ body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
581
+ } catch {
582
+ return send(res, 400, { error: "Invalid JSON" });
583
+ }
584
+ }
585
+ try {
586
+ send(res, 200, await handler(body, url) ?? { ok: true });
587
+ } catch (e) {
588
+ send(res, e.status ?? 400, { error: e.message, code: e.code, position: e.position, detail: e.detail, hint: e.hint });
589
+ }
590
+ return;
591
+ }
592
+ let file2 = path2.join(uiDir, path2.normalize(url.pathname).replace(/^(\.\.[/\\])+/, ""));
593
+ if (!file2.startsWith(uiDir) || !fs3.existsSync(file2) || fs3.statSync(file2).isDirectory()) file2 = path2.join(uiDir, "index.html");
594
+ if (!fs3.existsSync(file2)) return send(res, 404, { error: "UI not built. Run `pnpm build`." });
595
+ if (file2.endsWith("index.html")) {
596
+ const html = fs3.readFileSync(file2, "utf8").replace(
597
+ "<!--transom:settings-->",
598
+ `<script>window.__TRANSOM_SETTINGS__=${JSON.stringify(readSettings()).replace(/</g, "\\u003c")}</script>`
599
+ );
600
+ res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-store", "referrer-policy": "no-referrer" });
601
+ return res.end(html);
602
+ }
603
+ res.writeHead(200, {
604
+ "content-type": MIME[path2.extname(file2)] ?? "application/octet-stream",
605
+ "cache-control": file2.includes(`${path2.sep}assets${path2.sep}`) ? "public, max-age=31536000, immutable" : "no-store",
606
+ "referrer-policy": "no-referrer"
607
+ });
608
+ fs3.createReadStream(file2).pipe(res);
609
+ });
610
+ if (initialUrl) await connect({ url: initialUrl }).catch((e) => console.error(` Could not connect: ${e.message}`));
611
+ var port = Number(opt("port") ?? 0);
612
+ server.listen(port, "127.0.0.1", () => {
613
+ const addr = server.address();
614
+ const link = `http://127.0.0.1:${dev ? 5173 : addr.port}/#token=${token}`;
615
+ console.log(`
616
+ transom is running
617
+
618
+ \u2192 ${link}
619
+
620
+ Only this machine can reach it. Press Ctrl+C to stop.
621
+ `);
622
+ if (!flag("no-open")) {
623
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
624
+ const cmdArgs = process.platform === "win32" ? ["/c", "start", "", link] : [link];
625
+ spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }).on("error", () => {
626
+ }).unref();
627
+ }
628
+ });
629
+ var stop = () => disconnect().finally(() => process.exit(0));
630
+ process.on("SIGINT", stop);
631
+ process.on("SIGTERM", stop);