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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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.
|