Skip to content

Actions & commands

This page shows the two ways a record changes beyond Save: a fixed action, which is an ordinary edit behind a button, and a command, a named server operation with declared input, checks inside one transaction and a durable receipt.

import { action } from 'tablewalk/app';
actions: [
action('Start work', { set: { status: 'in_progress' }, when: 'status = ready', bulk: true }),
action('Assign reviewer', { command: assignReviewer, when: 'status = submitted' }),
],

Use a fixed action to set fields. Use a command when current state, who is acting, several writes or a safe retry must agree at the moment it runs.

A fixed action’s set goes through the ordinary update path and needs write on the resource. Its when only decides whether the button is drawn; it is not a permission. bulk: true offers it on a queue’s selection, record by record; reason: '<column>' asks why and writes the note with the change.

To make the actions the only way a column changes, declare it in transitions. The server then refuses any other change to it and checks the matching action’s when against the stored row inside the write:

issue: {
access: 'write',
transitions: { status: 'actions' },
actions: [
action('Start work', { set: { status: 'in_progress' }, when: 'status = ready' }),
action('Cancel issue', { set: { status: 'canceled' }, reason: 'cancel_note' }),
],
},

Undo returns the record to where it was. Keys are in the DSL reference.

Two words in a fixed action’s set are stamps, resolved by the server inside the write:

action('Resolve', { set: { status: 'resolved', resolved_at: 'now', resolved_by: 'me' }, when: 'status != resolved' }),
Word Column Written
'now' DATE The day in ui.timezone (UTC without one)
'now' TIMESTAMP, DATETIME The UTC instant, to the second
'me' Text The signed-in account’s email; needs auth

A request cannot choose the moment or the person. A column that cannot hold its stamp ('now' on text, 'me' on a number, key or reference) keeps the App from starting. Stamps are not written in tenant Apps yet; use a command’s now() and actor there. Declare stamped columns in transitions so nothing else can write them.

A role can run named fixed actions on a resource it reads but does not write:

auth: {
roles: {
payroll_manager: { read: true, actions: { pay_run: ['Submit for review', 'Approve payroll'] } },
},
},

The server admits the update only as exactly one of those actions — its set, stamps and reason, with its when judged on the stored row — and Undo stays. The grant must name a resource the role reads, with access: 'write', that the role does not already write, and a label that is one of its fixed actions. It is listed in authority.lock and is not yet available under auth.rows: 'policy' or 'tenant'. Payday grants its payroll manager every payroll and timesheet decision this way.

A command has two halves:

Half Where Holds
The reference, commandRef the App (browser-safe) id, version, target, versionField, targets, input
The definition, defineCommand a server-only module loaded with --commands source, footprint, when, handle, output

The App never imports the server module; the server module imports the reference and spreads it, so the contract is written once and startup refuses a registration that disagrees.

// refs.ts — imported by the App
import { commandRef, field } from 'tablewalk/app';
export const assignReviewer = commandRef('assignReviewer', {
version: 1, target: 'loan_application', versionField: 'version',
input: {
reviewer: field.reference('lender_staff', { value: 'id', labelColumn: 'name', query: 'active = true and role = reviewer' }),
reason: field.textarea({ max: 500 }),
},
});
// commands.ts — server-only
import { defineCommand, defineCommands, defineSource, refuse } from 'tablewalk/commands';
import type { Rows } from './tablewalk-schema.d.ts';
import { assignReviewer } from './refs.ts';
const lending = defineSource<Rows>('lending');
export default defineCommands({
assignReviewer: defineCommand({
...assignReviewer, source: lending,
read: ['lender_staff'], write: ['review_file'],
when: { status: 'submitted' },
async handle({ db, record }, input) {
const reviewer = await db.lender_staff.find({ id: input.reviewer, role: 'reviewer', active: true });
if (!reviewer) refuse('ineligible_state');
await db.loan_application.update(record, { status: 'in_review' });
await db.review_file.insert({ application_id: record.id, reviewer_id: reviewer.id, reason: input.reason });
},
}),
});
Part Says
version The command’s semantics. Changing what it does is a new version.
target, versionField It acts on one record of that resource, at the revision the form showed. A form’s command names neither.
source The one App source it runs on, in one transaction.
read, write The footprint beyond the target. Any other table is a type error and refused at runtime.
when What must hold of the target, checked inside the transaction, or ineligible_state. An object ({ status: ['new', 'qualified'] }) or the query language.
validate Optional: one rule the input declaration cannot state, run before the transaction. Any synchronous Standard Schema works too.
output The shape of what handle returns. Leave it out when it returns nothing.

