@kazurolabs/psql-explorer 0.1.0 → 0.3.1

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 (77) hide show
  1. package/README.md +229 -18
  2. package/RELEASES.md +95 -0
  3. package/dist/cli.js +57 -13
  4. package/dist/commands/configgen.js +25 -25
  5. package/dist/commands/explore.js +86 -4
  6. package/dist/config/load.js +11 -73
  7. package/dist/config/resolve-environment.js +10 -62
  8. package/dist/config/resolvers/index.js +1 -1
  9. package/dist/config/resolvers/prompt.js +2 -2
  10. package/dist/config/resolvers/vault.js +1 -83
  11. package/dist/db/store.js +57 -0
  12. package/dist/environments.js +5 -2
  13. package/dist/explorer/alter.js +493 -0
  14. package/dist/explorer/catalog.js +31 -0
  15. package/dist/explorer/connect-entry.js +29 -18
  16. package/dist/explorer/cypher-parse.js +478 -0
  17. package/dist/explorer/cypher.js +963 -0
  18. package/dist/explorer/ddl.js +502 -0
  19. package/dist/explorer/dependents.js +222 -0
  20. package/dist/explorer/design-load.js +254 -0
  21. package/dist/explorer/design.js +45 -0
  22. package/dist/explorer/edit.js +108 -0
  23. package/dist/explorer/explain.js +44 -3
  24. package/dist/explorer/filter.js +121 -0
  25. package/dist/explorer/graph.js +679 -0
  26. package/dist/explorer/gucs.js +31 -0
  27. package/dist/explorer/profile.js +102 -0
  28. package/dist/explorer/references.js +95 -0
  29. package/dist/explorer/rows.js +100 -13
  30. package/dist/explorer/schema-diff.js +360 -0
  31. package/dist/explorer/search.js +309 -0
  32. package/dist/explorer/security.js +152 -0
  33. package/dist/explorer/seed.js +1376 -0
  34. package/dist/explorer/session.js +25 -1
  35. package/dist/explorer/stats.js +135 -0
  36. package/dist/explorer/timeout.js +13 -0
  37. package/dist/screens/environments.js +6 -3
  38. package/dist/screens/explorer.js +0 -3
  39. package/dist/screens/splash.js +4 -2
  40. package/dist/web/server.js +655 -22
  41. package/package.json +4 -1
  42. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  43. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  44. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  45. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  46. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  47. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  48. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  49. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  50. package/web/dist/assets/button-BiFx4Lyw.js +1 -0
  51. package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
  52. package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
  53. package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
  54. package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
  55. package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
  56. package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
  57. package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
  58. package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
  59. package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
  60. package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
  61. package/web/dist/assets/index-CpHnjRLv.js +10 -0
  62. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  63. package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
  64. package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
  65. package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
  66. package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
  67. package/web/dist/assets/route-7eu-iAqD.js +15 -0
  68. package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
  69. package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
  70. package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
  71. package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
  72. package/web/dist/assets/table-Drc_n1gF.js +1 -0
  73. package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
  74. package/web/dist/assets/zone-CHDOrDBF.js +5 -0
  75. package/web/dist/index.html +9 -2
  76. package/web/dist/assets/index-BMLFCtFY.js +0 -17
  77. package/web/dist/assets/index-CMXJRL9O.css +0 -2
package/README.md CHANGED
@@ -52,6 +52,228 @@ their tables, views, functions, sequences and types, and the one you pick is sho
52
52
  Every view has an address of its own, e.g. `#/staging/table/app/users`. The server listens on loopback only. From a checkout, `npm run dev:web` starts the API and Vite with hot reload together (run
53
53
  `npm run install:web` once first), and `npm run build` compiles the CLI and builds `web/dist`.
54
54
 
