Skip to content

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.

app.ts
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.

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.

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.

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:

refs.ts
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-only
import { 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.' });
},
}),
});
  • ...disqualifyLeadRef states the contract once; startup refuses a mismatch.
  • source: lending runs one transaction and types db from Rows.
  • when is checked inside the transaction: a missing lead is not_found, a changed one stale_record, a converted one ineligible_state.
  • db.lead.update writes at the version it read; refuse(code) says no and keeps nothing.
  • output is checked before commit and kept in the receipt, so a repeated request is answered without running twice.

The handler · output shapes

ui: { theme: { preset: 'mono' } },

Mono is the default of the seven built-in looks; a reader may pick another. Looks.

Ignition is PostgreSQL; seed two empty databases, then name the lending one loans in a writable connection:

Terminal window
export IGNITION_LENDING_SEED_URL=postgres://…/lead_desk_lending
export IGNITION_APPLICANTS_SEED_URL=postgres://…/lead_desk_applicants
export 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:

Terminal window
npx tablewalk --config ./lead-desk.json --app ./lead-desk \
--commands ./lead-desk/commands.ts --check-app

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