The DSL in one App
This page builds a small lead desk in five steps — a resource, a view, an action, its command and a look — each linked to its reference.
The App lists leads and offers one guarded operation, Disqualify, which
asks why. It runs over the lending database of the
Ignition sample and is three files:
app.ts and a browser-safe refs.ts (the App), and a server-only
commands.ts.
The App file
Section titled “The App file”import { defineApp } from 'tablewalk/app';import type { App } from './tablewalk-schema.d.ts';
export default defineApp({ id: 'lead-desk', name: 'Lead desk', sources: { lending: { connection: 'loans', default: true } }, resources: { … }, // step 1, with step 3's action views: { … }, // step 2 home: 'leads', auth: { … }, // step 3 ui: { … }, // step 5} satisfies App);sources binds the App to a server connection named loans — a name, never a
URL. home is where a visitor starts; with no nav there is no menu. App
is generated from the database (tablewalk --app ./lead-desk --export types),
so satisfies App turns a mistyped table or column into an editor error.
Types and validation.
1. A resource: the table, said once
Section titled “1. A resource: the table, said once”import { money, tones } from 'tablewalk/app';
resources: { lead: { access: 'write', display: { requested_amount: money('USD'), status: tones({ new: 'neutral', qualified: 'info', converted: 'good', disqualified: 'bad' }), }, },},Everything the App says about a table lives in its resource block, and every
view, page and form reads it from there. access: 'write' is the most any
role may be granted; the connection’s "writable": true still decides whether
anything is written. Resources.
2. A view: what a visitor opens
Section titled “2. A view: what a visitor opens”import { listView } from 'tablewalk/app';
views: { leads: listView('Leads', 'lead', { show: ['reference', 'applicant_name', 'requested_amount', 'submitted_on', 'status'], filters: ['reference', 'applicant_name', 'status'], tabs: [{ label: 'Open', query: 'status = new or status = qualified' }, { label: 'All' }], }),},The key leads is the address, /lead-desk/leads. show picks columns,
filters become the search box and “+” pickers, and each tab is a
query-language condition. With no presentation the rows are a grid.
List views.
3. An action, and who may run it
Section titled “3. An action, and who may run it”import { action } from 'tablewalk/app';import { disqualifyLead } from './refs.ts';
lead: { // … access and display, as in step 1 … actions: [action('Disqualify', { command: disqualifyLead, when: 'status = new or status = qualified' })],},An action is declared once and offered wherever a lead appears. when only
decides whether the button is drawn. This one runs a command, because it asks
for a reason and must be decided against the stored lead; a plain
set: { status: 'disqualified' } would be an ordinary edit.
Fixed actions and commands.
A command runs only for a role granted it:
auth: { providers: ['email'], signUp: 'invite', roles: { manager: { read: true, write: false, commands: ['disqualifyLead'] } },},write: false keeps Save, New and Delete off, so the command is the only way
a lead changes.
Grant an operation without granting Save.
4. The command: a declared input and a typed db
Section titled “4. The command: a declared input and a typed db”The reference is the command’s contract. It is browser-safe, so the form is drawn from it:
import { commandRef, field } from 'tablewalk/app';
export const disqualifyLead = commandRef('disqualifyLead', { version: 1, target: 'lead', versionField: 'version', input: { reason: field.textarea({ max: 500, help: 'Kept on the lead for the desk. Nothing is sent to the applicant.' }), },});reason is required, at most 500 characters and labeled from its key; the
form and the server parse it with the same reader.
Inputs, declared once.
The handler is server code, loaded with --commands:
// commands.ts — server-onlyimport { defineCommand, defineCommands, defineSource, int, literal, shape, text } from 'tablewalk/commands';import type { Rows } from './tablewalk-schema.d.ts';import { disqualifyLead as disqualifyLeadRef } from './refs.ts';
const lending = defineSource<Rows>('lending');const disqualifyOutput = shape({ leadId: text({ max: 200 }), version: int({ min: 1 }), message: literal('Lead disqualified.') });
export default defineCommands({ disqualifyLead: defineCommand({ ...disqualifyLeadRef, source: lending, when: { status: ['new', 'qualified'] }, output: disqualifyOutput, async handle({ db, record: lead }, { reason }) { const { version } = await db.lead.update(lead, { status: 'disqualified', disqualified_reason: reason }); return disqualifyOutput.parse({ leadId: lead.id, version, message: 'Lead disqualified.' }); }, }),});...disqualifyLeadRefstates the contract once; startup refuses a mismatch.source: lendingruns one transaction and typesdbfromRows.whenis checked inside the transaction: a missing lead isnot_found, a changed onestale_record, a converted oneineligible_state.db.lead.updatewrites at the version it read;refuse(code)says no and keeps nothing.outputis checked before commit and kept in the receipt, so a repeated request is answered without running twice.
5. A look
Section titled “5. A look”ui: { theme: { preset: 'mono' } },Mono is the default of the seven built-in looks; a reader may pick another. Looks.
Run it
Section titled “Run it”Ignition is PostgreSQL; seed two empty databases, then name the lending one
loans in a writable connection:
export IGNITION_LENDING_SEED_URL=postgres://…/lead_desk_lendingexport IGNITION_APPLICANTS_SEED_URL=postgres://…/lead_desk_applicantsexport TABLEWALK_AUDIT_KEY="$(node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))')"node --import tsx examples/databases/ignition/seed-postgres.mjs /tmp/lead-desk-data{ "connections": [{ "name": "loans", "url": "postgres://…/lead_desk_lending", "writable": true }] }Check it with its command entry, as CI would:
npx tablewalk --config ./lead-desk.json --app ./lead-desk \ --commands ./lead-desk/commands.ts --check-appServing it signs people in, which needs BETTER_AUTH_SECRET and a first
account — see Sign-in, roles & data.
Build and deploy turns the three files into a
production build.