55
+ ### Settings on the SQL page
56
+
57
+ The **Settings** button on the SQL page sets Postgres settings (GUCs) such as `statement_timeout`,
58
+ `work_mem`, `search_path` or a custom `app.tenant_id` on the session before each Run, Explain and
59
+ export. They are kept per environment in the browser. In a read only environment the read only
60
+ settings themselves cannot be changed.
61
+
62
+ ### Seed page
63
+
64
+ Beside **SQL**, a **Seed** tab fills a table with generated rows, for building up a development
65
+ database (`#/dev/seed/app/users`). Pick a table, then a generator for each column: Faker's people,
66
+ places, companies, internet, text, numbers and dates, plus a fixed value, one of a list, a counter, a
67
+ Faker template such as `{{person.firstName}}-{{number.int(99)}}`, or an existing row of the table
68
+ a foreign key points at. Each column starts on a suggestion from its name, type and constraints
69
+ (serials, identities and `now()` defaults keep their default; enums and `CHECK (x IN (...))` lists
70
+ become "one of a list", where each allowed value can be ticked or not and given a weight, so
71
+ `shipped` can come up six times as often as `paid`; a typed list takes weights one a line), and has
72
+ its own options, a share of NULLs and a "unique" switch. Choose
73
+ how many rows (up to 100,000), a locale and optionally a seed (the same seed makes the same rows),
74
+ preview the first ten, then insert. All rows go in one transaction, so a run adds every row or
75
+ none; "Skip rows that clash" adds `ON CONFLICT DO NOTHING` instead. A unique column tries 100 times
76
+ for a value it has not made yet and then fails the run; "Skip rows that run out of unique values"
77
+ leaves that row out instead. Either skip can add a few fewer rows than asked for, and the result
78
+ says how many were skipped. Your picks for a table are kept in the browser.
79
+
80
+ Columns can depend on each other, so rows hang together. **Add condition** under a column's
81
+ generator adds "when ‹another column› is / is not / is one of / > / < / is NULL ‹value›, use ‹this
82
+ generator›" cases: for a book, `num_pages` can be 10 to 1000 when `is_digital` is true and 100 to
83
+ 2000 otherwise, and `file_mb` NULL unless it is digital. The first case that matches is used, and
84
+ the column's own generator otherwise. A template can also use another column of the same row,
85
+ e.g. `{{row.first_name}}.{{row.last_name}}@{{internet.domainName}}` for an email that matches the
86
+ name. Columns are made in the order this needs, so conditions may point either way; a loop, or a
87
+ condition on a column left on its default (whose value is not known), is refused.
88
+
89
+ Rows can take values from other tables through **Linked tables**. Each foreign key of the table is
90
+ a link already (named after its constraint), and **Link a table** adds any other table, say a
91
+ lookup table. A link gives each new row one row of its table, and every column that reads the link
92
+ reads that same row, so all the columns of a composite foreign key always point at a row that
93
+ exists. Columns read a link with "Existing row it references" (a foreign key column),
94
+ **Column of a linked table** (any link and column), or `{{link.name.column}}` in a template. A
95
+ link either picks **a random row for each new row** (optionally each row once, for a one-to-one
96
+ key) or makes **new rows for each of its rows**: set the orders link of `order_lines` that way with
97
+ 3 to 5 rows for each, and every order gets 3 to 5 lines; the number of rows to insert then follows
98
+ from the table. Several such links make rows for every combination of their rows. **Counting up for
99
+ each linked row** starts again for each of them, for line numbers 1, 2, 3 of each order.
100
+
101
+ Array columns (`text[]`, `int[]`, an array of an enum) get a list per row: **Some of a list** picks
102
+ between a min and max of distinct values from the ones you give (an enum array starts with its
103
+ labels), and any other generator is repeated for each item, with a min and max number of items
104
+ per array (1 to 3 unless you say). A fixed value written as an array literal, like `{a,b}`, is used
105
+ as it is.
106
+
107
+ The tab is only offered where the environment says `seeder: true`, which is the default for the
108
+ `development` and `local` tiers (and for local connections). A read only environment can only
109
+ preview, unless it is protected and switched to write mode.
110
+
111
+ ### Graph page
112
+
113
+ A **Graph** tab beside **Compare** draws rows and the rows linked to them through foreign keys, so
114
+ "this order, its customer, its lines and their products" can be seen at once
115
+ (`#/dev/graph/app/orders?filters=...&depth=1`). It only reads, and works on every environment. Pick a
116
+ table, narrow it with the same filters as the Rows tab, choose a depth (0 to 3) and a direction
117
+ (rows it points at, rows pointing at it, or both), and press **Draw**. Each row is a dot coloured by its
118
+ table that pushes the others apart while its links pull it towards the rows it is linked to (a
119
+ force-directed layout). The dot is named by a label from the row's data, and under it, fainter, is the
120
+ name of its table. An arrow runs from the row holding a key to the row it points at (hover for the
121
+ key's name). Click a dot to follow its links, shift-click to see its row, and click a "+37 more in
122
+ order_lines" node to load the next rows of a key. Drag a dot to move it, drag the background to move
123
+ around, scroll to zoom, and **Re-layout** lays it out afresh and fits it to the screen. The legend hides
124
+ and shows tables and keys. (The earlier layout, in columns of cards by distance, is still in the code
125
+ as `Canvas` in `web/src/components/graph-view.tsx`, but the page no longer uses it.)
126
+
127
+ A **schema map** above the graph is a small entity relationship diagram of the table's schema: a box
128
+ for each table (with the number of its rows drawn) and an arrow for each foreign key. Click a table to
129
+ leave it out of the graph, which then goes through it as it does for a join table, and click an arrow to
130
+ stop following that key; a bar offers **Draw again** (or **Undo**) to apply the changes. Clusters of
131
+ rows that are not linked to each other push each other further apart than rows of one cluster do.
132
+
133
+ **Labels and tables** sets how the drawing is named, for each kind of row in it. The first time a table
134
+ shows up it is asked about: a row is named by its primary key (the values joined for a composite key)
135
+ until other fields are picked, and several picked fields are joined too. A table can also be
136
+ **removed from the graph**, which is for join tables of many to many relationships: its rows are not
137
+ drawn, and the rows at either end are linked directly by a dashed line (named `via` the table) as if it
138
+ were not there. This also works for a chain: with `orders` and `order_lines` both removed, a customer is
139
+ linked to the products it ordered (`via orders › order_lines`). The foreign keys are followed in either
140
+ direction through up to three removed tables, with one join that gives the distinct pairs of rows, and
141
+ the shortest way to each table is used. A table that a key leads to directly is not also reached the long
142
+ way. At most 20 rows are linked to a row this way, and the graph says when that cut some off. The tables that look like join tables (two or more keys leaving their rows) are marked.
143
+ Removing or restoring a table draws the graph again. These choices, and the keys not followed, are remembered in the browser for each
144
+ environment.
145
+
146
+ The Rows tab has a **Graph** link that starts from its filters, and the row drawer a graph button that
147
+ starts from that row.
148
+ In the **SQL** tab, a result whose columns include a table's primary key gets a checkbox on each row;
149
+ tick some, pick which table they are rows of (the ones the statement names come first) and follow
150
+ **Graph N rows** to draw exactly those rows (`?keys=...` in the address). The address carries the table, filters, depth, direction and the cards that were
151
+ opened, so a copied link draws the same graph. **Export** saves the drawing as SVG or PNG, or the nodes
152
+ (with their rows) and edges as JSON.
153
+
154
+ ### Cypher page
155
+
156
+ A **Cypher** tab queries the database in a small, read only subset of Cypher, the language of Neo4j. A
157
+ label is a table and a relationship is a foreign key, so you ask for the shape of what you want rather
158
+ than writing the joins. To find products that customers buy together:
159
+
160
+ ```cypher
161
+ MATCH (a:Product)<-[]-(c:Customer)-[]->(b:Product)
162
+ WHERE a.id < b.id
163
+ RETURN a, b, count(DISTINCT c) AS customers
164
+ ORDER BY customers DESC
165
+ LIMIT 25
166
+ ```
167
+
168
+ Rows that are returned (`a`, `b`) are drawn as a graph, linked as the pattern links them, and a number the
169
+ query returns for each link (`customers`) labels it and sets its thickness. There is also a table of the
170
+ result. **Show SQL** shows the statement the query becomes, with the values written in, and how each
171
+ relationship was joined. The query is also in the address (`#/dev/cypher?q=...&run=1`).
172
+
173
+ - **Labels** name a table: `Customer`, `customers` and `OrderLine` (for `order_lines`) all work, and a
174
+ name in backticks may carry a schema, as in `` `shop.customers` ``. The environment's schema is tried first;
175
+ a name found in two other schemas must be given with one.
176
+ - **Relationships** follow a foreign key: `-[]->` from the table holding it, `<-[]-` to it, `-[]-` either
177
+ way. Tables with no key between them are joined through the shortest chain of keys through other tables
178
+ (up to three), as customers to products are through orders and order lines; the direction of the arrow
179
+ is not used for such a chain. When two keys, or two chains, are equally good, the error lists them and
180
+ a hint in the brackets picks one: the name of a key or its column (`-[:billing_address_id]-`), or a
181
+ table on the way (`-[:orders]-`). The nodes along a chain can also be written out.
182
+ A relationship cannot be named, and has no properties; paths of any length (`*`) are not supported.
183
+ - **Properties** in a node (`(c:Customer {country: 'NZ'})`) and **WHERE** narrow the rows: `=`, `<>`, `<`,
184
+ `<=`, `>`, `>=`, `IN [...]`, `IS [NOT] NULL`, `STARTS WITH`, `ENDS WITH`, `CONTAINS`, `=~` (a regular
185
+ expression for the whole value), `AND`, `OR`, `NOT`, and `toLower`, `toUpper`, `coalesce`, `abs`. Two
186
+ rows are compared with `=` and `<>` by their primary keys, which is how to leave out a row paired with
187
+ itself (`a <> b`), or to count each pair once (`a.id < b.id`).
188
+ - **RETURN** takes rows, columns (`c.name`, optionally `AS`) and `count`, `sum`, `avg`, `min`, `max` and
189
+ `collect`, which group by whatever else is returned; with `DISTINCT`, `ORDER BY`, `SKIP` and `LIMIT`.
190
+ `RETURN *` returns every named node.
191
+ - Two relationships that follow the same keys use different rows, as in Cypher (so a row is never its own
192
+ neighbour); the same row may still be matched by two nodes, which `<>` rules out.
193
+ - Not supported: anything that writes, `OPTIONAL MATCH`, `WITH`, `UNWIND`, `UNION`, parameters and
194
+ arithmetic.
195
+
196
+ Before a query runs, the SQL it becomes is **planned** (`EXPLAIN`, which runs nothing) and assessed. A
197
+ query that looks heavy shows what makes it so: a pattern in parts that nothing joins (a cross join), many
198
+ tables, grouping or sorting large tables that nothing narrows, or a plan that Postgres estimates at a high
199
+ cost or with a join producing millions of rows. A medium warning is shown with the result. A high one is
200
+ not run until **Run anyway** is pressed. A query that runs does so in a read only transaction that is
201
+ stopped after 15 seconds, and reads at most 200 rows (1000 with a `LIMIT`).
202
+
203
+ ### Build page
204
+
205
+ A **Build** tab makes a `SELECT` from a form, for when you would rather click than type
206
+ (`#/dev/build`). Pick a table to start from, then add what you need and the SQL is written beside it as
207
+ you go:
208
+
209
+ - **Join**: the tables related by a foreign key are offered first, with the columns that match already
210
+ filled in. Any other table can be joined too, on any two columns you pick: a foreign key is not
211
+ needed. A join can be inner, left, right, full or cross, take several tests (`=`, `<>`, `<`, ...), and
212
+ compare as text when the types differ. Each test says whether it follows a foreign key, and columns of
213
+ different types are flagged before Postgres refuses them.
214
+ - **Columns**: any column of any table, a count of rows, or a count, count distinct, sum, average, min
215
+ or max; a date can be cut into days, months, ... in a time zone. Columns that are not totals are
216
+ grouped by.
217
+ - **Filter**: tests that fit the column's type (contains, starts with, one of a list, between, within
218
+ the last 7 days, is empty, ...), against a value or another column, in groups of "all of" or "any
219
+ of" inside each other. A filter that is not filled in yet is left out. **Having** filters the totals.
220
+ - **Sort and limit**, and unique rows only.
221
+
222
+ **Run** shows the result with pages and export, **Edit as SQL** opens the statement in the SQL page, and
223
+ the address carries the whole form, so a link opens the same query. The form is also kept in the
224
+ browser per environment. The page only ever writes a `SELECT` (values are quoted, names are quoted when
225
+ they need it, and a statement is checked to read before it runs), so it works the same in a read only
226
+ environment. Joins, filters, grouping, sorting and a limit are covered; subqueries, `WITH` and window
227
+ functions are for the SQL page.
228
+
229
+ ### Design page
230
+
231
+ A **Design** tab makes tables from a form and changes the ones that exist, always showing the SQL it
232
+ will run (`#/dev/design/new`, and `#/dev/design/app/orders` to change `app.orders`; the table view
233
+ has an **Edit design** button). A form holds the table (schema, name, comment, unlogged, partitioning
234
+ by range, list or hash), a grid of columns (add, remove, reorder; built-in types with their sizes,
235
+ the database's enums, domains and composite types, arrays, `NOT NULL`, a default, identity, a stored
236
+ generated expression and a comment), constraints (primary key, unique, check, foreign key with its
237
+ actions, `MATCH FULL` and `DEFERRABLE`) and indexes (columns or expressions, unique, btree, hash, gin,
238
+ gist or brin, a partial `WHERE`, `INCLUDE`). Problems show as you type: a duplicate name, a foreign key
239
+ whose column types differ, a primary key on a nullable column (it becomes `NOT NULL`). The SQL is
240
+ formatted beside the form, with **Copy** and **Open in SQL**. **Create** runs it in one transaction and
241
+ names the statement that failed; afterwards **Seed it** opens the Seed page on the new table where
242
+ the environment allows seeding. A new form is kept in the browser until the table is created or cleared.
243
+
244
+ Editing loads the table into the same form and works out the `ALTER` statements from what you change:
245
+ adding, dropping and renaming columns, constraints and indexes (rows are followed by their own id, so
246
+ a rename is a `RENAME` and not a drop and an add), a changed type with a `USING` expression you can
247
+ write, defaults, `NOT NULL`, identity, comments, and renaming the table or moving it to another schema.
248
+ Partitioning, exclusion constraints and indexes with a custom operator class or ordering cannot be
249
+ changed here, and the page says which parts it leaves alone. Next to a statement are the warnings
250
+ that apply, with the table's row estimate: a type change that rewrites the table, `NOT NULL` or a new
251
+ constraint that reads every row. A foreign key or check can be added `NOT VALID` and then validated in a
252
+ second statement, and an index can be built `CONCURRENTLY`, which cannot run in a transaction and so
253
+ runs afterwards. Everything else runs in one transaction that starts with `SET LOCAL lock_timeout`
254
+ (5 seconds, editable), so a busy table fails fast instead of queueing behind other locks.
255
+
256
+ Anything that drops something (a table, column, constraint or index) lists what depends on it first
257
+ (views, foreign keys from other tables, functions), adds `CASCADE` only when ticked, and asks for two
258
+ random words before it runs, as write mode does. **Drop table** is a separate button.
259
+
260
+ The page needs `designer: true`, which defaults to on for the `development` and `local` tiers; where it
261
+ is off, or the environment is read only (unless it is protected and switched to write mode), the SQL
262
+ can only be previewed. The search box (`Cmd/Ctrl-K`) has **New table** and `edit design of orders`.
263
+
264
+ ### Compare page
265
+
266
+ A **Compare** tab shows how this environment's database is built differently from another's, for
267
+ checking that staging matches production before a release (`#/staging/compare/production`). It
268
+ compares every schema, or one, which may have another name on the other side (`shop` against
269
+ `shop_next`). Tables with their columns, indexes, constraints, triggers and row level security
270
+ policies, views, materialized views, functions, sequences, types and extensions are each listed as
271
+ differing, or only on one side; what differs is shown side by side, and view and function
272
+ definitions line by line. Owners, grants, comments, column order and the objects an extension
273
+ creates are not compared. Filter by name or by kind of difference, open either side's object, or
274
+ copy the whole comparison as text. Only the catalogs are read, so it works on read only
275
+ environments, and a password the other environment asks for is asked on this page.
276
+
55
277
  ## Commands
