The DSL in one App
Read one App from top to bottom: a resource, a list, a record page, a role, a command and a look, each piece of the DSL once.
The App is Lintel (examples/apps/lintel),
a property company’s three Denver buildings: staff let homes, keep the
ledger and fix things, and residents sign in to the same App and see only
their own household. It is a handful of files, app.ts and the modules it
imports, plus commands/, the server code behind its buttons. Every snippet
below is cut from those files, so you can run what you read
(run it). Tick (examples/apps/tick/app.ts)
is the same shape in a single file.
The App file
Section titled “The App file”export default defineApp({ id: 'lintel', name: 'Lintel', sources: { lintel: { connection: 'lintel' } }, resources, // …See it in the sample (examples/apps/lintel/app.ts:49)
idis the App’s name in addresses and in its account store.sourcesbinds the App to a server connection by name. The App never holds a URL or a password; the server’s config does.resourcesis imported fromresources.ts, andviewsfromviews.ts: the manifest splits when one file would be too long to read.- The file ends
satisfies App.Appis generated from the database bytablewalk check, so a misspelled table or column is an editor error.
1. A resource: the table, said once
Section titled “1. A resource: the table, said once” lease: { segments: { current: 'status = active or status = notice', ending_soon: 'status = active and end_on = next 60 days', // … }, access: 'write', recordLabel: '{unit_id.property_id.name} #{unit_id.number}', // … machine: machine('status', { draft: { on: { signLease: 'active' } }, active: { on: { giveNotice: 'notice' } }, notice: { on: { endLease: 'ended' } }, ended: { final: true }, }), // … },See it in the sample (examples/apps/lintel/resources.ts:106)
Everything the App says about a table lives in its resource, and every list, page and form reads it from there:
segmentsname a condition once, in the query language; the list below puts two of them in tabs.access: 'write'is the most any role may be granted on leases.recordLabelnames a record by its words (“Alder Court #101”), not its id.machineis the lease’s road. Each verb on it becomes a button where the lease’s state lists it, and the server refuses any other change of status. Here every verb is a command (step 5); a fixed move says{ to, label }.- A stored value reads as words without a rule:
ending_soonis “Ending soon”. Adisplayrule is for the word that differs.
Resources and fields · Display rules · Actions
2. A list: what a visitor opens
Section titled “2. A list: what a visitor opens” leases: listView('Leases', 'lease', { icon: 'file-text', roles: [...OFFICE, 'accountant'], show: ['unit_id', 'resident_id', 'status', 'start_on', 'end_on', 'rent', 'balance'], // … tabs: [ { segment: 'current', sort: 'end_on' }, { segment: 'ending_soon', sort: 'end_on' }, // … ], }),See it in the sample (examples/apps/lintel/views.ts:62)
The key, leases, is the address: /leases. show picks the columns, with
a reference drawn as the record’s words, and each tab names a segment, a
condition in the query language the resource declares once.
roles keeps the list out of other roles’ menus. With no presentation the
rows are a grid. home and nav say which list opens first and what the
menu holds.
Lists
3. A record page
Section titled “3. A record page”export default page( 'lease', [ header({ status: 'status', // … }), // … related('Charges', 'charge', 'lease_id', { show: ['description', 'due_on', 'amount', 'status'], sort: 'due_on desc', limit: 12, }),See it in the sample (examples/apps/lintel/pages/lease.ts:7)
A page is a list of sections, and a file in pages/ whose default export is
a page is that page, keyed by its name. header draws the name, the status
beside it and the record’s verbs; related lists the charges that point at
this lease through charge.lease_id.
Record pages
4. Who may do what
Section titled “4. Who may do what”const leasing = { label: 'Leasing', read: true, // … commands: ['signLease', 'giveNotice', 'endLease', ...repairs], // …} as const;// … auth: { rows: 'policy', providers: ['email'], // … roles: { leasing, manager, // …See it in the sample (examples/apps/lintel/app.ts:17)
People sign in by email. Leasing reads every table and runs the lease
commands, and its write list (left out here) names only the tables edited
in place, so a lease changes only by its commands. manager extends
leasing, and a resident is a role whose rows the policy decides, so each
sees their own household and nothing else. The server enforces every grant;
hiding a button is not security.
Roles and field access · Row access
5. A command: a decision made on the server
Section titled “5. A command: a decision made on the server”export const giveNotice = defineCommand({ source: lintel, target: 'lease', write: ['unit'], input: { end_on: field.date({ label: 'Moving out on', help: 'The last day of the lease. Thirty days from today at the earliest.' }) }, output: said, // … async handle({ db, record, now }, input) { // … if (input.end_on < addDays(today, 30)) refuse('A lease ends at least thirty days after notice is given.', { field: 'end_on' }); // … await db.lease.update(record, { status: 'notice', end_on: input.end_on }); const home = await db.unit.get(record.unit_id); await db.unit.update(home, { status: 'notice', available_on: addDays(input.end_on, 14) });See it in the sample (examples/apps/lintel/commands/leasing.ts:28)
Giving notice changes two tables at once and checks a date against the
lease, so it is a command rather than a plain set action like Tick’s:
inputdeclares the form: a date, with its help. The browser and the server read it with the same rules.- The machine’s
activestate listsgiveNotice, and the button is drawn only where it does; the command is checked again inside the transaction. handlegets adbtyped from the schema. The lease and the home change together, or neither does;refuse('…')says no and keeps nothing.tablewalk checkwritestablewalk-commands.d.tsfrom the commands, sogiveNoticeinapp.tsis checked like a column name.
6. A look
Section titled “6. A look”Lintel wears the default look, Lumen. To try another, change its ui and save:
ui: { theme: { preset: 'sage' } },Looks shows all three.
Run it
Section titled “Run it”From a source checkout (npm install first), Lintel’s own README has the
whole recipe; in short, seed a database and serve it with the App’s jobs and
row policy:
DIR=$(mktemp -d /tmp/lintel.XXXXXX)export LINTEL_MAIL_SECRET=$(openssl rand -base64 24) PAYMENT_CALLBACK_SECRET=$(openssl rand -hex 16)export BETTER_AUTH_SECRET=$(openssl rand -base64 32)export TABLEWALK_AUTH_ADMIN_EMAIL=admin@example.test TABLEWALK_AUTH_ADMIN_PASSWORD=choose-a-long-passwordnode --import tsx examples/databases/lintel/seed.mjs "$DIR/lintel.db"npx tsx src/server/index.ts --config "$DIR/lintel.db.json" --app examples/apps/lintel \ --jobs examples/apps/lintel/jobs.ts --policy examples/apps/lintel/policy.ts --port 4610You should now see the public front page at http://localhost:4610/.
Sign in with the email and password above, open Leases and a lease in
its active state: Give notice asks for a date at least thirty days away
and refuses an earlier one. The
Lintel README (examples/apps/lintel/README.md)
says what each file teaches and how its tests run.
Next: Sample Apps for Tick and Lintel in full, or Build with a coding agent to build your own.