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.
Fixed actions are ordinary edits
Section titled “Fixed actions are ordinary edits”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.
Stamping the moment and the person
Section titled “Stamping the moment and the person”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.
Grant a fixed action without write
Section titled “Grant a fixed action without write”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.
Commands
Section titled “Commands”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 Appimport { 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-onlyimport { 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.
Inputs, declared once
Section titled “Inputs, declared once”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.
The handler
Section titled “The handler”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().
Output shapes
Section titled “Output shapes”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.
Starting values: initial
Section titled “Starting values: initial”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.
Reading another source first: lookup
Section titled “Reading another source first: lookup”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.
After commit: effects
Section titled “After commit: effects”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.
Grant an operation without granting Save
Section titled “Grant an operation without granting Save”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.
Load and run
Section titled “Load and run”npx tablewalk --config ./server.json --app ./myapp --commands ./myapp/commands.tsOne --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.
Command forms
Section titled “Command forms”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.
Signed-in forms
Section titled “Signed-in forms”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 forms
Section titled “Public forms”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-outhomerule 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
referenceinputs: 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 andtablewalk checkrefuse 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')fromtablewalk/commandsgivesIGN-7K2M-QX4T-9BRD(sixty random bits), andpublicReferencePattern('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.
-
prefillableinputs 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.
Forms saved on every step
Section titled “Forms saved on every step”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=Strictcookie scoped to the form’s API path. Only its SHA-256 is stored, on the root row in theresume.tokencolumn, 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
draftstate, 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
notifyon 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,statusCheckandlinkare refused. A row-policy App’s public intake inserts once, so it refusessave.
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=Strictcookie; 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),atandvia(same-device,status-checkoremail) 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>/minelists the account’s own applications by the owner column alone. linkneeds the form’sstatusCheck, and all its columns nullable and owned by the server: no step may ask or write them.
Several records at once
Section titled “Several records at once”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.
Receipts
Section titled “Receipts”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.
Events and jobs
Section titled “Events and jobs”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' },});scheduleturns the event into the command’s target, input and due time. It must be pure: a re-sent event plans the same job.runAsis a service principal holding App roles; every attempt is authorized again, and a role with generic writes is refused.- Startup and
--check-apprefuse unresolved commands or events, a principal that is not granted its command, and cycles;--check-appprints 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.