tablewalk
v0.8.4
Published
Follow the data. A database browser built around the walk: a foreign key is a place you can go.
Downloads
1,855
Maintainers
Readme
tablewalk
Follow the data. — tablewalk.dev
Most database tools are SQL clients that happen to render a grid. tablewalk starts somewhere else: a foreign key is a place you can go. Open a row and it shows you every row elsewhere that points at it — the orders on a customer, the customer on an order — so you follow the data instead of writing joins to reach it. The same machinery answers over MCP, so a coding agent sees what you see (jump to setup).
npx tablewalk --demo # build a sample database and open it — an instant try
npx tablewalk mydata.sqlite # or your own: SQLite, Postgres, MySQL
npx tablewalk # or nothing, and the page asks for a connectionNo config, no build step, no schema to define first — it reads the catalog on
connect. Read-only until you turn editing on, per connection. Runs from
npx, a Docker image, or a desktop app.
npx tablewalk with no target opens a page that asks for a connection: a form
that takes a URL or its parts, tests it before keeping it, and reads the
password from an environment variable or the keychain rather than the string.
The print-and-exit commands need a target, though — there is nothing for
--export, --lint, --diff or --mcp to read without one:
npx tablewalk mydata.sqlite --export md >> CLAUDE.md # the schema, as a committed brief
npx tablewalk postgres://… --lint --fail-on high # what the shape costs, for CI
npx tablewalk --config ./tablewalk.json --diff staging # is staging the same shape?Build an app
App mode uses a version-controlled TypeScript DSL to define navigation, record
pages, forms, dashboards, actions and themes over the same database runtime.
JSON remains supported as a data-only definition format. Neither requires an
App build to run on the supported Node 24 runtime; TypeScript examples also
have an explicit typecheck, and tablewalk build is the optional production
step that runs it, validates the App and bundles it — see
Build and deploy an App.
These App features ship in tablewalk 0.8.4; the
status page says which are stable
and which are preview. Start with the sample catalog,
written for people and coding agents, then the authoring guide.
The reference ships in the package, so it reads offline: npx tablewalk docs
lists one-screen topics — what an App serves: its declared resources, and
no Workbench (access-model), keeping columns and secrets out of the browser (secrets),
relationships, dates-and-types, reload-and-restart, check-codes — and
npx tablewalk docs <topic> prints one. schema/app.d.ts carries the DSL's
hover docs, and tablewalk --mcp offers the same pages to a coding agent as resources.
Apps are internal tools and data apps over a database you already have. An
App serves only the resources it declares; the standalone browser
(npx tablewalk <db>, no App) is the tool for the tables it does not curate.
Think admin consoles, operations desks, review queues, CRM-style workspaces, where
the schema is the product model and shipping matters more than bespoke UI.
It is not a general frontend framework; a consumer-facing or heavily branded
product is better served by a conventional stack.
What 0.8.1 builds, each shown once on the site:
| | |
|---|---|
| The DSL | Resources, views, actions, commands and a look, typed against your own schema: the DSL in one App, then the reference |
| Seven looks | Mono (the default), Canvas, Iris (with a Coral palette), Ink, Sage, Signal and Tray, one structure and type scale, or a look of the App's own: looks |
| Building blocks | Cards, galleries, facts and sheets, a property rail, a record's sections as tabs, similar lists, a filter bar or a filter rail, boards and calendars you drag in, lists kept in your order: lists, pages & forms and the component tour |
| Your own components | Where no built-in fits, a React module of the App's own as a section, a value renderer, a form's input, a list's rows or a whole room, fed by declared, authorized reads, changing data only through the App's commands and drawn with tablewalk/ui in every look; in preview: custom components |
| The App API | An App's reads and commands to scoped tokens over loopback HTTP, and to a coding agent as MCP tools (--app-mcp), on SQLite and PostgreSQL, tenant Apps included; in preview: the App API |
| Commands | Declared inputs, a typed db, a checked record, refuse and output shapes; lookup and effects in preview: actions & commands |
| Sources and services | An API as a connection, a shared cache, a search engine and webhooks; tenant Apps with invitations, events and public intake in preview: services |
| Shipping | tablewalk build, an App's first page in 314 KB of script with brotli, PostgreSQL pools sized per connection: build and deploy, performance |
Building with a coding agent? Use the agent build guide
for a copyable prompt, a source-checkout recipe and a concrete verification checklist,
and npx tablewalk agent init to install the agent skill
that Claude Code, Codex, Cursor and Copilot load on their own.
An App's addresses read as words, one canonical address per thing. A record
is its id under the view that shows its table (/leads/127), so
following a reference opens the target's own address and Back retraces the
walk; a list is its view's key, and what the reader chose follows as
parameters: /leads?tab=working&search=stone,
/vehicles?make=Toyota,Honda&price=10000..20000&sort=-price. The New
form is /leads/new, a dashboard /sales-dashboard?period=last-30-days;
a natural key (/label/bug) and, on public pages, a slug
(/vehicles/1-2024-toyota-corolla-le) are opt-in. Only a question
those cannot say is written as q= its text. The database browser keeps the
query language in its links, and those still open inside an App.
A server serving one App serves it at the root, as above. Serve several and
each keeps its id (/compass/leads/127); --mount main=/ makes one of them
the main App at the root beside the others, and --base-path /tools serves
everything under a reverse proxy's path (https://host/tools/leads/127).
tablewalk check --routes prints the addresses as they will be served, and
refuses a main App's route that another App's place would hide.
For independent per-App connections and sign-in on one local listener,
tablewalk suite suite.json provides an initial suite runtime. See
Local App suites for configuration and
the currently supported subset.
For an opt-in local performance check, run npm run test:load:smoke from the
installed checkout. The load-testing section
describes concurrent HTTP traffic and real browser journeys against a fresh,
isolated Thread instance; existing previews and databases are not load targets.
The seven TypeScript example projects form a progression:
| Sample | Start here for |
| --- | --- |
| Tick | A five-minute TODO app: one DSL file with inline types, plus a README. Seed with node examples/databases/tick/seed.mjs <db>. |
| Thread | A practical modular starter: projects, issues and cycles. |
| Compass CRM | Rich CRM pages, dashboards, forms and display rules; App ID compass. |
| Keystone | A lender with five mounted sub-apps: tenant lead desk, dealer settings, public site, marketplace and customer portal over PostgreSQL. |
| Payday | People and payroll: pay runs, time off, timesheets and benefits from shared building blocks, with granted fixed actions and a custom component. |
| Depot | Warehouse operations in the Tray look: pick, pack and ship queues, receiving, rollups, record history and command forms with repeating lines. |
| Commons | A moderated community forum in the Ink look: signed-out reading, members' commands, an anonymous report form and moderation queues. |
All use the shared renderer. Seed fresh disposable databases and follow each
sample's README. Database setup lives separately under
examples/databases/. Review queues, status strips and action when
conditions are UI curation; server permissions and command checks enforce access
and business rules. Keystone’s lead desk demonstrates the tenant runtime; its other apps retain their own authorization.
Two access models exist, both opt-in and both enforced on the server. A row policy (auth.rows: 'policy' plus
--policy <file>) scopes what an authenticated App reads and, on a SQLite or
PostgreSQL source, what it may update, insert, delete and undo; see
row policies and Undo. A tenant
App (auth.rows: 'tenant' plus --policy <file>) adds native sign-in,
verified membership and per-tab tenant selection on one provisioned SQLite
file or dedicated PostgreSQL database; see tenant startup
for the supported boundary, what is still SQLite-only and what remains pending.
Tenant Apps are a preview. Neither model is
a production-readiness claim.
The idea
Every database client shows you tables. The interesting structure is the edges — and specifically the direction almost nothing surfaces well:
I am looking at this customer. What points at it?
Foreign keys are stored once and readable in both directions, so the answer is
already sitting in information_schema. tablewalk computes it for the row you
are looking at, counts the referring rows in each table, and lets you click
through to them.
That turns a database from a list into something you can walk:
customer › Harbour Freight › invoice customer_id = 1 › 1001 › invoice_line …Each step is a real filter you can see and edit, and walking back restores the filter you walked away from — which is the detail most browsers lose.
The query language
The bar at the top takes a small language. The table comes first, because that is how people say it out loud; everything else is optional.
customer active = true sort credit_limit desc
invoice invoice_date = last 30 days
employee title contains manager show first_name, last_name
customer country_code in (AU, NZ) and email is not empty
invoice where total >= 500 order by total desc limit 20| | |
|---|---|
| Operators | = != < <= > >=, contains, startswith, endswith (all three ignore case, on every engine), in, not in |
| Null checks | is empty, is not empty |
| Combining | and, or, parentheses |
| Dates | today, yesterday, this week, last month, last 30 days, next 7 days, previous 30 days (the window just before last 30 days, the same length) |
| Clauses | filter / where, show / select, sort / order by, limit |
| Projection | show a, b, c — or select, if that reads better to you |
Press SQL to see exactly what your query compiled to. The tool teaches the database rather than hiding it.
Counting things by something
by turns a query into a breakdown — the GROUP BY question, in the same
language and with the same guarantees. Measures first, then by, then what to
group by:
invoice count by status
invoice count, sum total as billed by month invoice_date sort billed desc limit 12
invoice where total > 100 count by customer_id.country_code.name as country
customer count distinct country_code as countries by active| | |
|---|---|
| Measures | count, count <column> (skips its nulls), count distinct <column>, sum, avg, min, max |
| Group keys | any column, or a reference path — up to three of them |
| Periods | by day \| month \| quarter \| year <date column> |
| Sorting | sort <name> names a column of the answer, key or measure |
A period comes back as a label — 2026-08, 2026-Q3 — spelled identically on
SQLite, Postgres and MySQL, so it sorts correctly as text and means the same
thing wherever you point the tool. Agents get this as the breakdown tool,
which is the one to reach for before sql: every value is bound and every
path is checked against the catalog.
Why not just let people type SQL?
Because this language compiles to a structure, never to SQL text:
customer name = "O'Brien"
↓
{ groups: [[{ column: 'name', op: '=', value: "O'Brien" }]] }
↓
SELECT * FROM "customer" WHERE "name" = ? ["O'Brien"]There is no path from what you type to what the database parses as syntax. Every value arrives as a bound parameter. A tool that accepted raw SQL would have to either trust the input or write a SQL parser to distrust it; this sidesteps both by making the dangerous shape unrepresentable.
Two things fall out of that for free:
- The compiler holds the schema.
customer contry_code = AUis an error with a suggestion, not a database exception — and not, worse, an empty result that looks like an empty table. - Dates resolve to real timestamps.
last 30 daysbecomes two bound values, identical in both dialects, visible in the explain panel.
One row, and everything about it
Click a row and you get a record page: the fields, and — in a rail beside them — every table that points at this row, counted, ranked by how much there is to read. An empty relationship is shown as a zero rather than hidden, because "nothing points here through this" is an answer, and a rail that quietly dropped it could not be trusted to be the whole list.
Records are named like a person would name them. A table with a name-like
column uses it; a table whose name lives one-to-one away — employee carrying
party_id, with the name on party.display_name — walks the reference and
says employee · Maeve Lindqvist instead of employee · 30. The walk is the
tool's whole idea, so it applies to headings and breadcrumbs too, and it
deliberately stops at ownership: an invoice is never named after its customer,
because forty invoices share that name and a label that identifies nothing is
worse than a key.
Pages go one step further: a composed view of one record — its fields, counts of what points at it, and lists of the rows themselves — laid out in sections you arrange. Every table gets a suggested page for free; Layout opens a builder where sections are added, hidden, filtered, resized and dragged into order, fields dragged into groups, and a to-one reference pulled in as its own block of fields. Values on a page render through the same module the record view uses, so a page you build never reads worse than the record it was built from.
Related lists sit in a tab strip rather than stacked down the page, and only the tab you open is fetched — six things pointing at a record is otherwise six queries and a scroll. A run of lists is one strip and anything between two lists splits them, so dragging sections is how you decide what is a tab of what; the builder's "Lists as tabs" is the way out of it.
Page view is sticky: from a page, a child row and a followed reference open as pages too, using whichever page you built for that table. In write mode the record's own fields become editors in place, through the same staging, statement preview and undo as the record view. Pin a page with the star beside its name and the home screen offers it — along with the records you were last looking at, and every page there is.
Check the shape (⌘K, or the ··· menu) runs the same lint an agent and
a build get, for the person who can actually add the index: keyless tables,
foreign keys with nothing behind them, references with no delete rule,
timestamps with no zone — grouped by table, each cost said once, and every
heading opens the table it is about.
Find a value (⌘K, or the ··· menu) answers the question you have when a
string arrives from somewhere else and you cannot yet name the table it
belongs to: it sweeps the text columns of every table — and uuid columns when
the value is a whole uuid — grouped by table, with each match a row you can
open. Bounded, and honest about it: it says which tables it could not read and
where it stopped. It is the same find the MCP server offers, through the
same endpoint, so the two cannot disagree about what a match is.
Everything is a link. In the database browser the URL carries the whole
trail — connection, queries, rows, page — so a walk can be pasted into a ticket
and arrive intact. A page is named in the link rather than identified:
/app/@customer-desk~1885. The grammar is documented in the ? help panel.
An App's links are its canonical addresses instead (above).
For coding agents: MCP
The same machinery, offered to an agent:
npx tablewalk --mcp --config ./tablewalk.json # speaks MCP on stdio{ "mcpServers": { "tablewalk": { "command": "npx", "args": ["tablewalk", "--mcp", "--config", "./tablewalk.json"] } } }Or point it at a database instead of a config file: "args": ["tablewalk",
"--mcp", "--db", "postgres://localhost/app"]. Twenty-five tools —
nineteen read-only, and six writes offered only when a connection is
"writable": true. Every answer carries a deep link the human beside the
agent can open in the UI, and says what it cost in rows and milliseconds.
Reading
| tool | what it answers |
|---|---|
| schema_summary | orientation in a few hundred tokens — hubs, event tables, the deepest reference chain |
| tables / table | the list; then a table's columns, DDL, and foreign keys in both directions |
| find | where a name lives, across every table and column |
| query | the query language; its errors carry suggestions and the table's real columns, so a mistake is fixed in one retry |
| breakdown | counts and sums grouped by something — invoice count by month invoice_date |
| record | one row, its human name, and everything that points at it |
| sql | read-only, for whatever the language cannot say; never over tablewalk's own tables |
| explain | the planner's account of a statement without running it — feed a query answer's sql back |
| profile | what a column actually holds: null share, distinct count, range, the values that repeat |
| lint | what the shape will cost: keyless tables, unindexed references, naive timestamps |
| change_impact | what breaks if you change a table or column — asked before the migration is written |
| diff | whether two connections are still the same shape |
| order | what has to exist before what, and the reverse for teardown |
| fixture | a real row and the graph it belongs to, ready to replay into a test database |
| refresh | re-read the catalog after a migration |
| connections | list them; the call to start with |
| scaffold_app / validate_app | draft an app directory from the schema, and check one against its connection — the authoring loop, for agents |
Writing — offered only when a connection is marked "writable": true; a
read-only server does not list tools it would refuse.
| tool | what it does |
|---|---|
| insert | one row; answers with what the database stored, defaults filled and key assigned |
| update | one row, by its primary key |
| delete | reports its impact first — every table pointing at the row, counts, ON DELETE rules — and removes nothing until called again with confirm: true |
| insert_graph / delete_graph | several related rows in one all-or-nothing transaction |
| revert | preview the last write, then confirm with confirm: true and that preview's next.id as expectedId; raw sql writes have no general inverse |
Write answers carry rowsInserted / rowsWritten / rowsDeleted.
Undo reports applied, not_applied, or unknown. An unknown result may have
committed: inspect the stored records and do not retry it. Pending or uncertain
Undo blocks falling through to older writes. Keep the exact expectedId after
a lost response; never replace it with an older entry to retry. This journal is
bounded and in-memory, not a durable recovery receipt.
A fixture is a graph, so insert_graph takes one: several related rows in one
transaction, where either all of them land or none does. A row that cannot
know its parent's key names it by label —
[{"table": "country", "as": "au", "values": {"code": "AU", "name": "Australia"}},
{"table": "customer", "as": "c1", "values": {"name": "Dry Creek", "country_code": {"ref": "au", "column": "code"}}},
{"table": "invoice", "values": {"customer_id": {"ref": "c1"}, "total": 99.5}}]— and {"ref": "c1"} resolves to that row's key as the database assigned it.
The answer carries a teardown list, reversed, for delete_graph, which
removes it the same way: one transaction, all or nothing. fixture's output
can be passed straight in as tables, which is the loop worth having — take a
real row out of development, put it into a test database, tear it down after.
Rows entering an agent's context leave the machine — point it at development
databases. Where you want that enforced rather than remembered, mark a
connection "rows": false: the catalog, row counts, null fractions, plans and
lint still answer, and everything that would return a value out of a row
refuses — at the adapter, so every endpoint and every tool at once. A server
whose connections are all shape-only does not list query, sql or record
at all.
Where only some columns must stay behind (a password hash, an API token), give
the connection "hidden": { "account": ["password_hash"], "*": ["*_token"] }
instead: the catalog and every answer leave those columns out, a query that
names one is refused, and raw SQL is refused on that connection. A database
role without SELECT on them is stronger still.
A call is bounded in time and in what it counts. Statements run under a
30-second cap, and sql takes timeout_ms to lower it for one statement:
Postgres sets it inside the statement's transaction and MySQL on the
statement's session, and SQLite, which cannot interrupt a statement
in-process, runs sql in a separate process that the cap stops — the reads
tablewalk compiles for SQLite are bounded by their limits instead. A client
that cancels a call (notifications/cancelled) stops the statement it is
running — the Postgres backend is cancelled, the MySQL query killed, the SQLite
process ended — and the call answers as cancelled; a write that has already
started runs to its end, so its outcome is known. The totals query and
breakdown report, and the counts on record, stop at 10,000 and say so
(cappedTotal, capped). And sql and explain refuse statements that touch
tablewalk's own runtime and sign-in tables — the __tablewalk_ tables and the
tablewalk_auth schema — whether the statement names them, reaches them
through a view, or could only name a table when it runs.
The shelf your team already keeps is readable too. Named queries and page
definitions from your tablewalk.json, and pages saved from the browser, are
served as MCP resources — tablewalk://{connection}/queries and
tablewalk://{connection}/pages — beside the schema brief. Reading them does
not run them; they are the questions your team decided were worth asking, in
front of an agent that would otherwise re-derive them badly.
And you can watch all of it: the Agent console (⌘K → "Agent console") drives the same tools by hand through the same code path, showing each answer exactly as an agent receives it — stats, token cost, and the deep links back into the UI.
A config file, and a team's surfaces
Everything above works with no config at all. A tablewalk.json earns its keep
when you want several databases to hand, and when the interface a team agrees
on should live in version control rather than in one person's browser.
{
"connections": [
{ "name": "app (local)", "url": "postgres://localhost/app", "schemas": ["public"] },
{ "name": "production", "url": "postgres://reader@prod/app", "rows": false },
{ "name": "analytics", "url": "postgres://user:pass@warehouse/an" },
{ "name": "cache", "url": "./var/cache.sqlite" },
{ "name": "desk", "kind": "http", "url": "https://desk.example/v1/",
"description": "desk.http.json", "auth": { "bearer": "${DESK_TOKEN}" } },
{ "name": "shared", "kind": "cache", "url": "valkeys://cache.internal:6379",
"auth": { "password": "${VALKEY_PASSWORD}" } },
{ "name": "search", "kind": "search", "url": "https://search.internal:8108",
"auth": { "apiKey": "${TYPESENSE_API_KEY}" } },
{ "name": "mail", "kind": "email", "url": "smtps://smtp.example.com:465",
"from": "Compass <[email protected]>",
"auth": { "username": "${SMTP_USER}", "password": "${SMTP_PASSWORD}" } }
]
}desk is an API rather than a database, read through a description
--from-openapi drafts from its spec: docs/http-sources.md.
shared and search are optional services, never databases: a Valkey cache every
instance shares (cached API answers, public-form rate limits) and a Typesense
engine an App's text filters can ask. Both can be down without anything
failing: services. mail is the
one email service a server sends through — notifications marked email: true,
a status check's step-up code, sign-up verification and reset links — over
smtps://, smtp:// with STARTTLS, or log: (write each message instead of
sending it, for development). A notice's email is queued with its write and
sent after the commit, with retries; a failed send never fails the write
(tablewalk docs email).
A PostgreSQL entry can size its pools, "pool": { "read": 16, "write": 8 }
(each 1 to 64; 4 by default, or --read-pool and --write-pool). An App
writes through its writer pool, saves of one record in the order they
arrived and saves of different records side by side; the database browser
writes through one connection, one write at a time, which its Undo needs.
A MySQL entry can set "pool": { "write": 8 } with the same bounds; its
read pool stays at four and background work has one separate writer.
A write waits at most --lock-timeout (5 s) for a row another transaction
holds, and is then refused with a retry.
Performance & PostgreSQL pools has
the capacity arithmetic, and --pooler transaction for a deployment behind
PgBouncer or RDS Proxy.
The same file can name SQL statements your team should have to hand:
{
"queries": [
{
"name": "Customers over their limit",
"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",
"description": "Where invoiced value has passed the agreed credit limit",
"connection": "app (local)"
}
]
}They appear above the SQL editor and in ⌘K. Leaving connection out offers the
statement on every database, which is right for one about a schema several of
them share and wrong for one about a table only production has. Statements you
save in the browser sit in the same list; the only difference is that a
committed one is not yours to delete.
It can also carry the built surfaces, so the interface a team agrees on lives in version control rather than in one person's browser. Four sections, all optional:
{
"views": [{ "name": "Unpaid, biggest first", "connection": "app (local)",
"text": "invoice show id, total, status, customer_id.name as customer where status != paid sort total desc limit 50" }],
"pages": [{ "label": "Customer desk", "base": "customer",
"sections": [
{ "kind": "fields", "title": "Account", "show": ["name", "email", "country_code.name"], "columns": 2 },
{ "kind": "metric", "title": "Billed", "measure": "sum total", "from": "invoice", "whose": "customer_id", "width": "third" },
{ "kind": "list", "title": "Invoices", "from": "invoice", "whose": "customer_id", "limit": 10 }
] }],
"layouts": [{ "table": "customer", "name": "Account desk",
"sections": [
{ "kind": "fields", "title": "Account", "show": ["name", "email", "credit_limit"] },
{ "kind": "list", "title": "Invoices", "from": "invoice", "whose": "customer_id", "limit": 5,
"sort": "invoice_date desc" }
] }]
}A view is a saved filter in the query language. A page is a composed
record — fields, counts and related lists — reached at /{connection}/@page~id.
A layout is how the record view itself arranges one table, which used to be
buildable only by clicking.
Page and layout sections speak the same words: rows are named as from and
whose — the invoice rows whose customer_id is this customer — with
show for the fields or columns, sort written like the query bar, and (on
a page) about when the section is about a record the base points at rather
than the base itself. A layout's list section may instead say via, the
foreign key's constraint name — the real key, because a child can point at a
parent twice and the column alone would not say which; a from/whose pair
is resolved to it against the schema, and a pair that matches no relationship
keeps its section and carries an error saying so rather than quietly
disappearing.
Committed layouts fill in for tables nobody has laid out here, and never over one somebody saved: a committed layout is the default a team starts from, and the copy in your browser is what you chose afterwards. A malformed entry in any of these stops the server and names the entry and the fix — a page that silently did not appear is a bug report that starts "I definitely wrote it".
Looked for in ./tablewalk.json, ./.tablewalk.json, then
$XDG_CONFIG_HOME/tablewalk/connections.json. They appear in a picker in the
sidebar and are opened lazily — naming eight databases does not mean
dialling eight databases. Connection strings stay on the server: the browser
is sent an id, a name, a dialect and a redacted detail, never a password.
HTTP diagnostics
Opt-in local request diagnostics are available with TABLEWALK_DIAGNOSTICS=1.
Generated request IDs correlate response headers with fixed-shape JSONL events
on stderr, without recording URLs, business payloads or SQL. No telemetry is
sent upstream. See HTTP diagnostics for the event
contract, existing-log distinction and delivery limits, and
Operations & observability for an
App's readiness and support bundle.
Credentials
A connection string is a credential, so tablewalk tries hard not to hold one. The password can come from four places, tried in this order:
| | |
|---|---|
| Environment | "url": "postgres://app:${PROD_PASSWORD}@db/app" — nothing is stored anywhere |
| OS keychain | macOS, via the built-in security tool, addressed by connection name |
| Credentials file | $XDG_CONFIG_HOME/tablewalk/credentials.json, refused unless mode 0600 |
| Inline in the URL | Works, and is reported in the UI, because it ends up in shell history |
An unset ${VAR} is an error rather than an empty expansion — otherwise you
get an authentication failure that says nothing about the real cause.
tablewalk deliberately does not encrypt its own secrets. Rolling
encryption for a local tool means inventing key management, and a key stored
next to the ciphertext it protects is obfuscation with extra steps. Every
option above either delegates to something that already solves the problem or
is explicit about being plaintext-on-disk with the permission discipline ssh
and psql use.
Connections added through the UI keep their password out of the stored URL: the secret goes to the keychain (or the 0600 file) and the URL that persists has no password in it. A connection list can be shared or committed without thinking about it.
Read-only, and meant
- The SQLite handle is opened
readOnly. Reaching past the query API entirely still fails. - Postgres connections
SET default_transaction_read_only = onand a 30s statement timeout; MySQL read sessions carry the same cap asmax_execution_time(max_statement_timeon MariaDB). - Writes exist only behind a per-connection
"writable": trueopt-in; adding a row is a form the schema writes, and deleting one leads with its impact. A write opens a second connection, so the one every read uses stays read-only for the life of the process however much gets edited. - An update or a delete has to name the whole primary key, and the row is counted on the read-only handle before anything is written: a key matching anything other than exactly one row is refused rather than applied. The danger in a cell editor was never injection.
- Table names cannot be bound as parameters, so they are checked against the catalog before being quoted into a statement.
- Every read is bounded. There is no code path to an unlimited query: a
caller that omits a limit gets 20, one that asks for too many gets 100,000,
and one that sends nonsense gets 20 rather than
LIMIT NaN. - Request bodies are capped at 256 KB.
- The server binds
127.0.0.1only. Listening on every interface would put your database one shared network away from someone else's browser;--hostoverrides it and says so at startup. - Connection strings never reach the client, in any response.
- "Read-only" is enforced at the session and transaction level, not the
role level. A superuser can still
COPY … TO PROGRAMinside a PostgresREAD ONLYtransaction, and a MySQL account withSUPERcan step around a read-only session. Connect as a role that can only read and the database enforces what tablewalk promises. A connection that is not writable but opens over a role that could do more (a superuser, a write grant,CREATE) gets one line on stderr saying what that role can do. - The connection manager trusts the browser only on loopback. Adding a
connection from the page can reference
${ENV_VAR}, look a password up in the keychain by name, open a SQLite file by path, and save the config — each of them what the config file itself may do, and each of them a way out when the browser is on another machine. So when the server is bound beyond loopback, or answers to an--allowed-hostname, a URL from the browser is used exactly as written: no environment expansion, no keychain, no file paths, no saving, and no redirecting a connection the config named. Add those connections totablewalk.jsonon the server instead. - Editing a connection's URL makes it read-only.
writableis a statement about one database, and it does not follow the entry to another.
There is a test asserting that name = "'; DROP TABLE customer; --" returns
zero rows and leaves the table standing.
Databases
| Dialect | Status | Notes |
|---|---|---|
| SQLite | Built in | Uses Node's own node:sqlite. No native module to compile. Reads and writes. |
| Postgres | Supported | Needs pg (npm install pg). Reads pg_catalog for keys and row estimates. Reads and writes. Business commands with durable receipts after migratePostgresCommandJournal; an App's sign-in store in its own tablewalk_auth schema after migratePostgresAuthStore. Pools and the pooler mode: performance; the command journal and the authentication store: docs/postgresql.md. |
| MySQL / MariaDB | Supported | Needs mysql2 (npm install mysql2). Reads information_schema. Reads and writes. |
| HTTP API | Preview | Built in. An API with an OpenAPI 3 spec: --from-openapi drafts a description of its lists and items, and a "kind": "http" connection reads through it. Filters, sort and paging only as the API documents them; no totals. Read-only. docs/http-sources.md. |
Adding another is one file implementing Adapter — introspect, query,
count, close — plus a case in src/adapters/connect.ts. The shared
buildWhere handles the dangerous part, so an adapter never builds a
predicate itself.
Options
tablewalk no target: serve, and ask for one in the browser
tablewalk <target> a SQLite file path, sqlite:<path>, or postgres:// URL
tablewalk check --app <dir> types, typecheck and validation of an App (below)
tablewalk build --app <dir> make an App ready for production (below)
tablewalk shot --app <dir> an App's pages as PNG files, to look at (below)
tablewalk migrate <command> change the schema with versioned SQL files (below)
tablewalk docs [topic] the App reference, offline (below)
tablewalk agent init put the agent skill where coding agents read it (below)
-d, --db <target> the same thing, explicitly (or set DATABASE_URL)
-p, --port <n> default 4111
--schema <name> Postgres only; repeatable. Defaults to all user schemas.
--read-pool <n> PostgreSQL connections that may read at once, per
database (default 4, at most 64); a connection's
"pool": { "read": n } wins. Or set
TABLEWALK_READ_POOL
--write-pool <n> connections an App may write through at once,
per PostgreSQL or MySQL database (default 4, at
most 64; the database browser always writes
through one); a connection's "pool": { "write":
n } wins. Or set TABLEWALK_WRITE_POOL
--lock-timeout <s> seconds a write waits for a row or table another
transaction holds before it is refused (default
5, 0.1 to 30). Or set TABLEWALK_LOCK_TIMEOUT
--pooler <mode> session (default: direct, or a pooler that keeps
a client on one server connection) or transaction
(PgBouncer in transaction mode, RDS Proxy): the
read-only guard and statement cap are then set
per transaction. Or set TABLEWALK_POOLER
--host <addr> interface to bind (default 127.0.0.1)
--allowed-host <name> also answer for this Host header; repeatable
--trust-proxy <address|CIDR>
a proxy whose X-Forwarded-For the public forms'
rate limit may believe (10.0.0.0/8, 172.17.0.1,
fd00::/8); repeatable or comma-separated, or set
TABLEWALK_TRUST_PROXY. Loopback is always trusted.
Rate limiting only, never sign-in
--trust-proxy-header <name>
behind a proxy that signs people in: the header
it sets with the user (X-Forwarded-User). Requests
from beyond loopback must carry it, and every
write is journaled with it
--config <path> read connections from this file, and the
cache, search, webhook and email services
(smtps://, smtp:// or log:) beside them
--no-config ignore any config file
--app <dir> serve an app: a directory whose app.ts (or
app.mjs / app.json) declares its resources,
views, navigation and pages. Repeatable; with
more than one, every app names its connection.
A config file can say the same with
"apps": [dir | { "dir", "env" }, …]; "env" lists
the variables the App's definition may read, and
it reads no others. A save reloads the App; a
change to its grants, sources or sign-in needs a
restart (tablewalk docs reload-and-restart).
One App is served at the root (/leads/127);
several each under its id (/compass/leads/127)
--mount <app>=<path> where an App is served, over that default: /
makes it the main App at the root, /billing
puts it there. Repeatable, one per App. Or
"mount": { "<app>": "/" } in the config
--base-path <path> the path a reverse proxy serves this server
under, like /tools: every address, asset, API
call, cookie and link carries it. Or "basePath"
in the config, or set TABLEWALK_BASE_PATH
--app-entry <file> with one --app: the manifest to read, when it is
not the directory's own app.ts. The directory
stays the App's root: components, pages/,
features/ and authority.lock
--commands <file> the server-only default defineCommands registry of
one explicit --app, when it is not the
commands.ts beside its manifest (read without
the flag); authenticated access and a migrated
writable SQLite or PostgreSQL command source
required. Restart the server to reload commands.
--jobs <file> load a server-only default defineJobs module with
one --app and its commands: jobs that run a
command, as a declared principal, when a
committed event says so or at the times they
say (every). Stores provisioned once from
tablewalk/maintenance; restart to reload.
--policy <file> load a server-only row or tenant policy for one authenticated,
named-source --app whose auth.rows is "policy" or "tenant".
Tenant Apps require one existing provisioned SQLite or
PostgreSQL source.
Row policies support reads and
explicit commands and scoped creation/updates with Undo.
Delete/restore requires an explicit deleted-record policy.
App and policy changes require a restart.
--from-openapi <spec> draft an HTTP connection's description from an
OpenAPI 3.0 or 3.1 JSON spec (a file or URL),
print it, and exit; guesses and skipped
operations go to stderr
--openapi <spec> serve an API straight from its OpenAPI spec (a
file or URL): the description is drafted in
memory and the base URL is the spec's server
--api-url <url> with --openapi: the API's base URL, when the
spec's servers do not give a usable one
--api-token-env <name> with --openapi: send $name as a bearer token
--scaffold-app <dir> draft an App project for the active connection
in helper-built TypeScript — list views, display
rules, an Overview, record pages, schema types,
tsconfig.json, a tablewalk.json naming a SQLite
file by a path from the App, and (outside a
checkout) package.json — that passes tablewalk
check as written, and exit. A start to curate.
With --config naming several connections, one
App with a source per connection; a PostgreSQL
or MySQL one holding several schemas is a source
per schema on that one connection.
--name <name> with --scaffold-app: the App's name, instead of
one read off the database file's
--check-app with --app: validate the app against its
connection, print every problem, and exit
non-zero if any. For CI. An app with an
authority.lock also fails when what it
grants differs from the lock.
--preview-features with --app: list each preview feature the app
uses, and where, instead of the one line that
counts them
--write-authority with --check-app: write each app's
authority.lock (what it grants, one fact per
line, and each command's contract) to commit
beside it. The server refuses to start an app
whose authority differs from its lock, and a
reload that changes it.
--bump <command> with --check-app: move the command's contract
to its next version in authority.lock, when it
changed and the App API, a job or kept
receipts depend on it
--reindex [resource] with --app and a "kind": "search" connection:
read every searched resource (or the one
named) from the database into its search
index, then exit
--demo build a throwaway sample database and open it
--open open a browser once the server is up
--dev serve the dev observer at /_tablewalk/observe
(loopback only, in memory, credentials masked)
--mcp speak MCP on stdio instead of serving HTTP
(the database browser's tools; no App)
--tools <name> explore, migrate, seed, or full (the default)
--output-schema advertise each tool's output shape in tools/list
--export <fmt> print the schema as md, mermaid, json or types
(a .d.ts of this connection's names for the app
DSL), then exit; openapi exports one App's local
read-contract preview, enabling no API routes
--api-integers <kind> required with --export openapi, --api-reads or
--app-mcp: decimal-string or number; no default
integer wire representation
--api-reads serve one authenticated App's API on loopback
(Preview): its reads, and with --commands the
targeted commands it exposes; requires integer
choice, rate budget and native readiness;
SQLite or PostgreSQL; a tenant App under its
--policy, each token bound to one membership;
row-policy Apps are refused
--api-requests-per-minute <count>
explicit --api-reads rate budget, 1–1000000
--app-mcp speak one authenticated App's API as MCP tools on
stdio (Preview), instead of serving HTTP: its
reads, and with --commands the targeted commands
it exposes. The token comes from
TABLEWALK_API_TOKEN, never the command line;
requires --api-integers; SQLite or PostgreSQL;
a tenant App under its --policy; row-policy
Apps are refused
--lint [fmt] report what the shape will cost, then exit
--fail-on <sev> with --lint, exit non-zero at this severity; for CI
--diff <conn> compare shapes with this connection, then exit
-h, --help print this and stop
-v, --version print the version and stop
tablewalk compare-openapi <previous.json> <current.json>
compare two local read-contract previews; JSON
report, no server or database. Exit 0: version
requirement met; 1: unmet or version decreased;
2: invalid input. Not a general compatibility proof.
Requires no-follow/nonblocking file-open support;
Windows comparison is not supported yet.
tablewalk check --app <dir> [--app-entry <file>]
[<db> | --db <target> | --config <path>]
[--commands <file>] [--jobs <file>] [--policy <file>] [--bump <command>]
[--routes [--mount <app>=<path>] [--base-path <path>]]
[--design] [--fail-on-design-warning] [--preview-features]
[--pending [--migrations <dir>] [--history-table <name>]] [--json]
the authoring loop in one command: regenerate the
App's tablewalk-schema.d.ts when the database
has changed and its tablewalk-commands.d.ts from
its commands module, typecheck it (file:line, the
mismatch, a hint) and run --check-app. Every
step runs; warnings are advisory unless --fail-on-design-warning is set. --routes also
prints every address the App answers to, as the
--mount and --base-path given would serve it, and
fails on a route another App's place would hide;
--design warns where a page, form or the shell
will come out basic, with the fix (tablewalk
docs design);
--preview-features lists each preview feature
where validation counts them; --pending judges
the App against a scratch copy of the database
with the pending migrations applied (SQLite: a
copy of the file; PostgreSQL: CREATE DATABASE
… TEMPLATE); --json prints one JSON document of
every finding (code, file, line, path, fix) for
an agent. Exit 1 could not check, 2 schema
types, 3 typecheck, 4 validation, 5 components,
6 a pending migration failed on the copy
(tablewalk docs check-codes)
tablewalk migrate new <name> [--repeatable]
tablewalk migrate status | up | validate | baseline | repair
[--app <dir>] [<db> | --db <target> | --config <path>]
[--connection <name>] [--schema <name>] [--migrations <dir>]
[--history-table <name>] [--dry-run] [--tool flyway]
versioned SQL files in Flyway's format, forward
only: V3__add_payoff_quote.sql once, in its own
transaction (MySQL: none; a failure stops the
run); R__views.sql again when it changes. up
applies what is pending, then with --app
regenerates the App's types and runs check;
status shows applied, pending, drift made
outside migrations and types older than the
database; validate fails on any of those;
baseline records an existing database as
version 0 and writes baseline.schema.json;
repair fixes the history. A lock keeps two
deploys apart. Exit 1 could not run, 2 files
and history disagree, 3 a migration failed,
4 applied but the App no longer checks
--migrations <dir> with migrate: where the files are (default
migrations/ in the App, or the connection's
"migrations": { "dir": … })
--history-table <name> with migrate: the history table (default
__tablewalk_schema_history, Flyway's layout;
flyway_schema_history to take over from Flyway)
--connection <name> with migrate: which configured connection
--dry-run with migrate up, baseline or repair: print what
would run and change nothing
--tool flyway with migrate: run it with an installed Flyway
(PATH or TABLEWALK_FLYWAY), same files and history
--repeatable with migrate new: an R__ file
tablewalk shot --app <dir> [<route>…] [<db> | --db <target> | --config <path>]
[--look <name|all>] [--dark] [--phone | --desk | --both]
[--full] [--as <role> [--tenant <id>]] [--out <dir>]
serve the App on a free port with a temporary
config directory and write a PNG of each route
(with none, every view and page it declares);
prints each file's path. Needs Playwright's
Chromium. Exit 1 could not shoot, 2 a page threw
--look <name|all> with shot: a built-in look, or all of them,
instead of the App's own
--dark with shot: the dark scheme
--phone, --desk, --both with shot: 390 wide, 1280 (default), or both
--full with shot: the whole page, not the window
--as <role> with shot: signed in as a fresh account with
that role (admin: the bootstrapped one)
--tenant <id> with shot --as on a tenant App: the tenant the
role is held in, a temporary membership in the
App's tenant store (journaled), revoked when the
shot ends; a platform role needs none
--out <dir> with shot: where the files go (default
tablewalk-shots/<app> in the temporary directory)
tablewalk build --app <dir> [--app-entry <file>] [--out <dir>]
[--config <path>] [--commands <file>] [--jobs <file>]
[--policy <file>]
typecheck, validate and bundle an App for
production (optional; an App also runs from its
source). With --config reaching the database,
also write its types and run --check-app on the
build. Exit 1 could not build, 2 typecheck,
3 validation, 4 bundle
--out <dir> with build: where the build goes (default
build/<dir name>); replaced whole on success,
refused unless new, empty or an earlier build
tablewalk docs [topic] the reference the package ships: with no topic,
the topics (start, access-model, secrets,
relationships, dates-and-types, …) and every
other page; with one, that page
--search <words> with docs: every line of the shipped pages that
says them
tablewalk agent init [--dir <project>] [--tool claude|codex|cursor|copilot|all] [--force]
copy the App skill into the project
(.claude/skills/tablewalk for Claude Code,
.agents/skills/tablewalk for Codex, Cursor and
Copilot) and point at it from AGENTS.md; a copy
the project changed is left alone (exit 1)
--dir <project> with agent init: where (default: here)
--tool <name> with agent init: which agents; default all
--force with agent init: replace a changed copy
tablewalk observe [--mcp] [--url <base>]
the running --dev server's observer: print its
address, or with --mcp speak its telemetry to a
coding agent on stdio (check, which runs
tablewalk check --json and needs no server,
recent_errors, list_traces, get_trace, search_logs,
slow_queries, n_plus_one, explain_access,
page_vitals, tail)
--url <base> with observe: the dev server (default: the one
this machine started most recently)An option it does not recognise is an error, not a shrug: tablewalk --prot
4111 says so rather than quietly serving the default port.
--tools offers one job's worth of tools instead of all twenty-five. Most
sessions are one job — an agent reading a schema does not need the graph
writers, one seeding a fixture does not need explain — and the listing is a
fixed cost paid on connect, before anything is asked. connections and
refresh are in every profile: the first is the call to start with, the second
is how an agent recovers from a schema that changed underneath it. A profile
only ever removes, so a read-only connection given seed still lists no
write tools.
full 5,579 · explore 2,737 · migrate 2,210 · seed 2,909 tokens--output-schema is off by default, and that default is a measurement. The
tool listing is what every agent pays for on connect, before it has asked
anything, and on a 66-table schema outputSchema was exactly half of it —
about 4,300 tokens, of which one byte-identical stats block repeated across
every tool is 1,200. It is optional in MCP and it is a contract rather than a
prompt: most agents never read it, and the answers carry the same fields
whether or not the shape was declared in advance. Turn it on for a client that
validates structuredContent against the declaration.
What it will and will not answer
Browsing is read-only unless a connection is marked writable, and the server enforces that — the padlock in the toolbar is the interface for a decision the server makes, not the decision itself.
Every request is checked against the Host it arrived for, reads included.
That is the defence against DNS rebinding: a page on a name an attacker
controls, pointed at your loopback, is same-origin as far as the browser is
concerned, so it can read what it asks for. tablewalk answers only on
localhost, on the address it was bound to, and on any name given with
--allowed-host — everything else gets a 403, including the page itself, so
the attempt fails at the door rather than one fetch later.
Requests that change something are checked twice more: the Origin, when the
browser sends one, and the content type, because a form post is the one shape
a cross-origin page can make without asking permission.
Nothing here is authentication. Anyone who can reach the port and send the
right Host can read every connection this server holds, run read-only SQL
against them, and add network connections of their own — which also makes the
server a probe for hosts it can reach. Bind to loopback — the default — and
that is your own machine; bind wider and it is whoever else can reach you,
which is why a server bound wider stops trusting the browser with file paths,
environment references and the config file (above), and why anything past a
private network belongs behind a proxy that authenticates.
An app can sign people in itself: "auth": { "providers": ["email"] } in
its manifest brings in Better Auth — email and
password, GitHub, Google or any OpenID Connect provider (Entra ID, Okta,
Keycloak…) with roles from its claims, invite-only, open or just-in-time
sign-up, roles with their own write scope — with sessions in a SQLite file of
tablewalk's own and the secrets in the environment. tablewalk docs
access-sources has the block and tablewalk docs identity-providers the
providers; it is an optional dependency, installed only by apps that ask.
When it is behind one — Cloudflare Access, oauth2-proxy, Tailscale's identity
headers — say so: --trust-proxy-header X-Forwarded-User (or the header your
proxy sets). Every request from beyond loopback must then carry it, so a
request that skipped the proxy is refused at the door, and every write in the
journal says who made it. That is the proxy's decision carried into this
process, not a login of tablewalk's own; per-user tabs, pins and history are
still the browser's. (--trust-proxy, without -header, is unrelated: it
names the proxies whose X-Forwarded-For a public form's rate limit reads,
and signs nobody in.)
Docker, and a desktop app
Container — ghcr.io/rbaljinder/tablewalk. It reads its connections from a
mounted tablewalk.json and holds no configuration of its own:
docker run --rm -p 127.0.0.1:4111:4111 -v "$PWD:/config:ro" ghcr.io/rbaljinder/tablewalkDEPLOY.md covers the two things a container changes about the
security model — binding 0.0.0.0 inside while publishing only to the host's
loopback, and --allowed-host for reaching it by a name other than
localhost.
An App in a container — build it in a builder stage with tablewalk build
and ship the build with the runtime, not its TypeScript.
examples/docker does that for four of the samples,
and Build and deploy an App
covers serving a build, version pinning and running behind a load balancer.
Desktop — a native app (macOS, Windows, Linux) is attached to the
latest GitHub release.
macOS is signed and notarised, so it opens normally; Windows is not signed
yet, so its first open needs an "open anyway" past SmartScreen; npx tablewalk
and the container need no such thing.
Keyboard
The same table is in the app under ? → Keys the page listens for, so it
cannot drift out of reach of the person who needs it.
| Key | |
|---|---|
| h | home — the schema, your work, the diagram |
| / | focus the query bar |
| ⌘/ | the query bar as a panel, with room to write in |
| t | focus the table filter |
| ⌘K | command palette: tables, pages, actions, recent queries |
| ⌘B | show or h
