npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 connection

No 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 = AU is 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 days becomes 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 = on and a 30s statement timeout; MySQL read sessions carry the same cap as max_execution_time (max_statement_time on MariaDB).
  • Writes exist only behind a per-connection "writable": true opt-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.1 only. Listening on every interface would put your database one shared network away from someone else's browser; --host overrides 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 PROGRAM inside a Postgres READ ONLY transaction, and a MySQL account with SUPER can 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-host name, 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 to tablewalk.json on the server instead.
  • Editing a connection's URL makes it read-only. writable is 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/tablewalk

DEPLOY.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