A full working command, step by step, is in the DSL in one App. Ignition’s platform.ts has six real ones.

A command’s input is fields keyed by name, in the order the form asks them. Each is required unless it says optional: true, and is labeled from its key (applicant_email is “Applicant email”) unless it gives a label. The form checks it before sending, and the server parses it with the same reader.

input: {
applicant_name: field.text({ label: 'Full name', max: 120 }),
applicant_email: field.email(),
applicant_phone: field.phone({ optional: true }),
requested_amount: field.money({ label: 'Amount to finance', min: 1 }),
down_payment: field.money({ below: 'requested_amount' }),
term_months: field.choice([36, 48, 60], { label: 'Term', each: months => `${months} months` }),
},
Helper Takes
field.text(), field.textarea() min/max characters, pattern and its message, case: 'upper' | 'lower'
field.email(), field.phone(), field.url() text with that format
field.number(), field.money() min, max, integer, step, below/above a sibling
field.date() min, max as YYYY-MM-DD, below/above a sibling
field.bool() —
field.choice(values, { each }) values, or { value, label } options; radios up to four, a listbox past that
field.reference(resource, { value, labelColumn, query }) a picker; never on a public form
field.group(fields, { repeat: { min, max } }) a section, or rows people add and remove

Every field also takes help. A refusal names its field by path (vehicles[1].stock) and never echoes the value. Limits: 32 fields in one declaration, 128 in all, groups two deep, 50 rows a repeat (10 on a public form). CommandInputOf<typeof ref.input> is the parsed type the handler receives.

handle(context, input) runs in one transaction on its source:

Name What
record The target, read in this transaction at the revision the form saw and in the state when requires.
records Every target, in request order, for targets: 'many'.
db The footprint’s tables, typed from the generated Rows.
actor The verified principal: id, roles, and for a signed-in account email and name from its session. kind is 'public' or 'service' for a public form or a job.
now(), signal, requestId The server clock, cancellation, the diagnostics id.
tx The raw transaction, for what db cannot say.

Before it runs, a missing target is not_found, one at another revision stale_record, and one failing when ineligible_state.

db.<table> Answers
get(key) the row, or not_found
find(where) the one matching row, or undefined
where(where?) a query: .orderBy(column, 'desc'), .limit(n), .first(); more than 1,000 rows without .limit is refused
count(where?) how many
insert(values) the stored row, key included (written tables only)
update(row, changes) the new row, guarded at the values read, versionField bumped
remove(row) nothing, or stale_record

A where is an object ({ status: 'available' }, a list for one of several, null for empty), the query language, or a tagged template whose values are always quoted: db.vehicle.where`make = ${input.make}` .

refuse(code, detail?) says no, and nothing the handler wrote is kept: refuse('forbidden'), refuse('invalid_input', 'applicant.email'), refuse('ineligible_state', { key: { id: loan.id } }). A sentence instead of a code is ineligible_state in the reader’s words, shown in the form and a selection’s report: refuse(`${person.name} has ${left} h of ${policy.name} left`) (one line, at most 300 characters).

Generated Rows type an INTEGER as number; wider integer columns are number | string, and ids past 2^53 stay strings. Never pass an id through Number().

import { date, int, literal, shape, text } from 'tablewalk/commands';
output: shape({ applicationId: text({ max: 200 }), version: int({ min: 1 }), reviewDueOn: date(), message: literal('Lead converted.') }),

text, int, number, id, bool, date, literal(...), list(item, { min, max }), shape({ … }), optional, nullable and oneOf(...); ShapeOf<typeof s> is the type. The shape is checked before commit, keeps only its declared keys, and is what the receipt stores. A handler that returns a value with no output is refused before commit.

initial({ record, db }) on a single-record command returns starting values for its form, read in the same transaction as the revision the form submits. Its db only reads, within the footprint.

defineCommand({
...priceVehicle, source: market,
sources: { valuations: ['estimate'] },
async lookup({ sources, record }) {
return { estimate: await sources.valuations.estimate.find({ stock_ref: record.stock_ref }) };
},
async handle({ db, record, lookup }) { … },
});

lookup reads another source — a database or an HTTP connection — before the transaction opens (get, find, where; at most 64 KiB of JSON) and the handler receives it as lookup. If that source fails, the attempt is refused as temporarily_unavailable. A replay answers from its receipt, whatever the other source says now. In a tenant App, only owner: 'global' resources may be looked up. Preview.