56
278
 
57
279
  ```sh
@@ -65,6 +287,7 @@ psql-explorer query --env staging "select count(*) from orders"
65
287
  echo "select now()" | psql-explorer query --env staging
66
288
  psql-explorer info --env staging
67
289
  psql-explorer history --env staging
290
+ psql-explorer diff --env staging --with production [--schema app] [--with-schema app_v2]
68
291
  psql-explorer configgen:file # a commented example environment file
69
292
  psql-explorer configgen:vault
70
293
  psql-explorer secret:set prod-password # for `source: stored`; asks, or reads stdin
@@ -143,8 +366,6 @@ quits (when not typing), and so does `Ctrl+C`.
143
366
  and a query reads at most `--max-rows` rows (1000 in the TUI), so a `SELECT *` on a big table
144
367
  does not fill your memory.
145
368
  - Values are shown as the text Postgres sends: no time zone conversion, no rounding.
146
- - Short-lived credentials and certificates issued by Vault are revoked when you leave the
147
- environment or the command ends.
148
369
 
149
370
  ## Configuration
150
371
 
@@ -212,6 +433,8 @@ tier: production # production | reference | demo | development | local
212
433
  protected: true # optional; default: tier is production
213
434
  readOnly: true # optional; default: protected
214
435
  confirm: true # optional; default: protected (type two random words to open it)
436
+ seeder: false # optional; default: tier is development or local (the web UI's Seed page)
437
+ designer: false # optional; default: tier is development or local (the web UI's Design page runs SQL)
215
438
  host: db.prod.internal
216
439
  port: 5432
217
440
  database: app
@@ -237,26 +460,14 @@ Any of `host`, `port`, `database`, `schema`, `username`, `password` and the TLS
237
460
  | `prompt` | asked for when connecting (no `value`), and remembered until the program ends |
238
461
  | `vault` | a key of a KV v2 secret: `value: <data path>#<key>`, e.g. `secret/data/db/prod#pw` |
