FrontierJS

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.

Step 1 of 15Use ← → to move

db/schema.litelitestone

model Lead {  id        Int        name      String  email     String  value     Float  createdAt DateTime (now())}

Describe the data

Data

Everything 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        Int        name      String   (1, 200)   email     String       value     Float    (0)  createdAt DateTime (now())}

Constrain it

Data

Field 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        Int        name      String   (1, 200)   email     String       value     Float    (0)  createdAt DateTime (now())   (0.4.4.5)}

Say who may do what

Data

One 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        Int        name      String   (1, 200)   email     String       value     Float    (0)  ownerId   Int  createdAt DateTime (now())   (0.4.4.5)  (read, ownerId == auth().id)}

Say which rows

Data

Gates 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        Int        name      String   (1, 200)   email     String       value     Float    (0)  ownerId   Int  createdAt DateTime (now())   (0.4.4.5)  (read, ownerId == auth().id)  ([ownerId, createdAt])  (audit)}

Ship the schema

Data

The 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

API

Naming 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()
No new code this step. This is derived from @length, @email and @gte.

The rules became 400s

API

Nothing 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()
No new code this step. This is derived from @@gate and @@allow.

The gate became 401s and 403s

API

Also 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()
No new code this step. Directives are parsed by the transport bridge.

Paginate and sort

API

Transport 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

API

One 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

UI

A 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 = false   const 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

UI

The 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

UI

make() 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

UI

The 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

UI

Three 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/
Every sample is the real API — see packages/junction/example/.Prefer to browse? → Showroom