import { post } from 'tablewalk/commands';
effects: {
notifyDealer: post('dealer-hooks', output => ({ applicationId: output.applicationId })),
},

post(webhook, project) records a JSON payload in the command’s transaction and delivers it to a "kind": "webhook" connection after commit, with retries. Nothing is sent on rollback and a replay records nothing again. A webhook’s answer never reaches the command. Not in tenant Apps. See webhooks. Preview.

A command runs only for a role granted it:

import { commandGrants } from 'tablewalk/app';
auth: {
roles: {
manager: { read: true, commands: commandGrants(assignReviewer) },
},
},

commandGrants(...refs) is shorthand for the ids (commands: ['assignReviewer']). The resources the command writes need access: 'write', and the connection needs "writable": true, but the manager gets no Save, New, Delete or Undo on them: the command is the only way they change. Startup refuses a role whose generic write grant overlaps a command’s writes, and names the grant to change.

Terminal window
npx tablewalk --config ./server.json --app ./myapp --commands ./myapp/commands.ts

One --commands entry serves one --app; handler changes need a restart. Commands run on SQLite and PostgreSQL and keep a journal of receipts and audit rows in the database. Provision it once, before the first start, from a maintenance script:

import { migrateSqliteCommandJournal, migratePostgresCommandJournal } from 'tablewalk/commands';
migrateSqliteCommandJournal('/absolute/path/business.db');
// or: await migratePostgresCommandJournal(process.env.DATABASE_URL);

Startup checks the journal and never creates it; a missing one is named with the call that provisions it. MySQL has no command journal. A row-policy App also loads its policy with --policy, and a tenant App its tenant policy.

A command form is a view that runs one command that creates records:

import { commandForm, commandRef, field, step } from 'tablewalk/app';
const logCall = commandRef('logCall', {
version: 1,
input: {
account: field.reference('account', { value: 'id', labelColumn: 'name' }),
notes: field.textarea({ max: 2000, optional: true }),
},
});
views: {
'log-call': commandForm('Log a call', logCall, [step('The call', ['account', 'notes'])], {
done: { title: 'Call logged', message: 'It is on the account.' },
}),
},

Its reference names no target. With steps, each input belongs to exactly one step and the form is a stepper; without, it is one page. done is the screen after submitting. Needs auth and auth.roles. Preview.

A form is signed in unless it says public: true. It is a room at /{app}/{key}: nav and home may name it, and only roles granted its command see it or may submit it. After a submission it offers Fill in another. Not yet on Apps with auth.rows.

public: true serves the form to anyone at /{app}/form/{key}, running a command registered with public: true:

views: {
finance: commandForm('Apply for financing', applyForFinancing, [
step('About you', ['applicant_name']),
step('The loan', ['requested_amount']),
], { public: true, done: { title: 'Application received', message: 'Keep this reference.' } }),
},
  • It never appears in nav; a landing’s call to action or a signed-out home rule links to it.

  • It always runs as a public principal (actor.kind === 'public'), even for a signed-in visitor, and a public command cannot be granted to a role.

  • No reference inputs: a public form never lists a directory.

  • Its audit keeps which columns were written, not the values (audit: { redact: 'values' } is the default for a public command; any command may name columns to redact).

  • Submissions are rate limited per client (five a minute, thirty an hour) and across all clients; with a cache every instance shares the budget. Behind a proxy, name it with --trust-proxy.

  • On a tenant App, the published link’s ?via= address places the submission with one tenant; see public intake.

  • It is write-only to the person filling it in. Its command answers shape({ reference: text(…), message: literal(…) }) or less; startup and tablewalk check refuse any other output shape, and the done screen shows the reference and your fixed words, nothing typed.

  • Make the reference random, never a key or a counter: publicReference('IGN') from tablewalk/commands gives IGN-7K2M-QX4T-9BRD (sixty random bits), and publicReferencePattern('IGN') is its output pattern.

  • With steps, the last step reviews every answer from the page’s memory, and leaving the page asks first until it is sent.

  • prefillable inputs may be filled from a page’s headline action only with fields the public grant already opens; anything else is refused at load.

Hold nothing scarce on an unverified submission: write a request that signed-in staff then act on, and refuse by field only for the shape of what was typed.

save: 'each-step' saves a long public application into your real tables as the applicant goes, so nothing is lost to a closed tab. Each step runs a command of its own; the form’s command is the final submit:

