FrontierJS
Skip to the showroom

Showroom

One model.
Everything below it, derived.

model Lead

Database

  • Migrations
  • SQL & indexes
  • Row policies
  • Gates
  • Typed client

REST API

  • Endpoints
  • Validation
  • Authorization
  • OpenAPI
  • WebSockets

UI Resource

  • Hook pipeline
  • Blank records
  • Live updates
  • TypeScript
  • JSON Schema

Derived automatically

The seed

model Lead {  id        Int          name      String     (1, 200)   email     String         status    LeadStatus (new)  value     Float      (0)  ownerId   Int  createdAt DateTime   (now())   (0.4.4.5)  (read, ownerId == auth().id)  ([status, createdAt])  (audit)}

Where it is leveraged

Pick one. The schema above highlights the lines responsible.

Data realm

API realm

UI realm

Direction — not yet built

Migration SQL

Data

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

Data

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);

Row policies

Data

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

Gates

Data

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 })

Typed client

Data

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' },
})

TypeScript types

Data

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'

REST endpoints

API

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

Validation

API

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"
  }
}

Authorization

API

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.

JSON Schema

API

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 }
    }
  }
}

OpenAPI

API

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": { … } } }
}

Live events

API

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" } }

Resource + hooks

UI

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]' })

Blank records

UI

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)

Live updates

UI

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}

Generated forms

proposed

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.

Offline-first

proposed

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.
Every sample here is the real API — see packages/junction/example/.← Back to the overview