239
462
 
240
- Vault can also issue the login and the client certificate:
241
-
242
- ```yaml
243
- credentials: # instead of username and password
244
- source: vault-database
245
- value: database/creds/readonly
246
- tls:
247
- mode: verify-full
248
- client: # instead of cert and key (and ca, unless given)
249
- source: vault-pki
250
- value: pki/issue/psql-explorer
251
- commonName: psql-explorer
252
- ```
253
-
254
463
  ### Vault
255
464
 
256
465
  Vault needs `VAULT_ADDR`, and a token from the first of: `VAULT_TOKEN`; an AppRole login with
257
466
  `VAULT_ROLE_ID` and `VAULT_SECRET_ID`; a JWT login with `VAULT_JWT` and `VAULT_ROLE`; the
258
- `~/.vault-token` the `vault` CLI writes on `vault login`. Secrets are read each time they are
259
- needed and never kept, so a rotated password is picked up at once. An expired token is reported
467
+ `~/.vault-token` the `vault` CLI writes on `vault login`. Only plain KV v2 secrets are read, not
468
+ dynamic ones such as the database or PKI engines. Secrets are read each time they are needed and
469
+ never kept, so a rotated password is picked up at once. A batch of queries that runs side by side
470
+ (the value search reads tables on up to 4 connections) reads them once and shares the result. An expired token is reported
260
471
  as such, with a reminder to `vault login`.