apply: commandForm('Apply for financing', submitApplication, [
step('About you', startApplication),
step('Address', saveAddress),
step('Income', saveIncome),
step('Co-applicant', saveCoApplicants),
], {
public: true, save: 'each-step',
resume: { token: 'resume_token_hash', expires: 'resume_expires_at', progress: 'resume_progress', after: '14 days' },
draft: { status: 'in_progress' },
}),
// The first step inserts the root in its draft state; later steps get the root's key from the server.
async handle({ db, draft }, input) {
if (draft!.root) await db.application.update(await db.application.get(draft!.root), input);
else await db.application.insert({ ...input, ...draft!.start });
return { message: 'Saved.' as const };
}
  • The applicant’s place is an HttpOnly, SameSite=Strict cookie scoped to the form’s API path. Only its SHA-256 is stored, on the root row in the resume.token column, and it is cleared on submit.
  • Later steps write tables that reference the root. The server fills in the link, and a step reads and writes only its own application’s rows. Address repeating rows by their position (position: index + 1), never by a key.
  • The submit runs in one transaction, moves the root out of the draft state, and is refused until every step is saved.
  • Nothing saved is ever answered back, even with the cookie. The review is drawn from the page’s memory; after a reload it reads “Saved — not shown for your privacy”, with Change to enter a step again. Leaving asks first (the browser’s generic prompt; mobile Safari may not show it).
  • Staff lists over the root leave drafts out. A tab whose query names the draft value (status = in_progress) shows them.
  • Only the submit may declare events, effects or a lookup, and a resource’s notify on these tables must be held to states after the draft.
  • abandoned: 'purge' (the default) removes expired drafts and their rows when a new application starts; 'keep' leaves them.
  • Load checks name the root’s resume columns (nullable text and a timestamp) and every root column a later step fills, which must be nullable.
  • On a tenant App every step is resolved from its ?via= address and saved in that tenant (or the platform’s triage). The token is bound to the tenant as well as the draft: under another address it continues nothing. There, statusCheck and link are refused. A row-policy App’s public intake inserts once, so it refuses save.

A statusCheck lets an applicant ask for the state of a submitted application, on its own page at /{app}/form/<key>/status. The receipt is confirmed by one to three details only they know, and the check answers shows (['status']) in the words says gives. Every wrong answer gets the same reply in the same time, the page marks no field for it, and five failures lock a receipt for an hour. Mark a detail such as the last four digits of an SSN keyed: true on its input, and it is stored only as an HMAC under TABLEWALK_FORM_KEY. POST /api/forms/<key>/status takes { receipt, confirm: { column: value } }.

stepUp: { email: 'applicant_email', shows: ['first_name', 'loan_amount'] } opens more than the state, through the config’s email service: Email me a code sends a six-digit code to the root’s own email column, and the details asked again with the code open shows for a 15-minute session in that browser. A code works once, for 10 minutes; three go to an application an hour, and a wrong, used or expired code gets one reply (POST /api/forms/<key>/stepup, then /details).

alert names a nullable timestamp on the root. The attempt that locks a submitted application’s receipt stamps it, once per lock, and your own notify rule tells staff; load refuses an alert no rule watches:

statusCheck: { receipt: 'reference', confirm: { applicant_last_name: 'Last name' }, alert: 'check_alert_at' },
// resources.application:
notify: [{ on: { changes: 'check_alert_at' }, when: { status: ['submitted', 'in_review'] },
to: 'assigned_to.email', title: 'Repeated status checks on {reference}' }],

Every attempt is one line in the server’s log: the form, the outcome, a digest of the receipt and the signed-in account, never a detail typed.

link lets an applicant add a submitted application to an account, on proven control only, never on matching a name or an SSN:

link: { owner: 'account_id', at: 'linked_at', via: 'linked_via',
sameDevice: { token: 'claim_token_hash', expires: 'claim_expires_at' }, statusCheck: true },
  • sameDevice: the submit leaves a claim in the browser for 30 minutes (an HttpOnly, SameSite=Strict cookie; its SHA-256 on the root). The done screen offers Create an account to track this and Sign in to add it, both leading to /{app}/form/<key>/claim, which asks “Add it to your account?”. Yes links it; signing in alone never does. No, the done screen’s This is a shared device, signing out, the expiry and the next application from the browser all burn the claim.
  • statusCheck: true: signed in, the status page offers Add to my account after the check passes.
  • email: { column: 'applicant_email', verified: 'email_verified' }: signed in with an email the sign-in store has verified (the link sign-up sends), the status page says “We found 1 application submitted with this email — is this yours?”, and Yes, it’s mine links that one. It links without asking only where the applicant proved the address with a code on the form’s review (verified, which the server stamps with the address proved). An unverified account email matches nothing. Needs the email service.
  • A link writes owner (the account’s id), at and via (same-device, status-check or email) once, compare-and-set, and is refused when the application already has an owner. Staff read the three columns like any other, and unlink with a command of yours that clears them. The history (where kept) names the account, the server logs every link, and where the server sends email the account is told.
  • Once linked, the anonymous status check no longer answers for it; the owner’s session reads it, and POST /api/forms/<key>/mine lists the account’s own applications by the owner column alone.
  • link needs the form’s statusCheck, and all its columns nullable and owned by the server: no step may ask or write them.

