Showroom
Derived automatically
model Lead {id Intname String (1, 200)email Stringstatus LeadStatus (new)value Float (0)ownerId IntcreatedAt DateTime (now())(0.4.4.5)(read, ownerId == auth().id)([status, createdAt])(audit)}
Pick one. The schema above highlights the lines responsible.
Data realm
API realm
UI realm
Direction — not yet built
The schema is the migration source. fli diffs it against the database and writes the SQL; you never hand-write a CREATE TABLE.
Derived from the field list — diffed against the live database
$ 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,
status TEXT NOT NULL DEFAULT 'new',
value REAL NOT NULL DEFAULT 0,
ownerId INTEGER NOT NULL,
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
);
Indexes are declared beside the data they index, so they travel with the model instead of living in a migration nobody reads.
Derived from @@index([...]) and @unique
-- from @@index([status, createdAt])
CREATE INDEX leads_status_createdAt
ON leads (status, createdAt);
-- from @unique on email
CREATE UNIQUE INDEX leads_email ON leads (email);
Policies compile into the WHERE clause of every query. Not a filter you remember to apply — a filter no caller can skip.
Derived from @@allow(read, ownerId == auth().id)
const db = client.$setAuth(user) // user.id = 42
await db.lead.findMany({ where: { status: 'active' } })
-- the SQL actually executed:
SELECT * FROM leads
WHERE status = 'active'
AND ownerId = 42 -- appended by the policy
// asSystem() is the one documented bypass:
await client.asSystem().lead.findMany() // no policy applied
One ordinal level per operation, read from the Trust Hierarchy. Read is open, writes need a user, deletes need an admin.
Derived from @@gate(0.4.4.5)
(0.4.4.5)
// │ │ │ └── delete : 5 ADMINISTRATOR
// │ │ └──── update : 4 USER
// │ └────── create : 4 USER
// └──────── read : 0 STRANGER
await db.lead.findMany() // ✓ anyone
await db.lead.create({ ... }) // ✗ AccessDeniedError unless level >= 4
// Junction maps its session to the same scale:
new GatePlugin({ getLevel: user => user ? LEVELS.USER : LEVELS.STRANGER })
model Lead becomes db.lead — PascalCase singular in, camelCase accessor out. Filters, ordering and pagination are typed from the fields.
Derived from the model name and its fields
const hot = await db.lead.findMany({
where: { status: 'active', value: { gte: 5000 } },
orderBy: { createdAt: 'desc' },
limit: 20,
})
const one = await db.lead.findUnique({ where: { email: '[email protected]' } })
await db.lead.update({
where: { id: 7 },
data: { status: 'closed' },
})
One command turns the schema into declarations. Separate Create and Update shapes, so generated fields are not required on insert.
Derived from the field list and their nullability
$ litestone types ./types.d.ts =client
export interface Lead {
id: number
name: string
email: string
status: LeadStatus
value: number
ownerId: number
createdAt: string
}
export interface LeadCreate { name: string; email: string; /* … */ }
export interface LeadUpdate { name?: string; email?: string; /* … */ }
export type LeadStatus = 'new' | 'active' | 'closed'
Naming the model on a service derives the whole CRUD surface. Six lines of registration, five endpoints.
Derived from model: 'lead' on the service
app.services.register(createService({
name: 'leads',
model: 'lead',
}))
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
GET /api/leads?$limit=20&$offset=40&$orderBy=-createdAt
Field rules become 400s at the API edge. Write the constraint once, on the data, and every entry point enforces it.
Derived from @length, @email, @gte on the fields
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"
}
}
The gate becomes 401s and 403s without a guard in the handler. A Litestone denial is normalized into the framework error shape.
Derived from @@gate and @@allow — enforced below the service
POST /api/leads → 401 no session
POST /api/leads (level 4 user) → 201
DELETE /api/leads/7 (level 4 user) → 403 needs ADMINISTRATOR
// toFrameworkError() maps across the package boundary:
// AccessDeniedError → 403 Forbidden
// ValidationError → 400 BadRequest (+ field errors on .data)
// Every response, success or failure, is one shape.
The bridge between realms. Junction validates against it; Sierra ships it to the browser so the UI knows the same rules.
Derived from generateJsonSchema(schema)
import { generateJsonSchema } from '@frontierjs/litestone'
{
"Lead": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 200 },
"email": { "type": "string", "format": "email" },
"status": { "enum": ["new", "active", "closed"], "default": "new" },
"value": { "type": "number", "minimum": 0 }
}
}
}
Mounted automatically. Every registered service contributes its paths, and the models become the component schemas.
Derived from the registered services and their models
app.configure(openapi({ title: 'Leads API', version: '1.0.0' }))
GET /openapi.json
{
"openapi": "3.1.0",
"paths": {
"/api/leads": { "get": { … }, "post": { … } },
"/api/leads/{id}": { "get": { … }, "patch": { … }, "delete": { … } }
},
"components": { "schemas": { "Lead": { … } } }
}
One announcement point. Every mutation fans out to the event bus and to the declared channel — no second publish path to forget.
Derived from channel: 'leads' on the service
const live = announce(() => app.channel('leads'))
createService({
name: 'leads', model: 'lead',
channel: 'leads',
hooks: { after: { create: [live], patch: [live], remove: [live] } },
})
// → WebSocket subscribers receive:
{ "type": "leads created", "data": { "id": 8, "name": "Acme" } }
The UI half of the service. Identical hook phases to the API realm, on purpose: learn the pipeline once and it holds on both sides.
Derived from the service name — and the same four phases as the API
const leads = createResource('leads', {
hooks: {
around: { all: [async (ctx, next) => {
busy = true; await next(); busy = false
}] },
before: { create: [ctx => { ctx.data.source = 'web' }] },
after: { find: [ctx => ctx.result.data.forEach(fmt)] },
error: { all: [ctx => toast(ctx.error.message)] },
},
})
await leads.find({ status: 'active' })
await leads.create({ name: 'Acme', email: '[email protected]' })
make() builds an empty record shaped by the schema, with declared defaults already applied — the starting value for a create form.
Derived from the JSON Schema defaults
const draft = leads.make()
{
name: '',
email: '',
status: 'new', // from @default(new)
value: 0, // from @gte(0)
// id and createdAt are omitted — the database owns them
}
// bind it straight to inputs, then:
await leads.create(draft)
The consuming side of the same publish. The resource is a reactive object, so a pushed row re-renders whatever reads it.
Derived from the channel the service declared
<script>
const leads = createResource('leads')
await leads.find()
leads.subscribe() // joins the 'leads' channel
</script>
<!-- no refresh logic: the list is reactive -->
{#each leads.data as lead}
<tr><td>{lead.name}</td><td>{lead.status}</td></tr>
{/each}
Direction, not a feature. The inputs exist — Sierra already ships the JSON Schema to the client — but nothing renders from it yet.
Derived from not built yet — the JSON Schema is already in the browser
// PROPOSED — this does not work today.
<Form model="Lead" />
// would derive, from the schema alone:
// email → <input type="email" required>
// name → maxlength=200
// status → <select> of the enum
// value → <input type="number" min="0">
// create gate fails → submit disabled, no round trip
// Tracked in IDEAS/overview.md as the #1 gap:
// derivation currently stops at the API boundary.
Direction. Litestone is SQLite, so the same schema, queries and gates can run in the browser. Sync and conflict policy are the open design.
Derived from not built yet — but one engine already runs on both sides
// PROPOSED — this does not work today.
model Lead {
// …
(lww) // conflict policy declared, not hand-rolled
}
// The gate is evaluated locally before queueing, so the user is told
// "you can't do that" with no round trip — and the server re-checks
// on sync. Only possible because authorization lives in the schema.
// Tracked in IDEAS/offline-first-and-release.md.
packages/junction/example/.← Back to the overview