tablewalk 0.0.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Baljinder Randhawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,553 @@
1
+ # tablewalk
2
+
3
+ **Follow the data.**
4
+
5
+ Most database tools are SQL clients that happen to render a grid. tablewalk
6
+ starts somewhere else: a foreign key is a place you can go.
7
+
8
+ ```bash
9
+ npx tablewalk # asks in the browser
10
+ npx tablewalk mydata.sqlite
11
+ npx tablewalk postgres://user:pass@localhost/appdb
12
+ npx tablewalk mydata.sqlite --export md >> CLAUDE.md # the schema, as a committed brief
13
+ npx tablewalk postgres://… --lint --fail-on high # what the shape costs, for CI
14
+ npx tablewalk --config ./tablewalk.json --diff staging # is staging the same shape?
15
+ ```
16
+
17
+ No config file, no saved connection, no schema to define first. Point it at a
18
+ database and it reads the catalog on connect.
19
+
20
+ Or point it at nothing. `npx tablewalk` on its own starts the server and the
21
+ page asks for a connection — a form that takes either a URL or its parts, tests
22
+ it before keeping it, and can read the password from an environment variable or
23
+ the keychain instead of the string. A connection added that way lasts for the
24
+ session; "Save to file…" writes it down, with any typed password moved to the
25
+ keychain rather than into the file.
26
+
27
+ The commands that print and exit still need a target, because there is nothing
28
+ for `--export`, `--lint` or `--diff` to read without one, and `--mcp` does too:
29
+ an agent on stdio has no dialog to fill in.
30
+
31
+ ## The idea
32
+
33
+ Every database client shows you tables. The interesting structure is the
34
+ edges — and specifically the direction almost nothing surfaces well:
35
+
36
+ > I am looking at this customer. **What points at it?**
37
+
38
+ Foreign keys are stored once and readable in both directions, so the answer is
39
+ already sitting in `information_schema`. tablewalk computes it for the row you
40
+ are looking at, counts the referring rows in each table, and lets you click
41
+ through to them.
42
+
43
+ That turns a database from a list into something you can walk:
44
+
45
+ ```
46
+ customer › Harbour Freight › invoice customer_id = 1 › 1001 › invoice_line …
47
+ ```
48
+
49
+ Each step is a real filter you can see and edit, and walking back restores the
50
+ filter you walked away from — which is the detail most browsers lose.
51
+
52
+ ## The query language
53
+
54
+ The bar at the top takes a small language. The table comes first, because that
55
+ is how people say it out loud; everything else is optional.
56
+
57
+ ```
58
+ customer active = true sort credit_limit desc
59
+ invoice invoice_date = last 30 days
60
+ employee title contains manager show first_name, last_name
61
+ customer country_code in (AU, NZ) and email is not empty
62
+ invoice where total >= 500 order by total desc limit 20
63
+ ```
64
+
65
+ | | |
66
+ |---|---|
67
+ | Operators | `= != < <= > >=`, `contains`, `startswith`, `endswith` (all three ignore case, on every engine), `in`, `not in` |
68
+ | Null checks | `is empty`, `is not empty` |
69
+ | Combining | `and`, `or`, parentheses |
70
+ | Dates | `today`, `yesterday`, `this week`, `last month`, `last 30 days`, `next 7 days` |
71
+ | Clauses | `filter` / `where`, `show` / `select`, `sort` / `order by`, `limit` |
72
+ | Projection | `show a, b, c` — or `select`, if that reads better to you |
73
+
74
+ Press **SQL** to see exactly what your query compiled to. The tool teaches the
75
+ database rather than hiding it.
76
+
77
+ ### Counting things by something
78
+
79
+ `by` turns a query into a breakdown — the GROUP BY question, in the same
80
+ language and with the same guarantees. Measures first, then `by`, then what to
81
+ group by:
82
+
83
+ ```
84
+ invoice count by status
85
+ invoice count, sum total as billed by month invoice_date sort billed desc limit 12
86
+ invoice where total > 100 count by customer_id.country_code.name as country
87
+ customer count distinct country_code as countries by active
88
+ ```
89
+
90
+ | | |
91
+ |---|---|
92
+ | Measures | `count`, `count <column>` (skips its nulls), `count distinct <column>`, `sum`, `avg`, `min`, `max` |
93
+ | Group keys | any column, or a reference path — up to three of them |
94
+ | Periods | `by day \| month \| quarter \| year <date column>` |
95
+ | Sorting | `sort <name>` names a column of the *answer*, key or measure |
96
+
97
+ A period comes back as a label — `2026-08`, `2026-Q3` — spelled identically on
98
+ SQLite, Postgres and MySQL, so it sorts correctly as text and means the same
99
+ thing wherever you point the tool. Agents get this as the `breakdown` tool,
100
+ which is the one to reach for before `sql`: every value is bound and every
101
+ path is checked against the catalog.
102
+
103
+ ### Why not just let people type SQL?
104
+
105
+ Because this language compiles to a *structure*, never to SQL text:
106
+
107
+ ```
108
+ customer name = "O'Brien"
109
+
110
+ { groups: [[{ column: 'name', op: '=', value: "O'Brien" }]] }
111
+
112
+ SELECT * FROM "customer" WHERE "name" = ? ["O'Brien"]
113
+ ```
114
+
115
+ There is no path from what you type to what the database parses as syntax.
116
+ Every value arrives as a bound parameter. A tool that accepted raw SQL would
117
+ have to either trust the input or write a SQL parser to distrust it; this
118
+ sidesteps both by making the dangerous shape unrepresentable.
119
+
120
+ Two things fall out of that for free:
121
+
122
+ - **The compiler holds the schema.** `customer contry_code = AU` is an error
123
+ with a suggestion, not a database exception — and not, worse, an empty
124
+ result that looks like an empty table.
125
+ - **Dates resolve to real timestamps.** `last 30 days` becomes two bound
126
+ values, identical in both dialects, visible in the explain panel.
127
+
128
+ ## One row, and everything about it
129
+
130
+ Click a row and you get a record page: the fields, and — in a rail beside
131
+ them — every table that points at this row, counted, ranked by how much there
132
+ is to read. An empty relationship is shown as a zero rather than hidden,
133
+ because "nothing points here through this" is an answer, and a rail that
134
+ quietly dropped it could not be trusted to be the whole list.
135
+
136
+ Records are named like a person would name them. A table with a `name`-like
137
+ column uses it; a table whose name lives one-to-one away — `employee` carrying
138
+ `party_id`, with the name on `party.display_name` — walks the reference and
139
+ says `employee · Maeve Lindqvist` instead of `employee · 30`. The walk is the
140
+ tool's whole idea, so it applies to headings and breadcrumbs too, and it
141
+ deliberately stops at ownership: an invoice is never named after its customer,
142
+ because forty invoices share that name and a label that identifies nothing is
143
+ worse than a key.
144
+
145
+ **Pages** go one step further: a composed view of one record — its fields,
146
+ counts of what points at it, and lists of the rows themselves — laid out in
147
+ sections you arrange. Every table gets a suggested page for free; **Layout**
148
+ opens a builder where sections are added, hidden, filtered, resized and
149
+ dragged into order, fields dragged into groups, and a to-one reference pulled
150
+ in as its own block of fields. Values on a page render through the same module
151
+ the record view uses, so a page you build never reads worse than the record it
152
+ was built from.
153
+
154
+ Related lists sit in a tab strip rather than stacked down the page, and only
155
+ the tab you open is fetched — six things pointing at a record is otherwise six
156
+ queries and a scroll. A run of lists is one strip and anything between two
157
+ lists splits them, so dragging sections is how you decide what is a tab of
158
+ what; the builder's "Lists as tabs" is the way out of it.
159
+
160
+ Page view is sticky: from a page, a child row and a followed reference open as
161
+ pages too, using whichever page you built for that table. In write mode the
162
+ record's own fields become editors in place, through the same staging,
163
+ statement preview and undo as the record view. Pin a page with the star beside
164
+ its name and the home screen offers it — along with the records you were last
165
+ looking at, and every page there is.
166
+
167
+ **Check the shape** (⌘K, or the `···` menu) runs the same `lint` an agent and
168
+ a build get, for the person who can actually add the index: keyless tables,
169
+ foreign keys with nothing behind them, references with no delete rule,
170
+ timestamps with no zone — grouped by table, each cost said once, and every
171
+ heading opens the table it is about.
172
+
173
+ **Find a value** (⌘K, or the `···` menu) answers the question you have when a
174
+ string arrives from somewhere else and you cannot yet name the table it
175
+ belongs to: it sweeps the text columns of every table — and uuid columns when
176
+ the value is a whole uuid — grouped by table, with each match a row you can
177
+ open. Bounded, and honest about it: it says which tables it could not read and
178
+ where it stopped. It is the same `find` the MCP server offers, through the
179
+ same endpoint, so the two cannot disagree about what a match is.
180
+
181
+ Everything is a link. The URL carries the whole trail — connection, queries,
182
+ rows, page — so a walk can be pasted into a ticket and arrive intact. A page
183
+ is named in the link rather than identified: `/app/@customer-desk~1885`. The
184
+ grammar is documented in the `?` help panel.
185
+
186
+ ## For coding agents: MCP
187
+
188
+ The same machinery, offered to an agent:
189
+
190
+ ```bash
191
+ npx tablewalk --mcp --config ./tablewalk.json # speaks MCP on stdio
192
+ ```
193
+
194
+ ```json
195
+ { "mcpServers": { "tablewalk": { "command": "npx", "args": ["tablewalk", "--mcp", "--config", "./tablewalk.json"] } } }
196
+ ```
197
+
198
+ Twenty-three tools. Reading everywhere: `refresh` (re-read the catalog after a migration — the schema is cached per open, and the error for an unknown table says when to call it), `schema_summary` (orientation in a few hundred
199
+ tokens — hubs, event tables, the deepest reference chain), `tables`, `table`
200
+ (columns, DDL, and foreign keys in **both** directions), `find` (where a name
201
+ lives, across tables and columns), `query` (the query language, whose errors
202
+ carry suggestions and the table's real columns — what lets an agent fix a
203
+ mistake in one retry), `breakdown` (counts and sums grouped by something —
204
+ `invoice count by month invoice_date`), `sql` (read-only, for whatever the
205
+ language still cannot say), `record`
206
+ (one row, its human name, and everything that points at it), `explain` (the
207
+ planner's account of a statement without running it — feed a `query` answer's
208
+ `sql` back verbatim), `profile` (what a column actually holds: null share,
209
+ distinct count, range, the values that repeat), `lint` (what the shape will
210
+ cost: keyless tables, unindexed references, naive timestamps),
211
+ `change_impact` (what breaks if you change this table or column — asked
212
+ *before* the migration is written: what points at it under which delete rule,
213
+ whether those columns are NOT NULL, whether an index leads on them, and how
214
+ many rows actually hold null, which is the fact that decides whether an
215
+ `ALTER` succeeds), `diff` (whether
216
+ two connections are still the same shape), `order` (what has to exist before
217
+ what, and the reverse for teardown), `fixture` (a real row and the graph it belongs to, ready to replay into a test database), and `connections`. Every result carries a deep link the human beside the agent
218
+ can open in the UI, and says what it cost: wall time and rows read.
219
+
220
+ Writing only where your config says so. `insert`, `update`, `delete`,
221
+ `insert_graph`, `delete_graph` and `revert` are offered only when a connection
222
+ is marked `"writable": true` — a read-only server does not list tools it would
223
+ refuse. `revert` undoes the last write behind the same two-call `confirm` gate
224
+ `delete` uses; a `sql` write is recorded but marked unrevertible, because
225
+ arbitrary SQL has no general inverse.
226
+ `insert` answers with the row as the database stored it, defaults filled and
227
+ key assigned; `delete` answers with its impact first — every table pointing at
228
+ the row, counts, and `ON DELETE` rules — removing nothing until called again
229
+ with `confirm: true`. Write answers carry `rowsInserted` / `rowsWritten` /
230
+ `rowsDeleted`.
231
+
232
+ A fixture is a graph, so `insert_graph` takes one: several related rows in one
233
+ transaction, where either all of them land or none does. A row that cannot
234
+ know its parent's key names it by label —
235
+
236
+ ```json
237
+ [{"table": "country", "as": "au", "values": {"code": "AU", "name": "Australia"}},
238
+ {"table": "customer", "as": "c1", "values": {"name": "Dry Creek", "country_code": {"ref": "au", "column": "code"}}},
239
+ {"table": "invoice", "values": {"customer_id": {"ref": "c1"}, "total": 99.5}}]
240
+ ```
241
+
242
+ — and `{"ref": "c1"}` resolves to that row's key *as the database assigned it*.
243
+ The answer carries a `teardown` list, reversed, for `delete_graph`, which
244
+ removes it the same way: one transaction, all or nothing. `fixture`'s output
245
+ can be passed straight in as `tables`, which is the loop worth having — take a
246
+ real row out of development, put it into a test database, tear it down after.
247
+
248
+ Rows entering an agent's context leave the machine — point it at development
249
+ databases. Where you want that enforced rather than remembered, mark a
250
+ connection `"rows": false`: the catalog, row counts, null fractions, plans and
251
+ `lint` still answer, and everything that would return a value out of a row
252
+ refuses — at the adapter, so every endpoint and every tool at once. A server
253
+ whose connections are all shape-only does not list `query`, `sql` or `record`
254
+ at all.
255
+
256
+ The shelf your team already keeps is readable too. Named queries and page
257
+ definitions from your `tablewalk.json`, and pages saved from the browser, are
258
+ served as MCP **resources** — `tablewalk://{connection}/queries` and
259
+ `tablewalk://{connection}/pages` — beside the schema brief. Reading them does
260
+ not run them; they are the questions your team decided were worth asking, in
261
+ front of an agent that would otherwise re-derive them badly.
262
+
263
+ And you can watch all of it: the **Agent console** (⌘K → "Agent console")
264
+ drives the same tools by hand through the same code path, showing each
265
+ answer exactly as an agent receives it — stats, token cost, and the deep
266
+ links back into the UI.
267
+
268
+ ## A config file, and a team's surfaces
269
+
270
+ Everything above works with no config at all. A `tablewalk.json` earns its keep
271
+ when you want several databases to hand, and when the interface a team agrees
272
+ on should live in version control rather than in one person's browser.
273
+
274
+ ```json
275
+ {
276
+ "connections": [
277
+ { "name": "app (local)", "url": "postgres://localhost/app", "schemas": ["public"] },
278
+ { "name": "production", "url": "postgres://reader@prod/app", "rows": false },
279
+ { "name": "analytics", "url": "postgres://user:pass@warehouse/an" },
280
+ { "name": "cache", "url": "./var/cache.sqlite" }
281
+ ]
282
+ }
283
+ ```
284
+
285
+ The same file can name SQL statements your team should have to hand:
286
+
287
+ ```json
288
+ {
289
+ "queries": [
290
+ {
291
+ "name": "Customers over their limit",
292
+ "sql": "select c.name, sum(i.total) as invoiced from customer c join invoice i on i.customer_id = c.id group by c.id having sum(i.total) > c.credit_limit",
293
+ "description": "Where invoiced value has passed the agreed credit limit",
294
+ "connection": "app (local)"
295
+ }
296
+ ]
297
+ }
298
+ ```
299
+
300
+ They appear above the SQL editor and in ⌘K. Leaving `connection` out offers the
301
+ statement on every database, which is right for one about a schema several of
302
+ them share and wrong for one about a table only production has. Statements you
303
+ save in the browser sit in the same list; the only difference is that a
304
+ committed one is not yours to delete.
305
+
306
+ It can also carry the built surfaces, so the interface a team agrees on lives
307
+ in version control rather than in one person's browser. Four sections, all
308
+ optional:
309
+
310
+ ```json
311
+ {
312
+ "views": [{ "name": "Unpaid, biggest first", "connection": "app (local)",
313
+ "text": "invoice show id, total, status, customer_id.name as customer where status != paid sort total desc limit 50" }],
314
+
315
+ "pages": [{ "name": "Customer desk", "base": "customer",
316
+ "sections": [
317
+ { "kind": "fields", "title": "Account", "paths": ["name", "email", "country_code.name"], "columns": 2 },
318
+ { "kind": "metric", "title": "Billed", "fn": "sum", "from": "invoice", "path": "customer_id", "column": "total", "width": "third" },
319
+ { "kind": "list", "title": "Invoices", "from": "invoice", "path": "customer_id", "limit": 10 }
320
+ ] }],
321
+
322
+ "layouts": [{ "table": "customer", "name": "Account desk",
323
+ "sections": [
324
+ { "kind": "fields", "title": "Account", "paths": ["name", "email", "credit_limit"] },
325
+ { "kind": "list", "title": "Invoices", "from": "invoice", "path": "customer_id", "limit": 5,
326
+ "orderBy": [{ "column": "invoice_date", "direction": "desc" }] }
327
+ ] }]
328
+ }
329
+ ```
330
+
331
+ A **view** is a saved filter in the query language. A **page** is a composed
332
+ record — fields, counts and related lists — reached at `/{connection}/@page~id`.
333
+ A **layout** is how the record view itself arranges one table, which used to be
334
+ buildable only by clicking.
335
+
336
+ A list section names its relationship as `from` and `path` — the child table
337
+ and the column of it pointing back — or as `via`, the foreign key's constraint
338
+ name. `via` is the real key, because a child can point at a parent twice and
339
+ the column alone would not say which; the pair is resolved to it against the
340
+ schema, and a pair that matches no relationship keeps its section and carries
341
+ an error saying so rather than quietly disappearing.
342
+
343
+ Committed layouts fill in for tables nobody has laid out here, and never over
344
+ one somebody saved: a committed layout is the default a team starts from, and
345
+ the copy in your browser is what you chose afterwards. A malformed entry in any
346
+ of these stops the server and names the entry and the fix — a page that
347
+ silently did not appear is a bug report that starts "I definitely wrote it".
348
+
349
+ Looked for in `./tablewalk.json`, `./.tablewalk.json`, then
350
+ `$XDG_CONFIG_HOME/tablewalk/connections.json`. They appear in a picker in the
351
+ sidebar and are **opened lazily** — naming eight databases does not mean
352
+ dialling eight databases. Connection strings stay on the server: the browser
353
+ is sent an id, a name, a dialect and a redacted detail, never a password.
354
+
355
+ ## Credentials
356
+
357
+ A connection string is a credential, so tablewalk tries hard not to hold one.
358
+ The password can come from four places, tried in this order:
359
+
360
+ | | |
361
+ |---|---|
362
+ | **Environment** | `"url": "postgres://app:${PROD_PASSWORD}@db/app"` — nothing is stored anywhere |
363
+ | **OS keychain** | macOS, via the built-in `security` tool, addressed by connection name |
364
+ | **Credentials file** | `$XDG_CONFIG_HOME/tablewalk/credentials.json`, **refused unless mode 0600** |
365
+ | **Inline in the URL** | Works, and is reported in the UI, because it ends up in shell history |
366
+
367
+ An unset `${VAR}` is an error rather than an empty expansion — otherwise you
368
+ get an authentication failure that says nothing about the real cause.
369
+
370
+ tablewalk deliberately does **not** encrypt its own secrets. Rolling
371
+ encryption for a local tool means inventing key management, and a key stored
372
+ next to the ciphertext it protects is obfuscation with extra steps. Every
373
+ option above either delegates to something that already solves the problem or
374
+ is explicit about being plaintext-on-disk with the permission discipline `ssh`
375
+ and `psql` use.
376
+
377
+ Connections added through the UI keep their password out of the stored URL:
378
+ the secret goes to the keychain (or the 0600 file) and the URL that persists
379
+ has no password in it. A connection list can be shared or committed without
380
+ thinking about it.
381
+
382
+ ## Read-only, and meant
383
+
384
+ - The SQLite handle is opened `readOnly`. Reaching past the query API entirely
385
+ still fails.
386
+ - Postgres connections `SET default_transaction_read_only = on` and a 30s
387
+ statement timeout.
388
+ - Writes exist only behind a per-connection `"writable": true` opt-in;
389
+ adding a row is a form the schema writes, and deleting one leads with its
390
+ impact. A write opens a *second* connection, so the one every read uses
391
+ stays read-only for the life of the process however much gets edited.
392
+ - An update or a delete has to name the whole primary key, and the row is
393
+ counted on the read-only handle before anything is written: a key matching
394
+ anything other than exactly one row is refused rather than applied. The
395
+ danger in a cell editor was never injection.
396
+ - Table names cannot be bound as parameters, so they are checked against the
397
+ catalog before being quoted into a statement.
398
+ - Every read is bounded. There is no code path to an unlimited query: a
399
+ caller that omits a limit gets 20, one that asks for too many gets 100,000,
400
+ and one that sends nonsense gets 20 rather than `LIMIT NaN`.
401
+ - Request bodies are capped at 256 KB.
402
+ - The server binds `127.0.0.1` only. Listening on every interface would put
403
+ your database one shared network away from someone else's browser; `--host`
404
+ overrides it and says so at startup.
405
+ - Connection strings never reach the client, in any response.
406
+
407
+ There is a test asserting that `name = "'; DROP TABLE customer; --"` returns
408
+ zero rows *and leaves the table standing*.
409
+
410
+ ## Databases
411
+
412
+ | Dialect | Status | Notes |
413
+ |---|---|---|
414
+ | SQLite | Built in | Uses Node's own `node:sqlite`. No native module to compile. Reads and writes. |
415
+ | Postgres | Supported | Needs `pg` (`npm install pg`). Reads `pg_catalog` for keys and row estimates. Reads and writes. |
416
+ | MySQL / MariaDB | Supported | Needs `mysql2` (`npm install mysql2`). Reads `information_schema`. Reads and writes. |
417
+
418
+ Adding a third is one file implementing `Adapter` — `introspect`, `query`,
419
+ `count`, `close` — plus a case in `src/adapters/connect.ts`. The shared
420
+ `buildWhere` handles the dangerous part, so an adapter never builds a
421
+ predicate itself.
422
+
423
+ ## Options
424
+
425
+ ```
426
+ tablewalk no target: serve, and ask for one in the browser
427
+ tablewalk <target> a SQLite file path, sqlite:<path>, or postgres:// URL
428
+ -d, --db <target> the same thing, explicitly (or set DATABASE_URL)
429
+ -p, --port <n> default 4111
430
+ --schema <name> Postgres only; repeatable. Defaults to all user schemas.
431
+ --host <addr> interface to bind (default 127.0.0.1)
432
+ --allowed-host <name> also answer for this Host header; repeatable
433
+ --config <path> read connections from this file
434
+ --no-config ignore any config file
435
+ --open open a browser once the server is up
436
+ --mcp speak MCP on stdio instead of serving HTTP
437
+ --tools <name> explore, migrate, seed, or full (the default)
438
+ --output-schema advertise each tool's output shape in tools/list
439
+ -h, --help print this and stop
440
+ -v, --version print the version and stop
441
+ ```
442
+
443
+ An option it does not recognise is an error, not a shrug: `tablewalk --prot
444
+ 4111` says so rather than quietly serving the default port.
445
+
446
+ `--tools` offers one job's worth of tools instead of all twenty-two. Most
447
+ sessions are one job — an agent reading a schema does not need the graph
448
+ writers, one seeding a fixture does not need `explain` — and the listing is a
449
+ fixed cost paid on connect, before anything is asked. `connections` and
450
+ `refresh` are in every profile: the first is the call to start with, the second
451
+ is how an agent recovers from a schema that changed underneath it. A profile
452
+ only ever *removes*, so a read-only connection given `seed` still lists no
453
+ write tools.
454
+
455
+ full 5,579 · explore 2,737 · migrate 2,210 · seed 2,909 tokens
456
+
457
+ `--output-schema` is off by default, and that default is a measurement. The
458
+ tool listing is what every agent pays for on connect, before it has asked
459
+ anything, and on a 66-table schema `outputSchema` was exactly half of it —
460
+ about 4,300 tokens, of which one byte-identical `stats` block repeated across
461
+ every tool is 1,200. It is optional in MCP and it is a contract rather than a
462
+ prompt: most agents never read it, and the answers carry the same fields
463
+ whether or not the shape was declared in advance. Turn it on for a client that
464
+ validates `structuredContent` against the declaration.
465
+
466
+ ## What it will and will not answer
467
+
468
+ Browsing is read-only unless a connection is marked writable, and the server
469
+ enforces that — the padlock in the toolbar is the interface for a decision the
470
+ server makes, not the decision itself.
471
+
472
+ Every request is checked against the `Host` it arrived for, reads included.
473
+ That is the defence against DNS rebinding: a page on a name an attacker
474
+ controls, pointed at your loopback, is *same-origin* as far as the browser is
475
+ concerned, so it can read what it asks for. tablewalk answers only on
476
+ `localhost`, on the address it was bound to, and on any name given with
477
+ `--allowed-host` — everything else gets a 403, including the page itself, so
478
+ the attempt fails at the door rather than one fetch later.
479
+
480
+ Requests that change something are checked twice more: the `Origin`, when the
481
+ browser sends one, and the content type, because a form post is the one shape
482
+ a cross-origin page can make without asking permission.
483
+
484
+ Nothing here is authentication. Anyone who can reach the port and send the
485
+ right `Host` can read the database. Bind to loopback — the default — and that
486
+ is your own machine; bind wider and it is whoever else can reach you.
487
+
488
+ ## Docker, and a desktop app
489
+
490
+ There is a Dockerfile and an Electron app in `desktop/`. Both are covered in
491
+ [DEPLOY.md](DEPLOY.md), including the two things a container changes about the
492
+ security model — binding `0.0.0.0` inside while publishing only to the host's
493
+ loopback, and `--allowed-host` for reaching it by any name but `localhost`.
494
+
495
+ ## Keyboard
496
+
497
+ The same table is in the app under `?` → **Keys the page listens for**, so it
498
+ cannot drift out of reach of the person who needs it.
499
+
500
+ | Key | |
501
+ |---|---|
502
+ | `h` | home — the schema, your work, the diagram |
503
+ | `/` | focus the query bar |
504
+ | `⌘/` | the query bar as a panel, with room to write in |
505
+
506
+ The `···` menu carries **Theme** — match the system, always light, always
507
+ dark — and the rows-per-page control lives with the pager.
508
+ | `t` | focus the table filter |
509
+ | `⌘K` | command palette: tables, pages, actions, recent queries |
510
+ | `⌘B` | show or hide the table list |
511
+ | `Backspace` or `⌘[` | walk back one step |
512
+ | `?` | the query-language panel |
513
+ | `Esc` | leave the field, or close what is open |
514
+
515
+ Single keys work when the caret is not in a text field, and never through a
516
+ dialog: the page under a modal is not taking keys.
517
+
518
+ In a result grid: arrows move a cell cursor, `Enter` opens that row's record,
519
+ `⌘C` copies the cell, `Home`/`End` jump to the first and last column.
520
+
521
+ ## Development
522
+
523
+ ```bash
524
+ npm install
525
+ npm test # typecheck + import check + the unit suite, against real SQLite files
526
+ npm run demo # build a demo database and open it
527
+ npm run build # emit dist/, runnable by plain node
528
+ ```
529
+
530
+ The demo database is generated from source in `test/make-demo-db.ts` rather
531
+ than committed as a binary, so you can read what you are browsing. Its shape
532
+ deliberately covers the things that are easy to get wrong: a self-referencing
533
+ key, a composite key, a view, and a nullable reference.
534
+
535
+ For something with weight to it, seed bulk data:
536
+
537
+ ```bash
538
+ npx tsx test/make-demo-db.ts .demo/big.db --scale 2000 # ~87,000 rows, <1s
539
+ ```
540
+
541
+ Seeding is deterministic — the same scale and seed always produce the same
542
+ database, so a screenshot or a reported bug can be reproduced exactly. The
543
+ small fixture is left untouched by default because the test suite asserts
544
+ exact counts against it.
545
+
546
+ The client is hand-written ES modules with no build step and no framework,
547
+ because the promise is `npx tablewalk mydb.sqlite` and nothing else. A bundler
548
+ in the pipeline would mean a `dist/` to keep current and a reason for the
549
+ published package to drift from the source.
550
+
551
+ ## Licence
552
+
553
+ MIT.