FrontierJS

The ripple

Change one line.
Watch what moves.

Scaffolding generates code once and then drifts. Derivation does not. Pick an edit to the schema — the diff appears on the left, and everything downstream that moves because of it appears on the right. Including, importantly, the things that don't.

db/schema.lite

  value     Float    (0)  company   String?   (1, 120)  ownerId   Int

5 things moved. 1 deliberately did not.

moved

Migration

Data

<code>ALTER TABLE leads ADD COLUMN company TEXT</code> — diffed, not hand-written.

moved

TypeScript

Data

<code>company?: string</code> on <code>Lead</code>, <code>LeadCreate</code> and <code>LeadUpdate</code>.

moved

Validation

API

Optional, but trimmed and length-checked when present. A 400 you did not write.

moved

OpenAPI

API

The <code>Lead</code> component schema gains the property automatically.

unchanged

Endpoints

API

Same five routes. A field is not a route.

moved

make()

UI

<code>leads.make()</code> now returns a <code>company</code> key, ready to bind.

db/schema.lite

  email     String       value     Float    (0)  value     Float    (100) (1000000)  ownerId   Int

4 things moved. 2 deliberately did not.

moved

Validation

API

<code>value: 50</code> now 400s with "must be >= 100". One edit, every entry point.

moved

Direct writes

Data

<code>db.lead.create()</code> rejects it too — the rule is on the data, not the route.

moved

JSON Schema

API

<code>minimum: 100</code>, <code>maximum: 1000000</code> — shipped to the browser.

unchanged

Migration

Data

No schema change. The column is unchanged; only the rule moved.

unchanged

TypeScript

Data

Still <code>value: number</code>. Types carry shape, not range.

moved

Form bounds

UI

The browser has the same JSON Schema, so the constraint is known client-side.

db/schema.lite

  createdAt DateTime (now())   (0.4.4.5)  (4.4.4.5)

4 things moved. 2 deliberately did not.

moved

HTTP

API

<code>GET /api/leads</code> flips from <code>200</code> to <code>401</code>. No handler edited.

moved

Direct reads

Data

<code>db.lead.findMany()</code> throws below level 4. The API is not the only door being locked.

moved

Live events

API

Channel subscribers are held to the same level — the gate applies on the way out.

unchanged

asSystem()

Data

Still bypasses, by design. It is the one documented escape and it stays explicit.

unchanged

Endpoints

API

The routes still exist. They answer 401 instead of 200 — a different thing from vanishing.

moved

Resource

UI

<code>leads.find()</code> now needs a session; the error hook sees a 401 to react to.

db/schema.lite

  (0.4.4.5)  (read,   ownerId == auth().id)  (update, ownerId == auth().id)

4 things moved. 2 deliberately did not.

moved

Every query

Data

<code>AND ownerId = ?</code> is compiled into the WHERE clause. Not a filter a caller applies.

moved

List results

API

<code>GET /api/leads</code> returns only yours — and <code>total</code> counts only yours.

moved

HTTP

API

Patching someone else's row is a <code>403</code>, from the data layer up.

moved

Live events

API

Subscribers stop receiving rows the policy would not have let them read.

unchanged

Migration

Data

Policies are query-time, not schema-time. Nothing to migrate.

unchanged

Code

UI

Not one line changes. The UI was never the thing enforcing this.

db/schema.lite