Declare targets: 'many' on the reference, and a command action may carry bulk: true: one request names a queue’s selection (up to 200 records) and commits for all of them or none.

const assignReviewer = commandRef('assignReviewer', {
version: 1, target: 'loan_application', versionField: 'version', targets: 'many',
input: { /* … */ },
});
actions: [action('Assign reviewer', { command: assignReviewer, bulk: true, when: 'status = submitted' })],

The handler loops over records. Any refusal refuses the whole request, naming the record it is about. Before submitting, the form lists records that no longer match when, with Deselect ineligible. Public and event-emitting commands cannot target many. For per-record results on plain field edits, use a fixed action with bulk: true.

Every command request carries an idempotencyKey. The same key with the same input replays the stored receipt without running the handler again; the same key with anything else is idempotency_conflict.

Outcome Means
applied It committed; a replay returns the stored result.
not_applied This attempt did not apply.
unknown Completion is uncertain: keep the original request and key, and ask its status. Never retry under a new key.

Commands do not offer Undo. Save, inline edits and fixed actions are a separate path: there are no Save hooks, and a Save followed by a command is two operations.

A command can record the facts it produced, in its own transaction, and a job can run another command later because of one. Preview; delivery is in-process, not a message broker.

Declare the event and project it from the command’s output:

import { defineCommand, defineEvent, defineEventProjection, type ShapeOf } from 'tablewalk/commands';
export const applicationSubmitted = defineEvent<{ applicationId: string; reviewDueOn: string }>({
name: 'application.submitted', version: 1, fields: ['applicationId', 'reviewDueOn'],
payload: { parse: parseSubmitted },
});
defineCommand({
...convertLeadRef, source: lending, output: convertOutput, /* … */
events: { submitted: defineEventProjection(applicationSubmitted,
({ applicationId, reviewDueOn }: ShapeOf<typeof convertOutput>) => ({ applicationId, reviewDueOn })) },
});

Declare the job in a server-only module loaded with --jobs beside --commands:

import { defineJob, defineJobs } from 'tablewalk/jobs';
import { applicationSubmitted } from './events.ts';
export default defineJobs({
reviewDue: defineJob({
version: 1,
on: { event: applicationSubmitted, command: { id: 'convertLead', version: 1 } },
runAs: 'review-clock',
command: { id: 'flagOverdueReview', version: 1 },
schedule: event => ({ runAt: Date.parse(event.reviewDueOn) + 86_400_000,
key: { id: event.applicationId }, input: { due_on: event.reviewDueOn } }),
}),
}, {
principals: { 'review-clock': { roles: ['automation'] } },
store: { namespace: 'ignition' },
});
  • schedule turns the event into the command’s target, input and due time. It must be pure: a re-sent event plans the same job.
  • runAs is a service principal holding App roles; every attempt is authorized again, and a role with generic writes is refused.
  • Startup and --check-app refuse unresolved commands or events, a principal that is not granted its command, and cycles; --check-app prints one line per job.

Provision the stores once from tablewalk/maintenance, on the jobs’ source:

import { provisionSqliteJobs } from 'tablewalk/maintenance';
provisionSqliteJobs('/absolute/path/business.db', { appId: 'my-app', commands, jobs });

provisionPostgresJobs does the same on PostgreSQL, and provisionSqliteTenantJobs/provisionPostgresTenantJobs for a tenant App. Commands that emit events without jobs need only provisionSqliteAppCommandEvents or provisionPostgresAppCommandEvents. inspectJobs, redriveJobs and pruneJobs (and their event-delivery twins) are the operator’s tools; readiness is on Operations.

Each hop carries a durable key from the event: exactly one event batch per committed command, at-least-once delivery, and one receipt per job’s command. A job only runs a command — there is no free-form handler — and row-policy Apps run neither jobs nor webhooks yet.