Walkthrough
Build it once, step by step.
One model becomes a database, an API, and a UI. Step forward and watch the files grow on the left — and what each line buys you on the right.
db/schema.litelitestone
model Lead {id Intname Stringemail Stringvalue FloatcreatedAt DateTime (now())}
Describe the data
DataEverything starts in one file. Model names are PascalCase and singular — always.
You already have a typed client
import { createClient } from '@frontierjs/litestone'
const db = await createClient({ db: 'app.db', schema: './db/schema.lite' })
// model Lead → accessor db.lead
await db.lead.create({ data: { name: 'Acme', email: '[email protected]', value: 12000 } })
await db.lead.findMany({ where: { value: { gte: 5000 } }, limit: 20 })
db/schema.litelitestone
model Lead {id Intname String (1, 200)email Stringvalue Float (0)createdAt DateTime (now())}
Constrain it
DataField rules live on the field. You will not write these checks anywhere else — that is the whole point.
Enforced at the data layer
await db.lead.create({ data: { name: '', email: 'nope', value: -5 } })
// ValidationError — nothing was written
{
name: 'must be between 1 and 200 characters',
email: 'must be a valid email address',
value: 'must be >= 0'
}
// @trim and @lower are transforms, applied on the way in.
db/schema.litelitestone
model Lead {id Intname String (1, 200)email Stringvalue Float (0)createdAt DateTime (now())(0.4.4.5)}
Say who may do what
DataOne ordinal level per operation, read against the Trust Hierarchy. Read is open; writing needs a user; deleting needs an admin.
Authorization, below every caller
(0.4.4.5)
// │ │ │ └── delete : 5 ADMINISTRATOR
// │ │ └──── update : 4 USER
// │ └────── create : 4 USER
// └──────── read : 0 STRANGER
const scoped = db.$setAuth(user) // level comes from GatePlugin
await scoped.lead.findMany() // ✓ anyone
await scoped.lead.create({ ... }) // ✗ AccessDeniedError below level 4
await db.asSystem().lead.create({...}) // the one documented bypass
db/schema.litelitestone
model Lead {id Intname String (1, 200)email Stringvalue Float (0)ownerId IntcreatedAt DateTime (now())(0.4.4.5)(read, ownerId == auth().id)}
Say which rows
DataGates are per-operation. Policies are per-row, and they compile into SQL — so this is not a filter a caller can forget.
Appended to every query
await db.$setAuth(user).lead.findMany({ where: { value: { gte: 5000 } } })
-- the SQL actually executed (user.id = 42):
SELECT * FROM leads
WHERE value >= 5000
AND ownerId = 42 -- from the policy. Not optional.
// There is no code path that skips this, because it is not in a code path.
db/schema.litelitestone
model Lead {id Intname String (1, 200)email Stringvalue Float (0)ownerId IntcreatedAt DateTime (now())(0.4.4.5)(read, ownerId == auth().id)([ownerId, createdAt])(audit)}
Ship the schema
DataThe schema is the migration source. fli diffs it against the live database and writes the SQL.
fli db:migrate
$ fli db:migrate
-- migrations/0001_init.sql (generated)
CREATE TABLE leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
value REAL NOT NULL DEFAULT 0,
ownerId INTEGER NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX leads_ownerId_createdAt ON leads (ownerId, createdAt);
$ litestone types ./types.d.ts # and TypeScript, from the same file
api/server.tsjunction
import { createApp, createService } from '@frontierjs/junction'const app = createApp({ db, auth, config: { port: 3200, apiPrefix: '/api' } })app.services.register(createService({name: 'leads',model: 'lead',}))await app.start()
Put it on the wire
APINaming the model on a service derives the whole CRUD surface. This is the entire server.
Five endpoints, no handlers
GET /api/leads find → { object, data, total }
GET /api/leads/:id get
POST /api/leads create
PATCH /api/leads/:id patch
DELETE /api/leads/:id remove
// createApp({ db }) also scopes Litestone per request, so the gate and
// the policy you wrote in Act 1 already see the calling user.
api/server.tsjunction
import { createApp, createService } from '@frontierjs/junction'const app = createApp({ db, auth, config: { port: 3200, apiPrefix: '/api' } })app.services.register(createService({name: 'leads',model: 'lead',}))await app.start()
The rules became 400s
APINothing was added to the server. The field rules from Act 1 are enforced at the edge because the API reads the same schema.
POST /api/leads
{ "name": "", "email": "not-an-email", "value": -5 }
→ 400 Bad Request
{
"name": "BadRequest",
"code": 400,
"data": {
"name": "must be between 1 and 200 characters",
"email": "must be a valid email address",
"value": "must be >= 0"
}
}
api/server.tsjunction
import { createApp, createService } from '@frontierjs/junction'const app = createApp({ db, auth, config: { port: 3200, apiPrefix: '/api' } })app.services.register(createService({name: 'leads',model: 'lead',}))await app.start()
The gate became 401s and 403s
APIAlso derived. A Litestone denial is normalized into the framework error shape, so success and failure are one contract.
The same gate, over HTTP
GET /api/leads → 200 read is level 0
POST /api/leads → 401 no session
POST /api/leads (level 4 user) → 201
DELETE /api/leads/7 (level 4 user) → 403 needs ADMINISTRATOR
// And the row policy still applies — a level 4 user listing leads
// sees only their own, because the WHERE clause says so.
api/server.tsjunction
import { createApp, createService } from '@frontierjs/junction'const app = createApp({ db, auth, config: { port: 3200, apiPrefix: '/api' } })app.services.register(createService({name: 'leads',model: 'lead',}))await app.start()
Paginate and sort
APITransport syntax uses $ prefixes; nothing past the bridge ever sees one. Filters land in ctx.query, shaping lands in ctx.directives.
GET /api/leads?value[gte]=5000&$limit=2&$orderBy=-createdAt
{
"object": "list",
"data": [
{ "id": 8, "name": "Acme Corp", "value": 12000 },
{ "id": 5, "name": "Globex Inc", "value": 8500 }
],
"total": 17,
"limit": 2,
"offset": 0
}
// A list keeps its envelope; a single record unwraps.
api/server.tsjunction
import { createApp, createService, announce } from '@frontierjs/junction'const app = createApp({ db, auth, config: { port: 3200, apiPrefix: '/api' } })const live = announce(() => app.channel('leads'))app.services.register(createService({name: 'leads',model: 'lead',channel: 'leads',hooks: { after: { create: [live], patch: [live], remove: [live] } },}))await app.start()
Broadcast changes
APIOne announcement point. Every mutation fans out to the event bus and to the declared channel — there is no second publish path to forget.
Over the WebSocket
// a client subscribed to the 'leads' channel receives:
{ "type": "leads created", "data": { "id": 9, "name": "Initech" } }
{ "type": "leads patched", "data": { "id": 5, "value": 9100 } }
{ "type": "leads removed", "data": { "id": 2 } }
// Same gate applies on the way out — subscribers only get rows
// their policy would have let them read.
src/routes/leads/index.mesasierra
<script>import { createResource } from '@frontierjs/sierra/resource'const leads = createResource('leads')await leads.find({ value: { gte: 5000 } })</script><table class="table">{#each leads.data as lead}<tr><td>{lead.name}</td><td>{lead.value}</td></tr>{/each}</table>
Bind it to a page
UIA route is a file. createResource gives you the service as a reactive object — no fetch, no store wiring.
The file path is the route
src/routes/leads/index.mesa → /leads
src/routes/leads/[id].mesa → /leads/42
src/routes/_module.mesa → the layout wrapping both
// leads.data is reactive. The table re-renders when it changes;
// there is no subscribe-and-setState step to write.
src/routes/leads/index.mesasierra
<script>import { createResource } from '@frontierjs/sierra/resource'let busy = falseconst leads = createResource('leads', {hooks: {around: { all: [async (ctx, next) => {busy = true; await next(); busy = false}] },before: { create: [ctx => { ctx.data.source = 'web' }] },error: { all: [ctx => toast(ctx.error.message)] },},})await leads.find({ value: { gte: 5000 } })</script>
Step into the pipeline
UIThe same four hook phases as the API realm — around, before, after, error — with a Context that mirrors it. Learn the pipeline once.
Identical on both sides of the wire
// UI — src/routes/leads/index.mesa
hooks: { around: {...}, before: {...}, after: {...}, error: {...} }
// API — api/server.ts
hooks: { around: {...}, before: {...}, after: {...}, error: {...} }
// Pipeline, both realms:
// around:enter → before → [work] → after → around:exit
// ↓ on throw
// error
// ctx.params is UI-only and never crosses the wire.
src/routes/leads/index.mesasierra
<script>import { createResource } from '@frontierjs/sierra/resource'const leads = createResource('leads')await leads.find()let draft = leads.make()</script><form onsubmit={() => leads.create(draft)}><input class="field" bind:value={draft.name} placeholder="Name"><input class="field" bind:value={draft.email} placeholder="Email"><button class="btn">Add lead</button></form>
Start a blank one
UImake() builds an empty record shaped by the schema, with its declared defaults already applied. The starting value for a create form.
Shaped by the same JSON Schema
leads.make()
{
name: '',
email: '',
value: 0, // from @gte(0)
// id, ownerId and createdAt are omitted — the server owns them
}
// The browser has the JSON Schema, so it knows email is an email and
// name maxes at 200. Rendering a full form from it is the next step
// for the framework — see the Vision section.
src/routes/leads/index.mesasierra
<script>import { createResource } from '@frontierjs/sierra/resource'const leads = createResource('leads')await leads.find()leads.subscribe() // joins the 'leads' channel</script><table class="table">{#each leads.data as lead}<tr><td>{lead.name}</td><td>{lead.value}</td></tr>{/each}</table>
Make it live
UIThe consuming side of the publish you declared in Act 2. One line, and the table follows the database.
No refresh logic anywhere
// Someone else POSTs a lead. Your table gains a row.
server ── create ──▶ channel 'leads'
│
▼
browser ── leads.data ──▶ re-render
// You wrote: one subscribe() call, and channel: 'leads' on the service.
// Everything between them was already there.
the whole thingsierra
db/schema.lite 13 lines the seedapi/server.ts 12 lines the APIsrc/routes/leads/index.mesa 14 lines the UI# derived, unwritten:# migrations + indexes + audit trail# row policies compiled into SQL# five REST endpoints + pagination# 400s from the field rules# 401s and 403s from the gate# TypeScript types + JSON Schema# WebSocket events, both directions
That is the app
UIThree files. Everything else was derived from the first one — and it all traces back to a schema you can read in ten seconds.
Where to go next
$ npx /cli@0.1 new my-app
# then read, in this order:
# ARCHITECT.md the mental model and the vocabulary
# PHILOSOPHY.md the axioms underneath it
# DECISIONS.md why things are the way they are
# and the runnable ladder:
# packages/junction/example/minimal/
# packages/junction/example/elegant.ts
# packages/junction/example/fullstack/