model Lead {model Opportunity {  id        Int        name      String   (1, 200) 

4 things moved. 2 deliberately did not.

moved

Accessor

Data

<code>db.lead</code> becomes <code>db.opportunity</code>. PascalCase singular in, camelCase out.

moved

Migration

Data

A table rename is diffed like any other change — review it before applying.

moved

TypeScript

Data

The interface is renamed, so every stale reference is a compile error rather than a runtime surprise.

moved

Service

API

One key: <code>model: 'opportunity'</code>. That is the whole server-side edit.

unchanged

URL

API

<code>/api/leads</code> is unchanged — the service <em>name</em> is not the model name. Renaming your data does not break your clients.

unchanged

Resource

UI

<code>createResource('leads')</code> still resolves. It binds to the service, not the table.

The journey

One request, every seam it crosses

Most frameworks hide this. FrontierJS names each handoff, because a boundary you can name is a boundary you can reason about — and every one of these is a real function you can open. Here is a single POST /api/leads, end to end.

HTTP arrives
transport/http.ts

The server matches a route. If an Authorization header is present, IAuth.verifySession(token) resolves it to a session — the inbound auth bridge.

bridge.toContext()
transport/bridge.ts

THE transport-to-service boundary. The HTTP request becomes a Context: body to ctx.data, path captures to ctx.route, filters to ctx.query.

parseDirectives()
transport/bridge.ts

$limit and $orderBy are split off into ctx.directives. The $ prefix is transport syntax and nothing past this line ever sees one.

around hooks
core/hooks.ts

The outermost pipeline phase wraps everything that follows, including the failure paths. Timing and tracing live here.

gateAuth()
core/litestone.ts

Derived from the model @@gate. No session where one is required stops here as a 401, before any query is built.

autoValidate()
core/litestone.ts

Derived from the field rules. @email, @length and @gte become a 400 with per-field messages — still before the database is touched.

before hooks
core/service.ts

Your code, finally. Defaults, enrichment, anything that should happen to the data on the way in.

$.db
core/litestone.ts

withLitestoneDb() put a per-request Litestone client here at createApp({ db }) time, already scoped to the caller.

$setAuth(user)
litestone/core/client.js

The Data-boundary checkpoint. Everything past this point knows who is asking — and asSystem() is the single documented way around it.

GatePlugin level check
litestone/plugins/gate.js

The same @@gate, enforced a second time at the data layer. Not redundancy — the API is one door, and the door is not the lock.

buildReadFilter()
litestone/core/plugin.js

@@allow predicates become a WHERE fragment, AND-merged with your own. This is why a policy cannot be skipped by a forgetful caller.

SQL
litestone/core/query.js

One statement, with your filters, the policy, and the pagination already folded in. Then the row comes back.

after hooks
core/service.ts

Your code again, on the way out. Shape it, format it, redact it.

wrapResult()
core/envelope.ts

THE result envelope, one owner. A list keeps { object, data, total }; a single record unwraps. Nothing else in the framework decides this.

announceToChannels()
core/service.ts

The single announcement point. One mutation, fanned out to both the event bus and the declared channel — there is no second path to forget.

WebSocket frame
transport/channels.ts

Subscribers receive "leads created". The gate still applies on the way out, so nobody is pushed a row they could not have fetched.

bridge.toResponse()
transport/bridge.ts

Back across the same boundary. A thrown FrameworkError becomes its status code here; a Litestone AccessDeniedError was normalized to 403 on the way.

The resource updates
sierra/resource/index.js

watchProxy() makes the result reactive, so whatever reads it re-renders. That seam — not a fetch call — is where the UI actually binds.

The counterfactual

What the 13 lines replace

"You didn't write this" means nothing until you see what you would otherwise have assembled — and, more to the point, kept in agreement with itself forever after.

The usual assembly

Every one of these is a good library, and most are better at their one job than anything here. The cost is not any of them individually — it is that your field list gets restated in ten places, in ten dialects.

ConcernWhat a Node dev reaches for
Schema & migrationsPrisma · Drizzle · TypeORM
Validation Zod · Valibot · Yup
HTTP routing Express · Fastify · Hono
Authentication Auth.js · Lucia · Passport
Authorization CASL · Oso · hand-rolled middleware
Row-level access Postgres RLS · CASL conditions · hand-written WHERE
API spec zod-to-openapi · @fastify/swagger · NestJS Swagger
Client types openapi-typescript · orval · ORM-generated types
Data fetching TanStack Query · SWR · RTK Query
Realtime Socket.IO · Ably / Pusher · raw ws

Add a field: ten edits across ten files, and nothing tells you when you have missed one. The compiler cannot help — these tools do not know about each other.

The seed

The same ten concerns, stated once. Everything downstream reads from the schema, so disagreement is not something you have to police.

ConcernWhere it lives
Schema & migrationsthe .lite file, diffed by fli db:migrate
Validation @email @length @gte, on the field
HTTP routing model: on the service
Authentication IAuth — swap the provider, keep the contract
Authorization @@gate, one level per operation
Row-level access @@allow, compiled into the WHERE
API spec derived from the registered services
Client types litestone types, same file
Data fetching createResource — reactive, hooks matching the API's
Realtime channel: — one announcement point, two fan-outs

Add a field: one edit. That is the entire pitch.

The fair comparison — things that already consolidate

Almost nobody wires all ten by hand any more, and pretending otherwise would be a strawman. These are the real alternatives, and each is genuinely good at collapsing part of that table.

OptionWhat it collapsesWhat is still separate
tRPCRouting, client types and data fetching — types flow end to end with no spec stepYour schema, validators and authorization are still three declarations. Nothing derives a UI.
SupabaseDatabase, auth, realtime, and row-level access via Postgres RLS — the closest thing to this idea in productionRLS is written in SQL, beside the schema rather than in it; validation and the client layer are yours. Self-hosting is possible but is not the default path.
Wasp · RedwoodJSThe whole stack, from one config — the same ambition as this projectThe glue is declared in a spec file you maintain, rather than derived from the data. Authorization lives in handlers, not on the model.

The line worth arguing about is narrower than "fewer dependencies": it is whether authorization belongs on the data, and whether the glue should be written at all. If those two do not matter to you, the options above are mature and this one is not.

And the part that is not done

Derivation currently stops at the API boundary. Junction derives services from a Model; Sierra does not yet derive forms or tables from one — the JSON Schema reaches the browser and nothing renders from it. That is the single largest gap in the framework, it is tracked openly, and it is the next thing worth building. A page that only showed you the wins would be the wrong kind of page.