261
472
 
262
473
  ### Local connections
package/RELEASES.md ADDED
@@ -0,0 +1,95 @@
1
+ # Release notes
2
+
3
+ ## 0.3.1 (2026-11-02)
4
+
5
+ ### 2026-11-02
6
+
7
+ - Fix: Send explain as one statement in a rolled-back transaction, so text after a `;` can no longer write to a read-only environment
8
+ - Fix: Make SQL page links wait for a click before running or explaining their statement
9
+ - Fix: Run a query that cannot be paged as written only when the person pressed Run on it, and page a query that ends in `; -- comment`
10
+
11
+ ## 0.3.0 (2026-11-02)
12
+
13
+ ### 2026-11-01
14
+
15
+ - [cli] Add a --relnotes flag that prints the release notes
16
+ - Fix: Do not draw nodes for the null side of an outer join in the graph
17
+ - Fix: Leave out rows whose chosen label is null from the row graph
18
+
19
+ ## 0.2.0 (2026-11-01)
20
+
21
+ Everything built so far, from the commit summaries, oldest first. Print it with `psql-explorer --relnotes`.
22
+
23
+ ### 2026-10-28
24
+
25
+ - [cli] Initial psql-explorer CLI and TUI
26
+
27
+ ### 2026-10-29
28
+
29
+ - Database view with schema navigation and object details
30
+ - SQL editor with paged results, ERD view and themes
31
+ - Explain button describing a query plan in plain English
32
+ - Answer prompted config values in the browser
33
+ - Confirm protected environments with two random words
34
+ - Switch protected databases to write mode from the SQL box
35
+
36
+ ### 2026-10-30
37
+
38
+ - Filter, follow keys, inspect rows and profile columns
39
+ - Open a table as a pre-filled query in the SQL box
40
+ - Deep links carry the SQL statement, tab and page, and refuse unknown databases
41
+ - [tui] Serve the web UI from the TUI and show where it is running
42
+ - Fix: Keep the address bounded so a huge statement cannot stop a query from running
43
+ - Open several query tabs on the SQL page and rename them
44
+ - Copy and share buttons on the SQL page, and paste a share code to open it
45
+ - Numbered pages and a go-to-page box on results pages
46
+ - Export results, query history and saved queries, and cancel with timing
47
+ - Result charts with image export, and inline row editing in write mode
48
+ - Group by day, month and more of date columns, with a time zone setting
49
+ - Format SQL, run scripts statement by statement, and read special types
50
+ - Table stats, an explain plan tree and a dependency view
51
+ - A Seed page that fills tables with generated rows
52
+ - Fix: Only pop up questions for the environment the page is on
53
+ - Skip seeded rows that run out of unique values
54
+ - Link seeded rows to other tables, and make rows for each of their rows
55
+ - Tick and weight the allowed values of a seeded column
56
+ - Compare schemas between environments on a Compare page and with diff
57
+ - Give the dev demo environment a database with a different schema
58
+ - Docs: Plan a record graph and a table designer in TASKS2.md
59
+ - Add a designer setting for environments
60
+ - Add a graph API for rows linked by foreign keys
61
+ - Add a DDL builder for table designs
62
+ - Add a Graph page that draws rows linked by foreign keys
63
+ - Add Graph entry points, a shareable address and export
64
+
65
+ ### 2026-10-31
66
+
67
+ - Add a Design page that creates tables from a form
68
+ - Edit and drop tables from the Design page
69
+ - Docs: Document the Design page and add New table and Edit design to the search box
70
+ - Add a Cypher page that queries foreign keys as a graph, and rework the Graph page
71
+ - Add a Settings editor for GUCs on the SQL page
72
+
73
+ ### 2026-11-01
74
+
75
+ - Explain functions and column defaults when hovering SQL
76
+ - Autocomplete function names in the SQL editor
77
+ - Show parameter help for the function call being written in the SQL editor
78
+ - Update the sidebar when DDL changes the database objects
79
+ - Autocomplete column types in CREATE TABLE and split the bundle
80
+ - Autocomplete columns in the column list of CREATE INDEX
81
+ - Autocomplete tables after ON and REFERENCES and columns in more DDL
82
+ - Add an LCARS theme with console sounds, a star chart graph and block navs
83
+ - Show row level security policies on tables and the roles of the server
84
+ - Show security details for views and functions
85
+ - Add a dark variant of the standard theme
86
+ - Fix: Share the standard title styling with the dark variant
87
+ - Give each theme its own splash text
88
+ - Add a Build page that makes SELECT queries from a form
89
+ - Drop a file onto a field in the row editor
90
+ - Raise the dropped file limit to 20 MB
91
+ - Fix: Accept a dropped file in an edit request
92
+ - Add a download link for bytea fields in the row viewer
93
+ - Detect more file types and offer a gzip download for bytea
94
+ - Fix: [cli] List confirm in the configgen examples
95
+ - Add a warp field behind the environment list in the lcars theme
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { readFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { render } from 'ink';
5
6
  import meow from 'meow';
@@ -20,7 +21,7 @@ import { createWebServer, DEFAULT_WEB_PORT, listen, openBrowser } from './web/se
20
21
  // a single source of truth. It handles --help and --version (exit 0) itself.
21
22
  const cli = meow(`
22
23
  Usage
23
- $ psql-explorer start the interactive TUI
24
+ $ psql-explorer start the interactive TUI (also serves the web UI; --port picks its port)
24
25
  $ psql-explorer envs
25
26
  $ psql-explorer schemas --env <name>
26
27
  $ psql-explorer tables --env <name> [--schema <schema>]
@@ -29,6 +30,7 @@ const cli = meow(`
29
30
  $ psql-explorer query --env <name> "<sql>" (or "-", or pipe the SQL in)
30
31
  $ psql-explorer info --env <name>
31
32
  $ psql-explorer history --env <name>
33
+ $ psql-explorer diff --env <name> --with <name> [--schema <schema>] [--with-schema <schema>]
32
34
  $ psql-explorer --web [--port 7878] [--no-open] serve the web UI and open it
33
35
  $ psql-explorer web same as --web
34
36
  $ psql-explorer configgen:file
@@ -46,6 +48,9 @@ const cli = meow(`
46
48
  environment anything that would change data is refused by the server.
47
49
  info Show the server version, database size and session settings.
48
50
  history The statements run with query or the TUI, newest first.
51
+ diff Compare the structure of two environments' databases: tables,
52
+ columns, indexes, constraints, triggers, policies, views, functions,
53
+ sequences, types and extensions. All schemas, or the one --schema names.
49
54
  web Serve the web UI and its API on http://127.0.0.1:<port> until stopped.
50
55
  configgen:file Print a fully commented example environment kept in a directory.
51
56
  configgen:vault Print the same for one kept in Vault.
@@ -57,7 +62,11 @@ const cli = meow(`
57
62
  directory, or vault:<KV v2 path> such as vault:secret/data/psql-explorer.
58
63
  It holds one <environment>/explorer.yaml per environment.
59
64
  --env <name> The environment: a shared one, or a local connection by name
60
- --schema <name> tables, describe, rows: the schema (default: the environment's)
65
+ --schema <name> tables, describe, rows: the schema (default: the environment's);
66
+ diff: compare only this schema
67
+ --with <name> diff: the environment to compare --env with
68
+ --with-schema <name>
69
+ diff: the schema on the --with side, when it is named differently
61
70
  --format <f> table (default), csv or json
62
71
  --json Same as --format json
63
72
  --limit <n> rows: rows per page (default 100); history: how many (default 50)
@@ -71,6 +80,7 @@ const cli = meow(`
71
80
  --port <n> web: the port to listen on (default 7878; the next free one is used if taken)
72
81
  --help Show this help
73
82
  --version Show the version
83
+ --relnotes Show the release notes
74
84
 
75
85
  Run without a command to start the interactive TUI.
76
86
  `, {
@@ -78,6 +88,8 @@ const cli = meow(`
78
88
  flags: {
79
89
  config: { type: 'string' },
80
90
  env: { type: 'string' },
91
+ with: { type: 'string' },
92
+ withSchema: { type: 'string' },
81
93
  schema: { type: 'string' },
82
94
  format: { type: 'string' },
83
95
  json: { type: 'boolean' },
@@ -89,9 +101,21 @@ const cli = meow(`
89
101
  timeout: { type: 'number' },
90
102
  port: { type: 'number' },
91
103
  web: { type: 'boolean' },
104
+ relnotes: { type: 'boolean' },
92
105
  open: { type: 'boolean', default: true },
93
106
  },
94
107
  });
108
+ // RELEASES.md ships next to dist, like web/dist, and needs neither the database nor a config.
109
+ if (cli.flags.relnotes) {
110
+ try {
111
+ process.stdout.write(readFileSync(join(import.meta.dirname, '..', 'RELEASES.md'), 'utf8'));
112
+ process.exit(0);
113
+ }
114
+ catch {
115
+ process.stderr.write('psql-explorer: the release notes are missing from this install\n');
116
+ process.exit(1);
117
+ }
118
+ }
95
119
  // Prints an example config and needs neither the local database nor a config.
96
120
  const [command, ...args] = cli.input;
97
121
  if (command === 'configgen:file' || command === 'configgen:vault') {
@@ -163,7 +187,6 @@ if (command === 'web' || (command === undefined && cli.flags.web)) {
163
187
  // Questions are answered in the browser: the page polls /api/prompt and posts the answer.
164
188
  resolvers: defaultRegistry(undefined, prompts, store, vault),
165
189
  prompts,
166
- groups: vault.groups,
167
190
  openSession,
168
191
  };
169
192
  const server = createWebServer({
@@ -188,10 +211,8 @@ if (command === 'web' || (command === undefined && cli.flags.web)) {
188
211
  }
189
212
  process.once('SIGINT', () => {
190
213
  server.close();
191
- void vault.groups.releaseAll().finally(() => {
192
- store.close();
193
- process.exit(0);
194
- });
214
+ store.close();
215
+ process.exit(0);
195
216
  });
196
217
  await new Promise(() => { });
197
218
  }
@@ -212,7 +233,6 @@ if (command !== undefined) {
212
233
  config,
213
234
  configError,
214
235
  resolvers: defaultRegistry(undefined, prompter, store, vault),
215
- groups: vault.groups,
216
236
  openSession,
217
237
  };
218
238
  code = await runCommand({
@@ -226,8 +246,6 @@ if (command !== undefined) {
226
246
  stdinIsTTY: process.stdin.isTTY === true,
227
247
  });
228
248
  }
229
- // Credentials and certificates issued for this command end with it.
230
- await vault.groups.releaseAll();
231
249
  store.close();
232
250
  // Let piped output drain before exiting.
233
251
  await new Promise((resolve) => process.stdout.write('', resolve));
@@ -249,15 +267,41 @@ const services = {
249
267
  store,
250
268
  tester: testConnection,
251
269
  resolvers: defaultRegistry(undefined, prompts, store, vault),
252
- groups: vault.groups,
253
270
  prompts,
254
271
  openSession,
272
+ openUrl: openBrowser,
255
273
  };
256
274
  services.startup = createStartup({ services, base: cli.flags.config, vault });
275
+ // The web UI is served from the same process, so the Environments screen can say where it is.
276
+ // Questions it triggers are answered in the browser, so it gets a prompt queue of its own; the
277
+ // config is the one the splash screen loads. Failing to start it never stops the TUI.
278
+ const webPrompts = createPromptQueue();
279
+ const webServices = {
280
+ store,
281
+ tester: testConnection,
282
+ resolvers: defaultRegistry(undefined, webPrompts, store, vault),
283
+ prompts: webPrompts,
284
+ openSession,
285
+ get config() {
286
+ return services.config;
287
+ },
288
+ get configError() {
289
+ return services.configError;
290
+ },
291
+ };
292
+ const webServer = createWebServer({
293
+ services: webServices,
294
+ staticDir: join(import.meta.dirname, '..', 'web', 'dist'),
295
+ });
296
+ try {
297
+ services.webUrl = await listen(webServer, cli.flags.port ?? DEFAULT_WEB_PORT);
298
+ }
299
+ catch {
300
+ webServer.close();
301
+ }
257
302
  const app = render(_jsx(App, { version: cli.pkg.version ?? '0.0.0', fullScreen: true, services: services }),
258
303
  // The app owns the whole terminal, and the shell's screen comes back when it exits.
259
304
  { alternateScreen: true });
260
305
  await app.waitUntilExit();
261
- // Short-lived credentials and certificates issued for this session end with it.
262
- await vault.groups.releaseAll();
306
+ webServer.close();
263
307
  store.close();
@@ -12,6 +12,19 @@ protected: true
12
12
  # Open every session read only (default_transaction_read_only), so nothing run here can change
13
13
  # data. Optional, default: the value of protected.
14
14
  readOnly: true
15
+
16
+ # Opening the environment first asks for two random words to be typed back, so it is never
17
+ # entered by accident. Optional, default: the value of protected.
18
+ confirm: true
19
+
20
+ # Offer the web UI's Seed page, which fills tables with generated rows. Optional, default: true
21
+ # for the development and local tiers, false otherwise.
22
+ # seeder: false
23
+
24
+ # Let the web UI's Design page run statements that create, alter and drop tables; without it the
25
+ # page only previews the SQL. Optional, default: true for the development and local tiers, false
26
+ # otherwise.
27
+ # designer: false
15
28
  `;
16
29
  const fileEnvironment = `# One environment: <environment>/explorer.yaml below the base path.
17
30
  # Use with: psql-explorer --config <base path> (or export PSQL_EXPLORER_ENVIRONMENTS=<base path>)
@@ -28,13 +41,13 @@ database: app
28
41
  # The schema browsing starts in. Optional, default: public
29
42
  # schema: app
30
43
 
31
- # Database login. username is required unless the login comes from Vault (see the vault example).
44
+ # Database login. Required.
32
45
  username: readonly
33
46
 
34
47
  # password (default) needs a password; none means the database takes no password.
35
48
  auth: password
36
49
 
37
- # Required when auth is password. Left out when auth is none.
50
+ # Required when auth is password. Must be left out when auth is none.
38
51
  password:
39
52
  source: file
40
53
  value: ./password.txt # relative to this file's folder; the whole content, trimmed
@@ -85,34 +98,21 @@ database: app
85
98
  # The schema browsing starts in. Optional, default: public
86
99
  # schema: app
87
100
 
88
- # Short-lived login issued by Vault's database secrets engine on every connect, and revoked when
89
- # you leave the environment. It fills username and password: set neither, and auth must stay
90
- # password.
91
- credentials:
92
- source: vault-database
93
- value: database/creds/readonly
94
- # Or a fixed login instead of credentials:
95
- # username: readonly
96
- # auth: password # password (default) | none
97
- # password: # required when auth is password
98
- # source: vault
99
- # value: secret/data/db/prod#password
101
+ username: readonly
102
+ auth: password # password (default) | none; none takes no password, so leave password out
103
+ password: # required when auth is password
104
+ source: vault
105
+ value: secret/data/db/prod#password
100
106
 
101
107
  # TLS. Optional, default: mode disable
102
108
  tls:
103
109
  # disable | require | verify-ca | verify-full
104
110
  mode: verify-full
105
- # Client certificate issued by Vault's PKI engine. It fills cert and key (set neither),
106
- # and ca too unless you give one. Has no effect with mode disable.
107
- client:
108
- source: vault-pki
109
- value: pki/issue/psql-explorer
110
- commonName: psql-explorer
111
- # CA certificate (PEM text). Required for verify-ca and verify-full unless client is given.
112
- # ca:
113
- # source: vault
114
- # value: secret/data/db/prod#ca
115
- # A fixed client certificate and key instead of client. Give both or neither.
111
+ # CA certificate (PEM text). Required for verify-ca and verify-full.
112
+ ca:
113
+ source: vault
114
+ value: secret/data/db/prod#ca
115
+ # Client certificate and key, for mutual TLS. Give both or neither.
116
116
  # cert:
117
117
  # source: vault
118
118
  # value: secret/data/db/prod#cert