@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,31 @@
1
+ export class GucError extends Error {
2
+ }
3
+ // Plain (work_mem) or custom (app.tenant_id): words joined by dots.
4
+ const validName = /^[a-z_][a-z0-9_$]*(?:\.[a-z_][a-z0-9_$]*)*$/i;
5
+ // These would lift the read only protection of the environment.
6
+ const readOnlySettings = new Set(['default_transaction_read_only', 'transaction_read_only']);
7
+ /** The settings a request asked for, from `[{name, value}]`; throws a GucError for anything else. */
8
+ export function parseGucs(raw) {
9
+ if (raw === undefined || raw === null)
10
+ return [];
11
+ if (!Array.isArray(raw))
12
+ throw new GucError('settings is a list of {name, value}');
13
+ return raw.map((item) => {
14
+ if (typeof item?.name !== 'string' || typeof item.value !== 'string') {
15
+ throw new GucError('settings is a list of {name, value}');
16
+ }
17
+ const name = item.name.trim().toLowerCase();
18
+ if (!validName.test(name))
19
+ throw new GucError(`"${item.name}" is not a setting name`);
20
+ return { name, value: item.value };
21
+ });
22
+ }
23
+ /** Sets each setting for the rest of the session; the Postgres error says if one is unknown or refused. */
24
+ export async function applyGucs(session, gucs) {
25
+ for (const { name, value } of gucs) {
26
+ if (session.readOnly && readOnlySettings.has(name)) {
27
+ throw new GucError(`${name} cannot be changed: this environment is read only`);
28
+ }
29
+ await session.rows('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
30
+ }
31
+ }
@@ -0,0 +1,102 @@
1
+ // What a column holds, worked out from a sample so it stays fast on big tables.
2
+ import { quoteIdentifier } from '../db/identifiers.js';
3
+ import { qualified } from './catalog.js';
4
+ import { withStatementTimeout } from './timeout.js';
5
+ export const SAMPLE_ROWS = 10_000;
6
+ export const PROFILE_TIMEOUT_MS = 8000;
7
+ export const HISTOGRAM_BUCKETS = 10;
8
+ const numberType = /^(smallint|integer|bigint|numeric|decimal|real|double precision)\b/;
9
+ const dateType = /^(date|timestamp)\b/;
10
+ const errorCode = (error) => error?.code ?? '';
11
+ /** The size of a sample that holds about SAMPLE_ROWS rows of a table of `estimated` rows, in percent. */
12
+ export const samplePercent = (estimated) => Math.min(100, Math.max(0.0001, (SAMPLE_ROWS / estimated) * 100));
13
+ const parse = (text) => (text === null ? [] : (JSON.parse(text) ?? []));
14
+ /** Counts by bucket number (from 1), with the empty buckets filled in. */
15
+ const bucketsOf = (found) => {
16
+ const counts = Array.from({ length: HISTOGRAM_BUCKETS }, () => 0);
17
+ for (const { bucket, count } of found)
18
+ counts[Number(bucket) - 1] = Number(count);
19
+ return counts;
20
+ };
21
+ /** Profiles one column of a table or view. Throws if the column does not exist. */
22
+ export async function profileColumn(session, table, column) {
23
+ const [info] = await session.rows(`SELECT pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, c.relkind AS kind,
24
+ c.reltuples::float8 AS tuples
25
+ FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
26
+ WHERE a.attrelid = $1::regclass AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped`, [qualified(table), column]);
27
+ if (!info)
28
+ throw new Error(`no column "${column}" in ${table.schema}.${table.name}`);
29
+ const type = info.type ?? '';
30
+ const tuples = Number(info.tuples ?? -1);
31
+ const estimatedRows = tuples >= 0 ? Math.round(tuples) : null;
32
+ // A table sampled by pages is random; a view, or a table nobody has analyzed, gives its first rows.
33
+ const sampleable = info.kind === 'r' || info.kind === 'm';
34
+ let source;
35
+ let from;
36
+ if (estimatedRows !== null && estimatedRows <= SAMPLE_ROWS * 2) {
37
+ source = 'all';
38
+ from = qualified(table);
39
+ }
40
+ else if (sampleable && estimatedRows !== null) {
41
+ source = 'sample';
42
+ from = `${qualified(table)} TABLESAMPLE SYSTEM (${samplePercent(estimatedRows).toFixed(4)})`;
43
+ }
44
+ else {
45
+ source = 'first';
46
+ from = `(SELECT * FROM ${qualified(table)} LIMIT ${SAMPLE_ROWS}) AS head`;
47
+ }
48
+ const name = quoteIdentifier(column);
49
+ // The first try compares the values themselves; types that cannot be compared (json, xml,
50
+ // point...) are compared as text instead, without min, max and a histogram.
51
+ const query = (orderable) => {
52
+ const value = orderable ? 'v' : 'v::text';
53
+ const number = numberType.test(type)
54
+ ? 'v::numeric'
55
+ : dateType.test(type)
56
+ ? 'extract(epoch from v)::numeric'
57
+ : null;
58
+ const histogram = orderable && number
59
+ ? `(SELECT json_agg(json_build_object('bucket', h.bucket, 'count', h.count)) FROM (
60
+ SELECT LEAST(width_bucket(${number}, r.lo, r.hi, ${HISTOGRAM_BUCKETS}), ${HISTOGRAM_BUCKETS}) AS bucket,
61
+ count(*) AS count
62
+ FROM s, (SELECT min(${number}) AS lo, max(${number}) AS hi FROM s WHERE v IS NOT NULL) r
63
+ WHERE v IS NOT NULL AND r.hi > r.lo GROUP BY 1) h)`
64
+ : 'NULL';
65
+ return `WITH s AS MATERIALIZED (SELECT ${name} AS v FROM ${from})
66
+ SELECT (SELECT count(*) FROM s) AS total,
67
+ (SELECT count(*) FROM s WHERE v IS NULL) AS nulls,
68
+ (SELECT count(DISTINCT ${value}) FROM s) AS distinct,
69
+ (SELECT json_agg(t) FROM (
70
+ SELECT ${value}::text AS value, count(*) AS count FROM s WHERE v IS NOT NULL
71
+ GROUP BY ${value} ORDER BY 2 DESC, 1 LIMIT 10) t) AS top,
72
+ ${orderable ? '(SELECT min(v)::text FROM s)' : 'NULL'} AS min,
73
+ ${orderable ? '(SELECT max(v)::text FROM s)' : 'NULL'} AS max,
74
+ ${histogram} AS histogram`;
75
+ };
76
+ const [row] = await withStatementTimeout(session, PROFILE_TIMEOUT_MS, async () => {
77
+ try {
78
+ return await session.rows(query(true));
79
+ }
80
+ catch (error) {
81
+ // 42883: no operator or function for the type; 42804: the types do not match.
82
+ if (!['42883', '42804', '22003', '22P02'].includes(errorCode(error)))
83
+ throw error;
84
+ return session.rows(query(false));
85
+ }
86
+ });
87
+ const total = Number(row?.total ?? 0);
88
+ return {
89
+ source,
90
+ sampleRows: total,
91
+ estimatedRows,
92
+ nullFraction: total === 0 ? 0 : Number(row?.nulls ?? 0) / total,
93
+ distinct: Number(row?.distinct ?? 0),
94
+ top: parse(row?.top ?? null).map((entry) => ({
95
+ value: entry.value,
96
+ count: Number(entry.count),
97
+ })),
98
+ min: row?.min ?? null,
99
+ max: row?.max ?? null,
100
+ histogram: row?.histogram ? bucketsOf(parse(row.histogram)) : null,
101
+ };
102
+ }
@@ -0,0 +1,95 @@
1
+ // Foreign keys between tables: where a row points, and which rows point at it.
2
+ import { quoteIdentifier } from '../db/identifiers.js';
3
+ import { qualified } from './catalog.js';
4
+ import { withStatementTimeout } from './timeout.js';
5
+ const KEY_COLUMNS = `c.conname AS name, fn.nspname AS from_schema, fc.relname AS from_table,
6
+ tn.nspname AS to_schema, tc.relname AS to_table,
7
+ (SELECT json_agg(a.attname ORDER BY k.ord)
8
+ FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
9
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum) AS from_columns,
10
+ (SELECT json_agg(a.attname ORDER BY k.ord)
11
+ FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord)
12
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum) AS to_columns`;
13
+ const KEY_FROM = `FROM pg_catalog.pg_constraint c
14
+ JOIN pg_catalog.pg_class fc ON fc.oid = c.conrelid
15
+ JOIN pg_catalog.pg_namespace fn ON fn.oid = fc.relnamespace
16
+ JOIN pg_catalog.pg_class tc ON tc.oid = c.confrelid
17
+ JOIN pg_catalog.pg_namespace tn ON tn.oid = tc.relnamespace`;
18
+ const keyOf = (row) => ({
19
+ name: row.name ?? '',
20
+ from: { schema: row.from_schema ?? '', name: row.from_table ?? '' },
21
+ to: { schema: row.to_schema ?? '', name: row.to_table ?? '' },
22
+ fromColumns: JSON.parse(row.from_columns ?? '[]'),
23
+ toColumns: JSON.parse(row.to_columns ?? '[]'),
24
+ });
25
+ /** Names a foreign key across the database (constraint names are only unique within a table). */
26
+ export const foreignKeyId = (key) => JSON.stringify([key.from.schema, key.from.name, key.name]);
27
+ /** Foreign keys of the table, and those of other tables that point at it. */
28
+ export async function listForeignKeys(session, table) {
29
+ const rows = await session.rows(`SELECT ${KEY_COLUMNS}, c.conrelid = $1::regclass AS outgoing
30
+ ${KEY_FROM}
31
+ WHERE c.contype = 'f' AND (c.conrelid = $1::regclass OR c.confrelid = $1::regclass)
32
+ ORDER BY fn.nspname, fc.relname, c.conname`, [qualified(table)]);
33
+ const outgoing = [];
34
+ const incoming = [];
35
+ for (const row of rows) {
36
+ const key = keyOf(row);
37
+ // A table that points at itself is both.
38
+ if (row.outgoing === 't' || row.outgoing === 'true')
39
+ outgoing.push(key);
40
+ if (key.to.schema === table.schema && key.to.name === table.name)
41
+ incoming.push(key);
42
+ }
43
+ return { outgoing, incoming };
44
+ }
45
+ /** Every foreign key that starts or ends in the schema. */
46
+ export async function listSchemaForeignKeys(session, schema) {
47
+ const rows = await session.rows(`SELECT ${KEY_COLUMNS}
48
+ ${KEY_FROM}
49
+ WHERE c.contype = 'f' AND (fn.nspname = $1 OR tn.nspname = $1)
50
+ ORDER BY fn.nspname, fc.relname, c.conname`, [schema]);
51
+ return rows.map(keyOf);
52
+ }
53
+ export const DEFAULT_COUNT_CAP = 1000;
54
+ export const COUNT_TIMEOUT_MS = 3000;
55
+ const timedOut = (error) => error?.code === '57014';
56
+ /**
57
+ * How many rows of each referencing table point at the row of `table` with these values (by
58
+ * column name). Counting stops at `cap` per table, and a count that takes longer than
59
+ * COUNT_TIMEOUT_MS is given up, so a row in a huge child table cannot hang the page.
60
+ */
61
+ export async function countReferences(session, table, values, { cap = DEFAULT_COUNT_CAP } = {}) {
62
+ const { incoming } = await listForeignKeys(session, table);
63
+ return withStatementTimeout(session, COUNT_TIMEOUT_MS, async () => {
64
+ const counts = [];
65
+ for (const key of incoming) {
66
+ const parameters = key.toColumns.map((column) => values[column]);
67
+ // A null never matches a foreign key, so a row without the value has no children.
68
+ if (parameters.some((value) => value === undefined || value === null)) {
69
+ counts.push({ key, count: 0, more: false });
70
+ continue;
71
+ }
72
+ const where = key.fromColumns
73
+ .map((column, i) => `${quoteIdentifier(column)} = $${i + 1}`)
74
+ .join(' AND ');
75
+ try {
76
+ const [row] = await session.rows(`SELECT count(*) AS n FROM (SELECT 1 FROM ${qualified(key.from)} WHERE ${where} LIMIT ${cap + 1}) AS limited`, parameters);
77
+ const n = Number(row?.n ?? 0);
78
+ counts.push({ key, count: Math.min(n, cap), more: n > cap });
79
+ }
80
+ catch (error) {
81
+ counts.push({
82
+ key,
83
+ count: null,
84
+ more: false,
85
+ error: timedOut(error)
86
+ ? `took longer than ${COUNT_TIMEOUT_MS / 1000}s`
87
+ : error instanceof Error
88
+ ? error.message
89
+ : String(error),
90
+ });
91
+ }
92
+ }
93
+ return counts;
94
+ });
95
+ }
@@ -0,0 +1,64 @@
1
+ // Paging through a table's rows, as the Rows tab and the rows command show them.
2
+ import { quoteIdentifier } from '../db/identifiers.js';
3
+ import { qualified } from './catalog.js';
4
+ export const DEFAULT_PAGE_SIZE = 100;
5
+ export function browseSql(table, { orderBy = [], where }) {
6
+ const order = orderBy
7
+ .map(({ column, descending }) => `${quoteIdentifier(column)} ${descending ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`)
8
+ .join(', ');
9
+ const n = where?.params.length ?? 0;
10
+ return `SELECT * FROM ${qualified(table)}${where?.sql ?? ''}${order ? ` ORDER BY ${order}` : ''} LIMIT $${n + 1} OFFSET $${n + 2}`;
11
+ }
12
+ /** One page of a table's rows. */
13
+ export function browseRows(session, table, options) {
14
+ return session.run(browseSql(table, options),
15
+ // One more row than wanted, which says whether there is a next page.
16
+ [...(options.where?.params ?? []), options.limit + 1, options.offset ?? 0], { maxRows: options.limit });
17
+ }
18
+ /** The exact number of rows; this reads the whole table, so the statement timeout bounds it. */
19
+ export async function countRows(session, table) {
20
+ const [row] = await session.rows(`SELECT count(*) AS count FROM ${qualified(table)}`);
21
+ return Number(row?.count ?? 0);
22
+ }
23
+ // Statements that return rows and can stand in a FROM clause.
24
+ const readsRows = /^(?:select|with|values|table)\b/i;
25
+ // SQLSTATE 42601 (syntax error) and 0A000 (not supported): the statement cannot be wrapped.
26
+ const cannotWrap = (error) => ['42601', '0A000'].includes(error?.code ?? '');
27
+ /** The statement without its leading comments and trailing semicolons. */
28
+ const trimStatement = (sql) => sql.replace(/^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/, '').replace(/[\s;]+$/, '');
29
+ /**
30
+ * One page of any statement's rows, without the statement needing a LIMIT of its own. A query is
31
+ * wrapped in `SELECT * FROM (...) LIMIT OFFSET`, so the planner knows that few rows are wanted and
32
+ * starts returning them early, as it does for an explicit LIMIT. Other statements, and queries that
33
+ * cannot be wrapped, run as they are and give their first rows.
34
+ */
35
+ export async function runPage(session, sql, { limit, offset = 0 }) {
36
+ const statement = trimStatement(sql);
37
+ if (!readsRows.test(statement))
38
+ return session.run(sql, [], { maxRows: limit });
39
+ // The newline keeps a trailing -- comment from swallowing the closing parenthesis.
40
+ const wrapped = `SELECT * FROM (\n${statement}\n) AS page LIMIT ${limit + 1} OFFSET ${offset}`;
41
+ try {
42
+ return await session.run(wrapped, [], { maxRows: limit });
43
+ }
44
+ catch (error) {
45
+ if (!cannotWrap(error))
46
+ throw error;
47
+ return session.run(sql, [], { maxRows: limit });
48
+ }
49
+ }
50
+ /** The most rows an export reads; more than this is cut off and says so. */
51
+ export const EXPORT_MAX_ROWS = 100_000;
52
+ /** Whether a statement is one that `exportRows` can read all of. */
53
+ export const canExport = (sql) => readsRows.test(trimStatement(sql));
54
+ /**
55
+ * Up to EXPORT_MAX_ROWS rows of a query, from its first. The statement is always wrapped, so an
56
+ * export can never run anything that is not a query, and never falls back to running it as it is.
57
+ */
58
+ export function exportRows(session, sql) {
59
+ const statement = trimStatement(sql);
60
+ if (!readsRows.test(statement)) {
61
+ throw new Error('Only a query that returns rows can be exported');
62
+ }
63
+ return session.run(`SELECT * FROM (\n${statement}\n) AS export LIMIT ${EXPORT_MAX_ROWS + 1}`, [], { maxRows: EXPORT_MAX_ROWS });
64
+ }
@@ -0,0 +1,360 @@
1
+ const relationKinds = {
2
+ r: 'table',
3
+ p: 'table',
4
+ v: 'view',
5
+ m: 'materialized view',
6
+ f: 'foreign table',
7
+ };
8
+ // The order kinds are listed in: what holds other things first.
9
+ const kindOrder = [
10
+ 'extension',
11
+ 'schema',
12
+ 'type',
13
+ 'sequence',
14
+ 'table',
15
+ 'foreign table',
16
+ 'view',
17
+ 'materialized view',
18
+ 'function',
19
+ ];
20
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ /**
22
+ * The text without its own schema's name in front of the names in it, so that "app.users" and
23
+ * "app_copy.users" read the same when one schema is compared with another. Names in other schemas
24
+ * keep theirs.
25
+ */
26
+ export function unqualify(text, schema) {
27
+ const quoted = `"${schema.replaceAll('"', '""')}".`;
28
+ let out = text.split(quoted).join('');
29
+ if (/^[a-z_][a-z0-9_$]*$/.test(schema)) {
30
+ out = out.replace(new RegExp(`(?<![\\w$".])${escapeRegExp(schema)}\\.(?=[\\w"])`, 'g'), '');
31
+ }
32
+ return out;
33
+ }
34
+ // Excludes what an extension created, which its own version already accounts for.
35
+ const notInExtension = (catalog, oid) => `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend e
36
+ WHERE e.classid = '${catalog}'::regclass AND e.objid = ${oid} AND e.deptype = 'e')`;
37
+ const userSchema = (column) => `${column} !~ '^pg_' AND ${column} <> 'information_schema' AND ($1::text IS NULL OR ${column} = $1)`;
38
+ /** The structure of a database (or one schema of it) as text, for comparing with another. */
39
+ export async function snapshotSchema(session, options = {}) {
40
+ const filter = [options.schema ?? null];
41
+ // With an empty search path Postgres writes every name with its schema, the same way on both
42
+ // sides, whatever each environment's search path is. Put back afterwards for the next statement.
43
+ const [saved] = await session.rows(`SELECT current_setting('search_path') AS path, pg_catalog.set_config('search_path', '', false)`);
44
+ try {
45
+ return { objects: await readObjects(session, filter) };
46
+ }
47
+ finally {
48
+ await session.rows(`SELECT pg_catalog.set_config('search_path', $1, false)`, [
49
+ saved?.path ?? '',
50
+ ]);
51
+ }
52
+ }
53
+ async function readObjects(session, filter) {
54
+ const objects = new Map();
55
+ const add = (kind, schema, name) => {
56
+ const key = objectKey({ kind, schema, name });
57
+ let object = objects.get(key);
58
+ if (!object) {
59
+ object = { kind, schema, name, parts: {} };
60
+ objects.set(key, object);
61
+ }
62
+ return object;
63
+ };
64
+ // Parts of a table, view, ... by its schema and name, whose kind is already known.
65
+ const relations = new Map();
66
+ const partsOf = (schema, name) => relations.get(`${schema}\0${name}`);
67
+ const set = (parts, part, value, schema) => {
68
+ if (parts)
69
+ parts[part] = unqualify(value, schema ?? '');
70
+ };
71
+ for (const row of await session.rows(`SELECT n.nspname AS name FROM pg_catalog.pg_namespace n
72
+ WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_namespace', 'n.oid')}`, filter)) {
73
+ add('schema', row.name ?? '', row.name ?? '');
74
+ }
75
+ for (const row of await session.rows(`SELECT x.extname AS name, x.extversion AS version, n.nspname AS schema
76
+ FROM pg_catalog.pg_extension x JOIN pg_catalog.pg_namespace n ON n.oid = x.extnamespace`)) {
77
+ const parts = add('extension', '', row.name ?? '').parts;
78
+ parts.version = row.version ?? '';
79
+ parts.schema = row.schema ?? '';
80
+ }
81
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
82
+ CASE WHEN c.relkind IN ('v', 'm') THEN pg_catalog.pg_get_viewdef(c.oid, true) END AS definition,
83
+ CASE WHEN c.relkind = 'p' THEN pg_catalog.pg_get_partkeydef(c.oid) END AS partition_key,
84
+ (SELECT pg_catalog.format('%I.%I', pn.nspname, pc.relname)
85
+ FROM pg_catalog.pg_inherits i
86
+ JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent
87
+ JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
88
+ WHERE i.inhrelid = c.oid AND c.relispartition) AS parent,
89
+ CASE WHEN c.relispartition THEN pg_catalog.pg_get_expr(c.relpartbound, c.oid) END AS bound,
90
+ c.relrowsecurity AS rls
91
+ FROM pg_catalog.pg_class c
92
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
93
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND ${userSchema('n.nspname')}
94
+ AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
95
+ const parts = add(relationKinds[row.kind ?? ''] ?? 'table', row.schema ?? '', row.name ?? '').parts;
96
+ relations.set(`${row.schema}\0${row.name}`, parts);
97
+ if (row.definition !== null)
98
+ set(parts, 'definition', row.definition.trim(), row.schema);
99
+ if (row.partition_key !== null)
100
+ set(parts, 'partitioned by', row.partition_key, row.schema);
101
+ if (row.parent !== null)
102
+ set(parts, 'partition of', `${row.parent} ${row.bound ?? ''}`.trim(), row.schema);
103
+ if (row.rls === 't')
104
+ parts['row level security'] = 'enabled';
105
+ }
106
+ // Columns of tables and foreign tables; a view's follow from its definition.
107
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
108
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS not_null,
109
+ pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default, a.attidentity AS identity,
110
+ a.attgenerated AS generated,
111
+ CASE WHEN a.attcollation <> t.typcollation THEN
112
+ (SELECT pg_catalog.format('%I.%I', cn.nspname, co.collname) FROM pg_catalog.pg_collation co
113
+ JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace WHERE co.oid = a.attcollation)
114
+ END AS collation
115
+ FROM pg_catalog.pg_attribute a
116
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
117
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
118
+ JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
119
+ LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
120
+ WHERE c.relkind IN ('r', 'p', 'f') AND a.attnum > 0 AND NOT a.attisdropped
121
+ AND ${userSchema('n.nspname')}
122
+ ORDER BY a.attnum`, filter)) {
123
+ const words = [row.type ?? ''];
124
+ if (row.collation)
125
+ words.push(`COLLATE ${row.collation}`);
126
+ if (row.not_null === 't')
127
+ words.push('NOT NULL');
128
+ if (row.identity === 'a')
129
+ words.push('GENERATED ALWAYS AS IDENTITY');
130
+ else if (row.identity === 'd')
131
+ words.push('GENERATED BY DEFAULT AS IDENTITY');
132
+ else if (row.generated === 's')
133
+ words.push(`GENERATED ALWAYS AS (${row.default}) STORED`);
134
+ else if (row.generated === 'v')
135
+ words.push(`GENERATED ALWAYS AS (${row.default}) VIRTUAL`);
136
+ else if (row.default !== null)
137
+ words.push(`DEFAULT ${row.default}`);
138
+ set(partsOf(row.schema, row.table), `column ${row.name}`, words.join(' '), row.schema);
139
+ }
140
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, i.relname AS name,
141
+ pg_catalog.pg_get_indexdef(x.indexrelid) AS definition
142
+ FROM pg_catalog.pg_index x
143
+ JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
144
+ JOIN pg_catalog.pg_class c ON c.oid = x.indrelid
145
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
146
+ WHERE ${userSchema('n.nspname')}
147
+ ORDER BY x.indisprimary DESC, i.relname`, filter)) {
148
+ set(partsOf(row.schema, row.table), `index ${row.name}`, row.definition ?? '', row.schema);
149
+ }
150
+ // Not-null constraints (Postgres 18) are left out: the column already says NOT NULL.
151
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, k.conname AS name,
152
+ pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
153
+ FROM pg_catalog.pg_constraint k
154
+ JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
155
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
156
+ WHERE k.contype <> 'n' AND ${userSchema('n.nspname')}
157
+ ORDER BY array_position(ARRAY['p', 'u', 'f', 'c', 'x', 't']::"char"[], k.contype), k.conname`, filter)) {
158
+ set(partsOf(row.schema, row.table), `constraint ${row.name}`, row.definition ?? '', row.schema);
159
+ }
160
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name,
161
+ pg_catalog.pg_get_triggerdef(t.oid, true) AS definition, t.tgenabled AS enabled
162
+ FROM pg_catalog.pg_trigger t
163
+ JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
164
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
165
+ WHERE NOT t.tgisinternal AND ${userSchema('n.nspname')}
166
+ ORDER BY t.tgname`, filter)) {
167
+ const disabled = row.enabled === 'D' ? ' (disabled)' : '';
168
+ set(partsOf(row.schema, row.table), `trigger ${row.name}`, `${row.definition}${disabled}`, row.schema);
169
+ }
170
+ for (const row of await session.rows(`SELECT schemaname AS schema, tablename AS table, policyname AS name, permissive,
171
+ array_to_string(roles, ', ') AS roles, cmd AS command, qual, with_check
172
+ FROM pg_catalog.pg_policies
173
+ WHERE ${userSchema('schemaname')}
174
+ ORDER BY policyname`, filter)) {
175
+ const words = [`AS ${row.permissive} FOR ${row.command} TO ${row.roles}`];
176
+ if (row.qual !== null)
177
+ words.push(`USING (${row.qual})`);
178
+ if (row.with_check !== null)
179
+ words.push(`WITH CHECK (${row.with_check})`);
180
+ set(partsOf(row.schema, row.table), `policy ${row.name}`, words.join(' '), row.schema);
181
+ }
182
+ for (const row of await session.rows(`SELECT n.nspname AS schema, p.proname AS name, pg_catalog.oidvectortypes(p.proargtypes) AS args,
183
+ p.prokind AS kind,
184
+ CASE WHEN p.prokind <> 'a' THEN pg_catalog.pg_get_functiondef(p.oid) END AS definition,
185
+ pg_catalog.pg_get_function_result(p.oid) AS returns
186
+ FROM pg_catalog.pg_proc p
187
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
188
+ WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_proc', 'p.oid')}`, filter)) {
189
+ const schema = row.schema ?? '';
190
+ const name = `${row.name}(${unqualify(row.args ?? '', schema)})`;
191
+ const parts = add('function', schema, name).parts;
192
+ if (row.definition !== null)
193
+ set(parts, 'definition', row.definition.trim(), schema);
194
+ else
195
+ set(parts, 'aggregate returning', row.returns ?? '', schema);
196
+ }
197
+ for (const row of await session.rows(`SELECT s.schemaname AS schema, s.sequencename AS name, s.data_type::text AS type,
198
+ s.start_value::text AS start, s.increment_by::text AS increment, s.min_value::text AS min,
199
+ s.max_value::text AS max, s.cache_size::text AS cache, s.cycle,
200
+ (SELECT pg_catalog.format('%I.%I.%I', rn.nspname, r.relname, a.attname)
201
+ FROM pg_catalog.pg_depend d
202
+ JOIN pg_catalog.pg_class r ON r.oid = d.refobjid
203
+ JOIN pg_catalog.pg_namespace rn ON rn.oid = r.relnamespace
204
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
205
+ WHERE d.objid = c.oid AND d.classid = 'pg_catalog.pg_class'::regclass
206
+ AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype IN ('a', 'i')
207
+ LIMIT 1) AS owned_by
208
+ FROM pg_catalog.pg_sequences s
209
+ JOIN pg_catalog.pg_namespace n ON n.nspname = s.schemaname
210
+ JOIN pg_catalog.pg_class c ON c.relname = s.sequencename AND c.relnamespace = n.oid
211
+ WHERE ${userSchema('s.schemaname')} AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
212
+ const schema = row.schema ?? '';
213
+ const parts = add('sequence', schema, row.name ?? '').parts;
214
+ for (const field of ['type', 'start', 'increment', 'min', 'max', 'cache']) {
215
+ parts[field] = row[field] ?? '';
216
+ }
217
+ parts.cycle = row.cycle === 't' ? 'yes' : 'no';
218
+ if (row.owned_by !== null)
219
+ set(parts, 'owned by', row.owned_by ?? '', schema);
220
+ }
221
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS name, t.typtype AS kind,
222
+ (SELECT string_agg(pg_catalog.quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder)
223
+ FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS labels,
224
+ CASE t.typtype
225
+ WHEN 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
226
+ WHEN 'r' THEN (SELECT pg_catalog.format_type(r.rngsubtype, NULL)
227
+ FROM pg_catalog.pg_range r WHERE r.rngtypid = t.oid)
228
+ END AS base,
229
+ t.typdefault AS default, t.typnotnull AS not_null
230
+ FROM pg_catalog.pg_type t
231
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
232
+ WHERE t.typtype IN ('e', 'c', 'd', 'r') AND ${userSchema('n.nspname')}
233
+ AND (t.typtype <> 'c' OR EXISTS (
234
+ SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
235
+ AND ${notInExtension('pg_catalog.pg_type', 't.oid')}`, filter)) {
236
+ const schema = row.schema ?? '';
237
+ const parts = add('type', schema, row.name ?? '').parts;
238
+ if (row.kind === 'e')
239
+ parts['enum labels'] = row.labels ?? '';
240
+ if (row.kind === 'd') {
241
+ set(parts, 'domain of', row.base ?? '', schema);
242
+ if (row.default !== null)
243
+ set(parts, 'default', row.default, schema);
244
+ if (row.not_null === 't')
245
+ parts['not null'] = 'yes';
246
+ }
247
+ if (row.kind === 'r')
248
+ set(parts, 'range of', row.base ?? '', schema);
249
+ }
250
+ // The attributes of composite types and the constraints of domains.
251
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, a.attname AS name,
252
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_of
253
+ FROM pg_catalog.pg_type t
254
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
255
+ JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c'
256
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
257
+ WHERE ${userSchema('n.nspname')}
258
+ ORDER BY a.attnum`, filter)) {
259
+ const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
260
+ if (object)
261
+ set(object.parts, `attribute ${row.name}`, row.type_of ?? '', row.schema);
262
+ }
263
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, k.conname AS name,
264
+ pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
265
+ FROM pg_catalog.pg_constraint k
266
+ JOIN pg_catalog.pg_type t ON t.oid = k.contypid
267
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
268
+ WHERE ${userSchema('n.nspname')}
269
+ ORDER BY k.conname`, filter)) {
270
+ const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
271
+ if (object)
272
+ set(object.parts, `constraint ${row.name}`, row.definition ?? '', row.schema);
273
+ }
274
+ return [...objects.values()];
275
+ }
276
+ const objectKey = (object) => `${object.kind}\0${object.schema}\0${object.name}`;
277
+ // Beyond this many lines a side the comparison is not worth its time: all of a, then all of b.
278
+ const MAX_DIFF_LINES = 3000;
279
+ /** The lines of a and b in order, those they share once, by the longest common subsequence. */
280
+ export function diffLines(a, b) {
281
+ const left = a.split('\n');
282
+ const right = b.split('\n');
283
+ if (left.length > MAX_DIFF_LINES || right.length > MAX_DIFF_LINES) {
284
+ return [
285
+ ...left.map((text) => ({ side: 'a', text })),
286
+ ...right.map((text) => ({ side: 'b', text })),
287
+ ];
288
+ }
289
+ // common[i][j]: how many lines left[i..] and right[j..] have in common.
290
+ const common = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
291
+ for (let i = left.length - 1; i >= 0; i--) {
292
+ for (let j = right.length - 1; j >= 0; j--) {
293
+ common[i][j] =
294
+ left[i] === right[j]
295
+ ? common[i + 1][j + 1] + 1
296
+ : Math.max(common[i + 1][j], common[i][j + 1]);
297
+ }
298
+ }
299
+ const lines = [];
300
+ let i = 0;
301
+ let j = 0;
302
+ while (i < left.length || j < right.length) {
303
+ if (i < left.length && j < right.length && left[i] === right[j]) {
304
+ lines.push({ side: 'both', text: left[i] });
305
+ i++;
306
+ j++;
307
+ }
308
+ else if (j >= right.length || (i < left.length && common[i + 1][j] >= common[i][j + 1])) {
309
+ lines.push({ side: 'a', text: left[i++] });
310
+ }
311
+ else {
312
+ lines.push({ side: 'b', text: right[j++] });
313
+ }
314
+ }
315
+ return lines;
316
+ }
317
+ const partChange = (part, a, b) => a !== null && b !== null && (a.includes('\n') || b.includes('\n'))
318
+ ? { part, a, b, lines: diffLines(a, b) }
319
+ : { part, a, b };
320
+ const sortKey = (object) => `${object.schema}\0${String(kindOrder.indexOf(object.kind)).padStart(2, '0')}\0${object.name}`;
321
+ /** The objects and parts that differ between two snapshots, by schema, kind and name. */
322
+ export function diffSnapshots(a, b, options = {}) {
323
+ const { schemas } = options;
324
+ // Side b's objects named as though in side a's schema, so the two line up.
325
+ const rename = (object) => schemas && object.schema === schemas.b
326
+ ? { ...object, schema: schemas.a, name: object.kind === 'schema' ? schemas.a : object.name }
327
+ : object;
328
+ const left = new Map(a.objects.map((object) => [objectKey(object), object]));
329
+ const right = new Map(b.objects.map(rename).map((object) => [objectKey(object), object]));
330
+ const changes = [];
331
+ let same = 0;
332
+ for (const key of new Set([...left.keys(), ...right.keys()])) {
333
+ const one = left.get(key);
334
+ const other = right.get(key);
335
+ const object = (one ?? other);
336
+ const names = [
337
+ ...new Set([...Object.keys(one?.parts ?? {}), ...Object.keys(other?.parts ?? {})]),
338
+ ];
339
+ const parts = names
340
+ .filter((part) => !one || !other || one.parts[part] !== other.parts[part])
341
+ .map((part) => partChange(part, one?.parts[part] ?? null, other?.parts[part] ?? null));
342
+ const status = !other ? 'only-a' : !one ? 'only-b' : parts.length > 0 ? 'changed' : null;
343
+ if (status)
344
+ changes.push({ kind: object.kind, schema: object.schema, name: object.name, status, parts });
345
+ else
346
+ same++;
347
+ }
348
+ changes.sort((x, y) => sortKey(x).localeCompare(sortKey(y)));
349
+ return { changes, same };
350
+ }
351
+ /** Reads both sides at once (one after the other on the same session) and compares them. */
352
+ export async function compareSchemas(a, b, options = {}) {
353
+ const { schema } = options;
354
+ const withSchema = options.withSchema ?? schema;
355
+ const readA = () => snapshotSchema(a, schema ? { schema } : {});
356
+ const readB = () => snapshotSchema(b, withSchema ? { schema: withSchema } : {});
357
+ // One connection runs one statement at a time, and each read changes its search path meanwhile.
358
+ const [left, right] = a === b ? [await readA(), await readB()] : await Promise.all([readA(), readB()]);
359
+ return diffSnapshots(left, right, schema && withSchema && schema !== withSchema ? { schemas: { a: schema, b: withSchema } } : {});
360
+ }