DSL reference
This page lists every key an App definition takes; the DSL in one App builds one step by step, and the guides linked from each section show the keys at work.
import { defineApp } from 'tablewalk/app';
export default defineApp({ id: 'compass', name: 'Compass CRM', connection: 'compass', resources: { opportunity: {} }, views: { opportunities: { kind: 'list', table: 'opportunity', label: 'Opportunities' } }, home: 'opportunities', nav: ['opportunities'], ui: { shell: { placement: 'top' } },});An App directory holds exactly one app.ts, app.mjs or app.json. The
exported definition is plain data: helpers such as defineApp, listView,
page and metric return ordinary objects and keep your literals, so a column
a helper names is still type-checked. Every place takes a fixed set of keys; an
unknown key is refused at load with the nearest spelling (Did you mean "label"?) or where it belongs. The complete types are
schema/app.d.ts.
Scaffold from a schema
Section titled “Scaffold from a schema”npx tablewalk mydata.sqlite --scaffold-app ./myapp --name "My App"npx tablewalk check --app ./myapp mydata.sqlitenpx tablewalk mydata.sqlite --app ./myappThe scaffold writes app.ts, resources.ts, views.ts, dashboard.ts, a
record page per main table and per table others point at under pages/,
tablewalk-schema.d.ts, tsconfig.json and a README, in the samples’ style:
a look, a logo and a grouped top menu; tones, money and units from the
catalog; a record with no name of its own named by its parent; a record page
with its parent’s card and its current child beside the tabs; and each New
form in groups named by what they hold, with its created-on date starting today. Line tables become related
lists, not menu entries; migration bookkeeping (Liquibase, Flyway) is left out,
a table without a primary key is listed but gets no page, and a read-only
connection gets no “Add your first” steps. A --config naming several
connections drafts one App with a source per connection. It passes tablewalk check
as written; rename and remove what you do not need. It never migrates the database.
The root
Section titled “The root”Only name and home are required.
| Key | Says |
|---|---|
id |
The App’s address (/{id}) and account store. Default: a slug of name (Help Desk → /help-desk). Set it before creating accounts. |
name |
What the App is called. |
connection |
One configured connection name. Never a URL. Not beside sources. |
sources |
Named bindings, { lending: { connection: 'loans', default: true } }; at most one is default, and each may name a schema and its resources. See sources. |
resources |
One block per table the App uses. Resources. |
views |
Everything a visitor can open, keyed. Views. |
pages |
Record pages, keyed, or one file per page under pages/. Pages. |
features |
Ordered slices merged at load. Features. |
home |
Where a visitor starts. Home. |
nav |
The menu. Optional. Nav. |
auth |
Sign-in, row scoping and roles. Auth. |
ui |
Chrome, look and formats. UI. |
api |
What other programs may reach, built with expose(). Preview; see the App API. |
Resources
Section titled “Resources”resources: { opportunity: { access: 'write', display: { amount: money(), stage: tones({ closed_won: 'good', closed_lost: 'bad' }) }, labels: { name: 'Opportunity', 'account_id.name': 'Account' }, form: { show: ['name', 'account_id', 'amount', 'close_date'], defaults: { stage: 'prospecting' } }, actions: [action('Mark won', { set: { stage: 'closed_won' }, when: 'stage = negotiation' })], }, account: { access: 'read' }, product: {},},Every table a view, dashboard or page reads needs a block; {} is enough:
an App serves only its declared resources (what an App serves). A
resource’s logical name is bare for the default source and source.name for
another; PostgreSQL’s public.opportunity is found as opportunity.
| Key | Says |
|---|---|
physical |
The physical table id when the name does not find it: 'archive.opportunity'. |
access |
The App’s ceiling: 'read' or 'write'. Once any resource declares one, the others are not written. The connection’s "writable": true still applies. |
delete |
true offers Delete (last in the record’s More menu). Needs access: 'write'. |
favorite |
true stars records into the menu’s Favorites. |
settings |
A one-row settings record opened from the account menu under these words: 'Company profile'. |
file |
The rows are files: { content, name, type?, size?, thumbnail?, maxBytes? } (default 10 MB, at most 25 MB). Preview. |
tags |
Chips under the title: { through, to, tone? }, a link table and the tags table. Preview. |
search |
Text filters asked of a Typesense connection: { connection, fields }. Preview; see services. |
transitions |
{ status: 'actions' }: the column changes only through the resource’s actions, checked on the server. |
fields |
A column ceiling for signed-in roles: { read, write? }. Needs access and auth.roles. |
hidden |
Columns no reader is ever served, signed in or not: ['password_hash']. Hidden columns. |
references |
Soft keys followed like foreign keys: { product_sku: { to: 'product', column: 'sku' } }. Declared references. |
display |
How each column’s values dress. Display rules. |
types |
SQLite: a TEXT column of ISO dates read as a date, { due_on: 'date' }. Dates and types. |
inputs |
A column’s form control drawn by an input component. |
validation |
Invariants checked on every write. Validation. Preview. |
labels |
One caption per column or label path, used everywhere. A string or a copy() reference. |
recordLabel |
What names one record. Record names. |
form |
The New form: show, defaults (a value, or 'today', 'now', 'me'), groups (label, description, fields), visibleIf ({ due_on: 'completed = false' }), steps (preview), description, open ('drawer' or 'page') and fields (label, help, placeholder, multiline, where). See forms. |
actions |
What may be done to one record: action(label, { set }) or action(label, { command }). See actions & commands. |
page |
Which page records open through, when two pages share the base. |
view |
Which list view addresses its records, when several list them. Addresses. |
address |
A natural key column used in the address instead of the primary key. Must be NOT NULL and unique. |
slug |
true follows a public record’s id with its name: /forecourt/vehicles/1-2024-toyota-corolla-le. |
noun |
What one record is called: 'issue' or { one: 'person', many: 'people' }. Names New and default labels. |
owner |
Under auth.rows: 'tenant': 'tenant', 'global' or 'platform'. |
audit |
{ redact: ['salary'] } or 'values': columns history and audit keep only as “changed”. |
import |
CSV import beside New: true, or { command, match }. Preview; see CSV import. |
rollups |
Numbers about the rows that point at each record. Rollups. |
notify |
Who is told about a write, in their inbox. See notifications. |
Every yes/no key takes true or false, and false means what leaving it out means.
Display rules
Section titled “Display rules”display is keyed by column. Helpers: money(currency?, { unit? }),
number({ unit? }), percent(), relative(), items(), tones(map). A
unit after money is its second argument: money('USD', { unit: '/mo' }) reads
“$1,850/mo”, and money(undefined, { unit: '/mo' }) keeps ui.currency.
| Key | Says |
|---|---|
format |
money, percent, number, relative, items, duration (seconds as “2h 15m”, preview), email, phone, url, masked (a placeholder, not protection). |
masked |
{ prefix: 'ENC:' }: only values starting with the prefix draw as a placeholder. |
currency |
ISO 4217 for money; default ui.currency, else USD. |
unit |
Said after a number or money figure: 'mi', 'kg', '/mo'. |
tone |
Value → good, warn, bad, info or neutral; numeric thresholds ('>= 90') and '*' as fallback. |
bar, max |
Draw the value as a bar, against 100 for a percent or against max. |
avatar |
true for initials, or { from, whose, sort? } for a photo from a file resource. |
icons |
Value → an App icon drawn before the word: { rush: 'fire' }. |
values |
Stored value → the word it is said as: { PUB: 'Published' }. tone and icons stay keyed by the stored value. |
humanize |
true says every value values does not name as words: IN_REVIEW reads “In review”. |
subtitle |
Another column said as a muted second line in lists. |
component |
A value component that draws the value. |
Rollups
Section titled “Rollups”import { count, money, sum } from 'tablewalk/app';
account: { rollups: { open_deals: count('opportunity', { query: 'stage != closed_won and stage != closed_lost' }), open_pipeline: sum('opportunity', 'amount', { query: 'stage != closed_won and stage != closed_lost' }), }, display: { open_pipeline: money() },},count(from, opts?), sum(from, column, opts?), min(…) and max(…) read
the from rows that point at the record; whose names the reference when
there are several, and query narrows over from’s own columns. A rollup
reads like a column in show, sorts, tabs, conditions, fields, cards, queues,
measures and breakdown keys, and through a reference (customer_id.open_deals).
A rollup reaches two hops either way: whose: 'task_id.project_id' reads a
project’s time entries through its tasks, or one rollup reads another; no
more. It is computed in the same statement, under the grants, hidden columns
and row policy of every table it walks, and is never written. Regenerate types after adding one. Not on tenant Apps or API sources.
Record names
Section titled “Record names”saved_vehicle: { recordLabel: 'vehicle_id.title' },purchase_order: { recordLabel: 'PO-{id}' },po_line: { recordLabel: '{product_id.name} × {qty}' },A column, a label path or a template in braces, computed in SQL so lists,
pickers and breadcrumbs sort and search the same name. A reader who may not
read a named column sees the key. Without recordLabel, a table with no name
column reads as its noun and key: “Purchase order #3”.
Conditions: me, paths and other columns
Section titled “Conditions: me, paths and other columns”Every condition — a list’s query, a tab, a dashboard section’s query, a
when — is the query language.
tabs: [ { label: 'Mine', query: 'owner_id = me' }, { label: 'Low', query: 'on_hand < product_id.reorder_level' }, { label: 'Pricey', query: 'product_id.unit_cost > 50' },],meis the signed-in person, from the verified session: their email beside a text column, or their row beside a reference onceauth.me: { table: 'employee', column: 'email' }says where people live. Needsauth; not in tenant Apps,visibleIf, validationifor a page section’sfilter.- A path walks references:
product_id.unit_cost > 50. Lists, tabs, dashboards and a page section’swhentake paths; an action’swhenreads the record’s own columns. - Another column is compared by naming it on the right: a path always, a
bare word beside a number, date or flag (
shipped_on > promised_on). Beside text a bare word is a value.
Validation
Section titled “Validation”validation: [ { kind: 'compare', field: 'close_date', operator: 'gte', other: 'created_on', message: 'A deal cannot close before it was created.' }, { kind: 'compare', field: 'probability', operator: 'eq', value: 100, if: 'stage = closed_won', message: 'A won deal is at 100% probability.' },],A rule is required or compare (eq, ne, lt, lte, gt, gte,
against other or value), optionally under if. if and visibleIf
compare local columns only (=, !=, <, <=, >, >=, in, is (not) empty, and, or, brackets; at most 16). At most 128 rules per resource.
views holds everything a visitor can open. The key is the view’s address,
lowercase letters, digits and hyphens. nav arranges the menu over some of
them: a list the menu does not name is still served at its address and offered
by ⌘K. Every dashboard and signed-in form must be reachable from nav, home
or an embed, or it is refused as an orphan.
kind |
Helper | What it is |
|---|---|---|
list |
listView(label, table, opts) |
The rows of one resource. |
dashboard |
dashboard(label, sections, opts) |
Sections about whole tables. |
landing |
landing(hero, sections, opts) |
The signed-out front page. See public pages. |
form |
commandForm(label, command, steps, opts) |
One form over one command; public: true serves it at /{app}/form/{key}. See command forms. |
room |
room(label, component, { reads, params }) |
A whole view drawn by a custom component. Preview. |
List views
Section titled “List views”views: { triage: listView('Triage', 'issue', { query: 'status = backlog or status = ready', show: ['name', 'status', 'priority', 'assignee_id.name'], presentation: queue('name', { lines: ['status', 'priority'] }), filters: ['priority', 'assignee_id'], open: 'modal', }),},| Key | Says |
|---|---|
table |
The resource the rows come from. |
label |
Heading and menu word. Default: the resource’s plural noun. |
path |
Its address segment, when not its key. |
icon |
Its menu mark: one of the App icons. |
query |
Standing conditions; tabs refine on top. |
show |
Columns and label paths. |
tabs |
{ label, query? }; a tab without a query shows every row. |
filters |
Columns offered as the search box and “+” pickers. [] means none. |
filterBar |
false hides the bar; 'rail' draws counted facets beside the rows (preview). |
advancedFilters |
true offers the raw query editor. |
sorts |
Sort menu choices: { label, sort }, e.g. 'price desc'. |
sort |
The default order, 'created_on desc'. Not beside order. |
order |
A numeric column holding each row’s place; editors drag rows or press Alt+↑/↓. |
presentation |
How rows draw; absent is a grid. Presentations. |
empty |
{ title, message?, action?: { label, view } } for a list with no rows. |
quickAdd |
true puts the New form above the rows. |
import |
This list’s CSV import, or false. |
favorite |
true stars the view into Favorites. |
totals |
Numeric columns summed in a footer over every matching row. Grid only. |
density |
'comfortable' or 'compact'. Grid only. |
rowActions |
true adds a ⋯ menu of the record’s actions to each row. Grid only. |
editable |
true or columns: cells edited in place and “Set field…” on a selection. Grid only. See editing in a list. |
open |
'page' (default), 'peek' or 'modal'. |
sizing |
'fit' (default) or 'scroll'. Grid only. |
actions |
Queue and board: which resource actions, by label, in order. |
realm |
'tenant' (default) or 'platform'. |
Presentations
Section titled “Presentations”| Helper | Draws |
|---|---|
checklist(primary, checked, { lines? }) |
A card per row with a checkbox. |
queue(primary, { lines?, facts?, badge?, openLabel? }) |
A keyboard-first selection list; bulk actions apply to the selection. |
{ kind: 'board', by, lanes?, order?, swimlanes?, limits?, primary, lines? } |
Lanes by one column or reference; cards move by drag, Alt+arrows or ⋯. limits are shown, never enforced. |
calendar(date, primary, { end?, lines?, badge? }) |
A month grid; with end, a bar across the days. Agenda on a phone. Preview. |
cards(primary, { value?, badge?, lines?, facts?, image?, media? }) |
A grid of cards; image is a URL column or { from, whose, sort? }; a fact { column: 'owners', label: 'owner' } reads “1 owner”, “3 owners”. Preview. |
{ kind: 'split', detail?, limit? } |
The list beside the selected record. |
customList(component, { show }) |
Rows drawn by a custom component. Preview. |
Queues, calendars and cards may name one-hop label paths (employee_id.name)
in their lines, facts and badges.
Dashboard views
Section titled “Dashboard views”import { dashboard, embed, metric, chart, rows } from 'tablewalk/app';
export const pipeline = dashboard('Pipeline', [ metric('Open pipeline', 'opportunity', { measure: 'sum amount', query: 'stage != closed_won and stage != closed_lost' }), chart('Bookings', 'opportunity', 'month close_date', { measure: 'sum amount', over: 'close_date' }), rows('Closing soon', 'opportunity', { query: 'close_date = next 30 days', show: ['name', 'amount', 'close_date'] }), embed('triage', { width: 'half', limit: 5 }),], { description: 'Keep the current pipeline in view.', windows: ['last 30 days', 'last 90 days'] });| Dashboard key | Says |
|---|---|
label, sections |
Required. |
heading, description |
Words over the sections; either may greet {me.name}. |
aside |
Sections in a column beside the main flow. |
table |
Default table for sections that name none. |
windows |
Periods offered to sections with over. Default last 30 / 90 days / 12 months. |
filters |
Pick-filters narrowing every section with that column. |
refresh |
Seconds between redraws, at least 5. |
path, icon, favorite, realm |
As on a list. |
| Section | Helper | Says |
|---|---|---|
| Metric | metric(title, table, { measure?, query?, compare?, better?, spark?, target?, view? }) |
One figure; compare: 'previous period' adds a change; view makes the card a link. |
| Chart | chart(title, table, by, { shape?, measure?, target?, height? }) |
shape: line (default), bar, donut, stacked (preview). |
| Breakdown | breakdown(title, table, by, { measure?, limit? }) |
Groups as bars: a vocabulary’s in its order, anything else biggest first. |
| Matrix | matrix(title, table, 'stage, month close_date', opts) |
Two keys as rows and columns. |
| Rows | rows(title, table, { show, sort?, limit? }) |
Matching rows; in the aside, at most three columns as a list. |
| Board | board(title, table, by, { lanes?, primary?, lines? }) |
Lanes, always full width. |
| Note | note(text, { title?, icon?, action? }) |
Plain-text guidance with one link. |
| First run | firstRun(title, steps) |
A welcome with next steps until the tables have rows. |
| Activity | activity(title, { resource: { icon } }, { limit? }) |
Recent changes across resources, from their history. |
| Embed | embed(view, { title?, tab?, limit?, interactive? }) |
A list view; interactive: true brings its search, filters and bulk actions. |
| Custom | custom(title, component, { rows, props }) |
A custom component. Preview. |
Every table section also takes query, over (the date column the window
narrows), windows (its own period picker, addressed
?period.<section-title>=), icon, title and width (full,
two-thirds, half, third). See dashboards.
import { page, summary, related } from 'tablewalk/app';
pages: { opportunity: page('Opportunity', 'opportunity', [ summary(['name', 'stage', 'amount']), related('Tasks', 'task', 'opportunity_id', { show: ['subject', 'status', 'due_on'] }), ]),},A page takes base (its resource), sections, and optionally label, path,
onlyOn (one connection), listTabs and realm. page('issue', [...])
leaves the label to the noun. The key is lowercase letters, digits, hyphens
and underscores; pages/<key>.json files are discovered too.
| Section | Helper | Says |
|---|---|---|
| Fields | fields(show, opts), summary(show) |
presentation: summary, accordion, headline, facts, table, properties, card; workflow: workflow(field, steps, { ordered }). |
| Related list | related(title, from, whose, opts) |
Rows pointing at the record: show, query, sort, limit, create, empty (or false), totals, same, about, presentation: 'gallery' or cards. |
| Similar | similar(title, from, { same, near? }) |
Rows like this one: near: { price: 0.25 } is ±25%. |
| Metric, breakdown, chart | metric(…, { whose }) etc. |
The dashboard helpers, narrowed to rows whose whose is this record. |
| Timeline | timeline(title, [stream(from, whose, at, show)]) |
Dated rows from up to four tables, merged. |
| History | history() |
The record’s own changes. See history. |
| Note | note(text, opts) |
Guidance on the record. |
| Embed | embed(view, { whose }) |
A list view narrowed to this record. |
| Custom | custom(title, component, { record, rows, props }) |
A custom component. Preview. |
Every section takes title, region ('aside' for the side panel), tab
(sections sharing a tab share a panel; preview), width, hidden and when
('status = converted'). See compose a record page.
Addresses
Section titled “Addresses”Every record, list, dashboard and form has one canonical address. tablewalk check --app <dir> --routes prints them all.
| What | Address |
|---|---|
| A list | /compass/leads |
| …with a tab, search, filter, sort | /compass/leads?tab=working&search=stone&source=referral&sort=-created_on |
| A record | /compass/leads/127 |
| A record’s tab | /compass/accounts/13/opportunities |
| The New form | /compass/leads/new |
| A dashboard and its window | /compass/sales-dashboard?period=last-30-days |
| A section’s own window | /depot/operations?period.lines-moved-by-day=last-30-days |
| A dashboard’s pick | /compass/sales-dashboard?owner_id=12 |
| A room and its place | /depot/pick-walk?list=12&line=3 (the params its room() declares) |
| A natural key | /thread/label/bug |
| A public slug | /forecourt/vehicles/1-2024-toyota-corolla-le |
| A composite key | /compass/opportunities/12/opportunity_line/3 |
- A record is addressed through the list view that shows its table: the
resource’s
view, else the first plain list over it in menu order. Back and the breadcrumbs still retrace the walk that reached it. - A split keeps its pick under its own key:
/thread/inbox/2. - A condition no filter can say is written
q=. pathrenames a view or page’s segment.new,api,_components,sign-in,welcomeandformare reserved.- Older links, including query-language ones, still open and are rewritten.
home: 'tasks',home: [{ view: 'welcome', signedOut: true }, { view: 'manager-desk', roles: ['manager', 'admin'] }, { view: 'portfolio' }],Required. A view id, or rules read in order on every request, first match
wins: signedOut: true (needs auth, names a public view), roles, and a
last rule with neither. A rule whose view the visitor cannot open gives way to
the next.
import { navGroup } from 'tablewalk/app';import type { App } from './tablewalk-schema.d.ts';
export const navigation = [ navGroup('sales', 'Sales', ['sales-dashboard', 'leads', 'opportunities', 'accounts'], { icon: 'chart-line' }), navGroup('activity', 'Activity', ['tasks', { view: 'activities', label: 'Calls & meetings' }]),] satisfies NonNullable<App['nav']>;Optional; without it there is no menu. An entry is a view id,
{ view, label }, a navGroup(id, label, children, { icon? }) (nesting up
to eight deep) or a feature placement { feature: 'id' }. Each list,
dashboard, signed-in form or room appears at most once; landings and public
forms are refused. Icons are Phosphor names from a curated set (house,
tray, kanban, calendar, users, package, receipt, gear and
more); a wrong name is refused with the nearest ones.
auth: { providers: ['email'], signUp: 'invite', defaultRole: 'viewer', roles: { viewer: { read: true }, manager: { read: true, write: ['opportunity'], commands: ['assignReviewer'], operations: ['readiness'] }, },},Present, every request needs a session. See sign-in, roles & data.
| Key | Says |
|---|---|
providers |
email (default), github, google. |
signUp |
'invite' (default) or 'open'. |
store |
SQLite file or postgres:// URL for accounts. Default: auth/{id}.db in tablewalk’s config directory. |
defaultRole |
A new account’s role. Default user. |
rows |
'all' (default), 'policy' (a server row policy) or 'tenant'. |
roles |
Grants within the ceilings; omitted grants deny. |
public |
What signed-out visitors read: { read: ['vehicle'], fields? }. Preview. |
tenancy |
With rows: 'tenant': { label?, profile?, platform? }. Preview. |
me |
{ table, column }: the table people are rows of, and its email column. |
A role takes read (true or a list), write (within its reads),
commands (command ids), actions ({ pay_run: ['Approve payroll'] }:
named fixed actions without write; see granting a fixed action),
operations ('readiness', 'bundle'), fields and, with a platform
realm, realm. An account holding admin manages accounts from the account menu.
ui: { logo: 'buildings', shell: { placement: 'side', width: 'wide', sidebar: { density: 'comfortable' } }, theme: { preset: 'canvas' }, editing: { save: 'blur' }, locale: 'en-US', timezone: 'America/New_York', currency: 'USD', footer: { text: 'Prices exclude tax.', links: [{ label: 'Terms', href: 'https://example.com/terms' }] },},| Key | Says |
|---|---|
logo |
An App icon beside the name (never an image URL). |
shell |
placement ('side' or 'top'), rail, menu, menuSearch, search, breadcrumbs, title, counts, width (full, wide, narrow), headerWidth, gutter, sidebar: { density, footer }. |
theme |
preset (mono default, canvas, iris, ink, sage, signal, tray, or a registered id), palette, and the accent, accentSoft, radius, font tokens. See looks. |
customThemes |
Looks registered with defineTheme. |
footer |
{ text?, links? } under every room; links are https:, mailto: or tel:. |
editing |
{ save: 'explicit' } (default) or { save: 'blur' }, saving a record field when it loses focus. |
export |
false hides Export. |
locale |
BCP 47 tag for every figure; default the reader’s browser. |
timezone |
Whose “today” date phrases mean. |
currency |
Default for money rules. Default USD. |
connect, images |
Origins custom components may fetch from or load pictures from. |
Types and validation
Section titled “Types and validation”npx tablewalk --config tablewalk.json --app ./myapp --export types > myapp/tablewalk-schema.d.tsnpx tablewalk --config tablewalk.json --app ./myapp --check-appnpx tablewalk check --app ./myapp --config tablewalk.jsonsatisfies App, with the generated type, checks table, column, connection,
view and look names in the editor, and label paths hop by hop for two hops:
views.ts(12,48): error TS2820: Type '"statuz"' is not assignable to type 'LabelPath<Tables, References, "ticket">'. Did you mean '"status"'?A list kept in its own const needs as const. --check-app runs the
runtime validation against the catalog, exits nonzero on problems, warns about
literals a column can never hold, and prints one preview: line counting
the preview features used (--preview-features lists each, and where). tablewalk check regenerates the types, typechecks and runs
--check-app in one command.
Module graph. Split an App into app.ts (identity, sources, auth, ui),
resources.ts, views.ts, navigation.ts and pages/. Modules run as
trusted server code in a fresh worker (not a sandbox); a broken edit keeps the
last good definition. Command handlers stay out of this graph. The worker
reads only the environment variables tablewalk.json lists for the App
(secrets); --app-entry <file> reads a manifest kept
elsewhere, with --app <dir> still the App’s root.
Features
Section titled “Features”A feature keeps one capability — its resources’ presentation, views, pages, menu fragment and grants — in one module, merged into the App at load.
import { defineApp, defineFeature, navGroup } from 'tablewalk/app';
const inventory = defineFeature({ id: 'inventory', resources: { vehicle: { display: { price: { format: 'money' } } } }, auth: { roles: { clerk: { read: ['vehicle'] } } }, views: { vehicles: { kind: 'list', table: 'vehicle', show: ['name', 'price'] }, }, nav: ['vehicles'],});
const desk = defineFeature({ id: 'desk', views: { desk: { kind: 'dashboard', label: 'Desk', sections: [{ kind: 'view', view: 'vehicles' }] } }, home: [{ view: 'desk' }],});
export default defineApp({ name: 'Dealership', resources: { lead: { access: 'write' }, vehicle: { access: 'read' } }, auth: { roles: { clerk: { read: ['lead'], write: ['lead'] } } }, views: { leads: { kind: 'list', table: 'lead' } }, features: [inventory, desk], home: [{ view: 'leads', roles: ['clerk'] }], nav: [navGroup('work', 'Work', ['leads', { feature: 'inventory' }, 'desk'])],});| Feature key | Says |
|---|---|
id |
Required, unique: names the feature in refusals, the authority report and { feature } placements. |
resources.<t> |
display, labels, validation, form, actions, page; a ceiling or binding only restated exactly. |
views, pages |
As in the App. |
nav |
A fragment of its own views, placed once by the App’s { feature: 'id' }. |
home |
Rules appended after the App’s. |
auth.roles |
Grants to roles the App declares, by resource name, within its ceilings. |
The App keeps name, connection, sources, ui, sign-in and which roles
exist. Merge rules: ids are global; identical facts merge and conflicting ones
are refused naming both contributors; actions and validation rules
concatenate; authority only adds. Put each feature in features/<id>.ts
(watched for reload) and type it with defineFeature({ … } satisfies Feature).
The authority lock
Section titled “The authority lock”npx tablewalk --config tablewalk.json --app ./myapp --check-app --write-authoritywrites authority.lock beside the App: every grant, ceiling, deletion
opt-in, public read, source binding and component origin, one line each,
naming who contributes it:
resource lead access write — the Approle clerk read vehicle — features "inventory"From then on --check-app fails with the difference whenever the App’s
authority changes, the server refuses to start an App that differs from its
lock, and a reload that changes authority waits for a restart. Review the
diff, then rewrite the lock.