The stack
Each stands alone — Litestone with any server, Mesa with any bundler. Together they share one model: Data → API → UI, seeded by a single schema.
Click any feature to see what it does, and what it saves you wiring up.
Authorization is part of the data definition, not a layer above it.
Models · enums · relations · reusable functions
One declarative file describes your data. Functions are reusable expressions you can call from defaults and policies, so logic lives beside the data it governs.
model Invoice {
id Int
total Float (0)
ownerId Int
owner User (ownerId)
}
Int String Float Bool DateTime Json Bytes
Seven scalars, mapped to SQLite storage classes and to TypeScript. Optionality is a trailing ? and it means the same thing in the database, the API and the editor.
Nothing — there is no common equivalent to swap out.
Gates on a 0–9 scale · policies compiled into WHERE
A gate grades each operation against a trust level. A policy is a row predicate compiled into the query itself — so there is no code path that can forget to apply it.
(0.4.4.5) // read.create.update.delete
(read, ownerId == auth().id)
-- every read becomes:
SELECT * FROM invoices WHERE ownerId = ?
@guarded · @secret · @encrypted at rest
Field-level protection travels with the field. Guarded fields never leave the data layer, secrets are redacted from logs and responses, and encrypted fields are ciphertext on disk.
Defaults · transforms · @updatedAt · soft delete
What happens automatically over a row's life, declared once. Transforms like @trim and @lower run on the way in, so the database never holds the unnormalized version.
email String
updatedAt DateTime
deletedAt DateTime? // soft delete
@@trail(audit), per model
Mark a model and every write is recorded with the actor, the operation and the before/after. No decorator on every mutation, no forgetting the one that matters.
Relations · cursor pagination · transactions · $raw
A full query API with nested reads, aggregates, windowing and distinct — plus a documented raw escape hatch for the cases a builder should not try to own.
await db.invoice.findMany({
where: { total: { gte: 500 }, owner: { is: { active: true } } },
orderBy: { createdAt: 'desc' },
cursor: { id: 120 },
take: 20,
})
@@db(name) · database-per-tenant registries
Route models to separate databases from the schema, and get database-per-tenant isolation from one accessor rather than a connection-juggling layer of your own.
@@external mirrors tables you don't own
Describe a table another system owns and query it with the same client. Migrations leave it alone, so you get typed reads without claiming responsibility for the schema.
Diffed from the schema as reviewable SQL
Change the schema, and the diff against the live database becomes a migration file you read before applying. Drift between file and database is a command, not a surprise.
$ fli db:migrate
+ ALTER TABLE invoices ADD COLUMN company TEXT
✓ migrations/0002_company.sql applied
$ fli db:status
✓ live database matches the schema
TypeScript declarations · JSON Schema
The same file emits types for your editor and JSON Schema for validation and the browser — so the three can never describe different shapes.
$ litestone types ./types.d.ts =client
interface Invoice { id: number; total: number }
interface InvoiceCreate { total: number }
Litestone Studio · REPL · doctor · seed · backup
A local web UI over your data, an interactive shell against the live client, a health check for your setup, and seeding and backup as first-class commands.
Name a model and its CRUD, validation and authorization come with it.
Five methods plus named actions
A service is the unit, not a route. It exposes find, get, create, patch and remove, plus any custom action — callable over HTTP, over WebSocket, or in-process.
createService({ name: 'invoices', model: 'invoice',
async markPaid(ctx) { … },
})
// same service, three ways in:
GET /api/invoices
ws { service: 'invoices', method: 'find' }
app.service('invoices').call('markPaid', id)
around · before · after · error
Four phases, per method or for all of them, at app or service level. around wraps everything including failures, which is where timing and tracing belong.
hooks: {
around: { all: [timing] },
before: { create: [setOwner] },
after: { create: [broadcast] },
error: { all: [report] },
}
One shape: auth · client · route · locals · query
Every hook and every method receives the same object, with the caller, the parsed query, the result and the error in known places — not a request object you decorate by convention.
Lists keep { data, total }; singles unwrap
One module decides the response shape for the entire framework, so pagination metadata is always in the same place and a single record is never wrapped for no reason.
GET /api/invoices → { object: "list", data: [...], total: 17 }
GET /api/invoices/8 → { id: 8, total: 500 }
HTTP and WebSocket over one bridge
Both protocols enter through the same boundary and produce the same Context, so a service does not know or care which one a caller used.
$limit $offset $orderBy $select, parsed at the edge
Result shaping is transport syntax, split from filters once at the boundary. Past that line nothing sees a $ — services receive clean filters and a directives object.
GET /api/invoices?status=open&$limit=20&$orderBy=-createdAt
ctx.query → { status: "open" }
ctx.directives → { limit: 20, orderBy: "-createdAt" }
CORS · CSRF · headers · rate limiting
The usual middleware stack, configured rather than assembled, and ordered correctly by default — preflights short-circuit before CSRF, which is easy to get wrong by hand.
One announcement, two fan-outs, gated on the way out
Declare channel: on a service and every mutation announces once, reaching both the event bus and WebSocket subscribers. Gates still apply, so nobody is pushed a row they could not fetch.
createService({ name: 'invoices', channel: 'invoices' })
// subscribers receive:
{ type: 'invoices created', data: { id: 9 } }
Named classes; data denials normalize into them
Throw BadRequest or NotFound anywhere and the transport serializes it correctly. A data-layer denial becomes a 403 with the same shape, so clients match one contract.
Typed resources and live subscriptions in the browser
The same service API from the other side of the wire, including subscriptions and session handling — not a fetch wrapper you maintain alongside the server.
Real apps, real pipelines, no port binding
Boot an app in-process, send real requests through the full hook pipeline, and stub only auth. Tests exercise the code that runs in production.
const app = await createTestApp({ db, auth: createStubAuth() })
await request(app).post('/api/invoices').send({ total: 5 })
A route establishes a session. Everything after it is an ordinary service.
Register · login · logout · password reset · email verify
Login cannot be gated by login, so these five are raw routes and always will be. Everything past that line is a service with the normal pipeline over it.
cookieAuth: true authenticates end to end
One session works for a fetch, for a browser navigation and for an OAuth callback — which is a redirect, and cannot carry a bearer header.
The flow engine is split from the transport
Building the URL, trading the code and normalizing the identity are one module with no HTTP, no database and no session in it. The plugin owns the routes; the provider list is data.
TOTP, base32 and recovery codes — clockless
RFC 6238 with the instant as a parameter rather than a read of the clock, so a code is testable and a drifting device is a value you pass in.
account · sessions · api-keys
What a signed-in person does next is not a route. Three ordinary services, gated like everything else, so there is no second authorization story for account management.
Two .lite files, split by who owns them
User is appended into your own schema so you can edit it; the machinery models are imported, so an upgrade reaches an installed app. extend model adjusts them at your end rather than by pasting.
extend model User {
company String?
}
Four awaited callbacks; a throw refuses the call
Each one runs BEFORE the thing it can refuse, so declining a registration is returning early rather than undoing a write.
Background work in the database you already have. No second service to run.
SQLite job queue and cron on app.jobs
Queues, retries and exponential backoff in the same database as everything else — so local development, CI and a single-box deployment all run the real thing.
The file names the job, and the file is the whole of it
send-email.job.ts is send-email. A defineJob naming anything else is refused at load rather than registered, because a handler answering to send-emial while every dispatch says send-email never runs and never says so.
export default defineJob({
handler: async ({ data }) => { await mail.send(data) },
queue: 'mail',
maxAttempts: 5,
})
cron is an option on the handler, not a second system
A job file declares its own schedule, so moving the file moves the schedule. A name is one schedule — registering it twice replaces, where two entries used to fire the job twice a minute.
dispatch({ id }) is a no-op for all time
A stated id is the jobs table’s primary key, so the same dispatch can never run twice — where a unique key frees itself the moment the job goes terminal.
A job runs as whoever asked for it
The principal travels with the dispatch, so a queued write is gated exactly as the request that queued it would have been. Background work is not a hole in the access model.
Heartbeats, crash resume, two instances on one queue
A running row carries the id of the instance holding it and that instance heartbeats, so a worker killed mid-job is replaced rather than leaving the job claimed forever.
caravan queue … from a shell, JSON out
The verbs an operator needs are a binary, not an admin page — which is what a paused deploy runs inside the serving container.
Every third party an app talks to, declared in one place.
A counterparty is named once — credential, policy and all
Because targets are declared, an application’s entire outbound surface is a list rather than an audit. Useful in review, and to anyone self-hosting who wants to know what the process can reach.
The route a Provider calls you on, declared beside its Target
A connector exports how the vendor signs, the app says which key verifies and what to do, conduit runs the order and keeps the receipt — so a redelivery is a duplicate only once the first was applied, and a throw hands the sender a clean retry.
Seven numbers per target, falling back field by field
One conduit carries a card processor, a mail sink and a health probe. One set of timeouts and retries fits none of them, so each target states its own and inherits the rest.
json or form, decided in the transport
The raw body is the same string the auth headers are built over, so an encoder living in a caller or a connector signs bytes that were never sent.
Method, path, timestamp, nonce and a hash of the body
A captured signature cannot be replayed, moved to another endpoint, or kept while the body is swapped underneath it. The same module verifies on the far side.
An unknown field on a descriptor is refused by name
Dropped silently is how a mistyped timeout becomes a production default that nobody can find.
Callbacks that receive and cannot act
A throw in one is swallowed, so the word on the option says the tier: these observe. The management pipeline keeps the word hook, because that one can refuse the call.
Conduit is where an outbound call belongs — not a boundary around the process
Nothing here blocks a raw fetch, and two batteries dial out directly on purpose. A contract with a working default is what keeps this package optional.
Nothing — there is no common equivalent to swap out.
One call fans out to a record, a live event and an email.
A notification is a file, and the file names it
OrderPaid.notification.ts is OrderPaid — the same rule job files already follow, so there is no registry to keep in step with the filesystem.
export default defineNotification({
via: (p) => p.total > 100 ? ['inApp', 'email'] : ['inApp'],
format: async (p) => ({ title: `Order ${p.id} paid` }),
})
app.notify → in-app record, WebSocket event, email
One send reaches every transport the definition asked for, and the in-app record is a row like any other — queryable, gated, and readable back.
type: holds the name still
The type is a column the browser reads to pick a renderer, so renaming the file orphans every existing row. State it and the file is free to move; a divergence is warned about.
Nothing — there is no common equivalent to swap out.
The unit of address is a Recipient, not a User
A notification can reach an address that was never an account — an invitee, a webhook, a shared inbox — without inventing a user row to hold it.
inApp and email, plus drivers of your own
A transport is a delivery medium, kept separate from the broadcast set a WebSocket subscriber joins — two things one word usually covers.
app.notifications answers what this app can send
Formatters may be async and take the payload rather than closing over it, so the catalog can be listed with nothing actually sent.
An MCP endpoint over the app you are already running, with the gate as the permission model.
One plugin, inside the running API
Stdio would have to boot the app a second time — a second job worker, a second migration check, and an event bus no browser tab is on. Mounted, a tool call is one more caller of the process that is already serving.
import { mcp } from '@frontierjs/mcp'
app.configure(mcp()) // POST/GET/DELETE at {apiPrefix}/mcp
Tools are computed per standing, not described in a prompt
What an agent may do is the gate it is already graded by. A stranger sees a stranger’s tools; a prompt can be talked out of a rule, and the database layer cannot.
A tool call goes through app.service(name)
The same hooks, transaction and announcement an HTTP or WebSocket call gets, so nothing an agent does is invisible to a browser watching the same rows.
The schema the browser already gets
Each tool’s input is the client-audience JSON Schema, and no call through this package can ask for the system audience — so a protected column cannot reach a tool description.
Nothing is kept between two requests
The projection is rebuilt at the caller’s standing on every request, so nothing an agent was shown outlives a sign-out.
Nothing — there is no common equivalent to swap out.
Automations that run inside your app, against your schema, refused by your gates.
A schema import and one plugin
Flows, versions and runs are declared models with real gates, so they are data in your database rather than rows in somebody else’s.
// db/schema.lite
import "@frontierjs/orion/orion.lite"
// api
import { orion } from '@frontierjs/orion'
app.configure(orion())
Triggers, conditions and actions
A trigger is a model write, a cron schedule, a webhook or a manual run. An action creates or patches a model, calls a service, makes an HTTP request or sends a notification. All of them are nouns the app already has.
A flow runs as its owner
Every step goes through the same gates a person does. A flow cannot read or write anything its owner could not, and run history redacts protected fields the way the audit trail does.
Runs on the job queue and survives a crash
The run is checkpointed once per stage, so a worker killed mid-run is replaced by another that finishes without repeating the finished stages. A wait can last days and resumes once.
Flows, runs and credentials, mounted by one file
The builder and the run inspector are components from the same kit as the rest of the app. Each node’s settings form is built from its schema, so a node you add gets a form too.
Nothing — there is no common equivalent to swap out.
Your file tree is the route table. A Resource is a service, made reactive.
File tree · dynamic · catch-all · layouts · groups
Routes come from the filesystem, so the URL structure and the folder structure cannot drift. Layouts nest by directory and groups organize without affecting the path.
src/routes/invoices/index.mesa → /invoices
src/routes/invoices/[id].mesa → /invoices/42
src/routes/[...404].mesa → anything else
src/routes/_module.mesa → the layout
Title · guards · render mode, per route
Route metadata lives at the top of the route file rather than in a separate config, so moving a page moves its configuration with it.
Guards · hooks · prefetch · scroll restore · slots
A navigation API with before and after hooks, guards that can redirect, link prefetching, and scroll restoration handled rather than reimplemented per app.
The API's four hook phases, client-side
A Resource binds a service to reactive state, with the identical around/before/after/error phases the server uses. Learn the pipeline once and it holds on both sides.
const invoices = createResource('invoices', {
hooks: { around: { all: [withSpinner] } },
})
await invoices.find({ status: 'open' })
A blank record shaped by the schema's defaults
Start a create form from the schema itself, with declared defaults already applied and server-owned fields left out. No hand-written initial state to fall out of sync.
invoices.make()
// { total: 0, status: 'draft' } — id and createdAt omitted
JSON Schema shipped into the bundle
The build puts your schema in the browser, so the client knows the same field rules the server enforces — without a second copy of them written in a client-side validator.
spa · static · widget · extension
One route tree, several outputs, chosen at build time. A prerendered marketing page, an embedded widget and a browser extension come from the same components.
Route loaders · parallel fetch · connection state
Data loading declared per route and resolved in parallel, with live connection state you can render against instead of tracking your own online flag.
Routes, resources, requests, events, jobs
An in-page overlay fed by the server's own telemetry, so you see the request, the hooks that ran and the job it queued in one timeline.
Vite · state-preserving HMR · auto-imports
Vite underneath with Mesa compilation, hot replacement that keeps signal state across edits, and auto-imports so common components need no import line.
One file renders to a page, an email, a fragment or a module. Zero dependencies.
Signals · watchProxy() makes plain objects reactive
Fine-grained signals with automatic dependency tracking. State stays a plain object — no wrapper to unwrap, no dependency array to keep correct.
let total = 0
$: withTax = total * 1.2 // recomputes when total changes
{#if} {#each} {#await} {#key} · snippets · bindings
Block directives rather than expressions, keyed lists, await blocks for promises, reusable snippets, and two-way binding that does not require an onChange handler.
{#each rows as row (row.id)}
<Row {row} />
{:else}
<EmptyState />
{/each}
Scoped per component
A style block applies to its own component only, with no naming convention to maintain and no runtime style injection.
.mesa or Markdown with frontmatter
Content-shaped pages stay content-shaped: a Markdown file compiles to the same component, with tables and syntax highlighting already handled.
DOM · SSR · static · email · fragment · modules
The same component renders live in the browser, to an HTML string on the server, to a prerendered file, to an email, or to an isolated fragment for embedding.
{@attach} — an element handed to your code
The escape hatch for a chart library or a focus trap: you get the node and a teardown. It does not run on the server, which is what makes a server-rendered page safe to write this way.
No framework dependency of its own
Mesa sits at the bottom, so it can be adopted with any bundler and any server. The rest of the framework is what happens when you keep going.
Nothing — there is no common equivalent to swap out.
Sixty-odd components over the design system. A form that already knows what it is editing.
<Form> takes a resource; every control resolves itself
A control reads its own label, its constraints and its server error from the form context, so a field is one tag with a name on it and the three things that usually drift cannot.
<Form resource={invoices} record={invoice}>
<Input name="total" />
<Select name="status" />
</Form>
Every component forwards its caller’s attributes
A form control puts them on the CONTROL rather than the wrapper, because that is what a label’s for and an aria-describedby have to reach. A kit that swallows them cannot be made accessible from outside.
<Form autosave> saves once the typing stops
The debounce, the in-flight state and the last-write-wins question are the form’s, not yours — one attribute rather than an effect in every screen.
A tone and a treatment, never a color
danger and outlined are what a button is; blue is what it happens to look like this theme. A kit component may not style a class the design system owns, so the two cannot fight.
A destructive action confirms itself
The confirmation is part of the component rather than a modal you remember to wire, so the dangerous path is the one with a guard on it by default.
A control of your own is two registrations
A custom control joins the form contract the same way the built-in ones did, so it gets labels, errors and constraints without special-casing.
FormButtonFieldFieldsetLabelInputTextareaSelectCheckboxSwitchRadioGroupNumberInputSliderComboboxMultiSelectDatePickerDateTimeInputJsonInputFileUploadBadgePillTagDotKbdMonoDividerBreadcrumbsPaginationStepsSectionHeaderCalloutEmptyStateCopyButtonCodeAvatarAvatarGroupStatStatCardTableBarSparklineAccountStatusJsonCardAccordionAccordionItemTabsTabListTabTabPanelModalDrawerPopoverTooltipDropdownMenuDropdownItemDropdownGroupDropdownSeparatorCommandPaletteConfirmationPopoverConfirmPanelConfirmProviderAlertAlertProviderToastToasterProgressSpinnerSkeletontoastStorealertStorecommandPaletteStoreThirty-five semantic terms. Plain CSS, no build step. This site is styled by it.
tokens → themes → tones → base → components → patterns
Cascade layers give an explicit precedence contract, which replaces the usual "do not reorder these imports" comment with something the browser enforces.
Your CSS is unlayered, so it always wins
Because every rule in the package sits in a layer, any unlayered rule you write beats it — no specificity war, and no !important.
/* yours, unlayered — wins by definition */
.btn { border-radius: 0 }
Fill and text derived together — AA by construction
Text color is computed from the fill's own luminance, so any tone in any theme — including a brand color added later — meets AA without being audited.
From [aria-current] and :user-invalid, never a class
Styling keys off the accessibility state that has to be correct anyway. A tab cannot look selected while telling a screen reader otherwise.
Six, swapped with one class on the root
A theme overrides token values only, so every component follows automatically — no provider, no runtime, no re-render.
None
Plain CSS files with an import entry point. No compile step, no config, no purge pass, and no build tooling to keep current.
appshelltopbarsidebarscreenpaneviewbtnpillbadgelinkchiptooltipavatarkbdiconcardtilealerttoastdialogpopoverdrawerfieldfield-hintswitchtabletilesfactstabsbreadcrumbpaginationnavliststepsbarfeeddisclosureAn email is a component. The same language, compiled for a worse renderer.
Table-based blocks, compiled by the same compiler
An email body is written the way a page is, so a designer moving between the two learns nothing new and the components are testable in a browser.
an email build target inlines styles to attributes
Mail clients strip style blocks, so the compiler puts the styles where they survive rather than asking you to write it that way by hand.
Plain-text alternative and the subject, from the component
The three things a message needs come out of one file, so a subject cannot drift from the body it belongs to.
The limits are written down
Outlook’s conditional-comment handling is the fragile part and the package’s docs say so. A compatibility claim nobody has opened in a real client is worth what it cost to write.
Nothing — there is no common equivalent to swap out.
A document to a PDF, an element to a PNG, in one headless Chrome.
printer.pdf(html) · printer.png(html, selector)
One Chrome, opened on the first job and reused after. It drives the browser with the same tool the component tests use, so an app that prints adds no second browser driver.
import { createPrinter, printDocument } from '@frontierjs/print-kit'
const printer = createPrinter()
const pdf = await printer.pdf(printDocument({ title, body, css }))
await printer.close()
app.configure(printer()) gives you app.printer
With no Chrome on the machine, the app refuses to start and says why. You find out at boot, not on the first invoice.
Nothing — there is no common equivalent to swap out.
The page runs offline with scripts off
A printed document is built from data somebody else entered. A URL in it cannot make your server fetch anything, and markup in a name runs nothing.
print.css handles page breaks and margins
Every printed document carries it after the app’s own styles, so page groups, break rules and margin boxes are already set up.
A browser extension as a surface of the app, not a second app.
extension/ sits beside api/ and web/
It earns its own directory because its config, its tests and its release are all different answers — and it reaches the same services with the same client.
$ fli make:extension
$ fli dev:extension # MV3, hot-reloaded
$ fli extension:build
A service worker carries calls to the API
The adapter is the real client rather than a lookalike, so sign-in, live updates and gating behave exactly as they do in the app.
A content script builds in lib mode
An MV3 content script is a CLASSIC script, so the preload helper a bundler normally writes makes the engine reject the whole file at parse time — before one line of it runs, with nothing at build time saying so.
Nothing — there is no common equivalent to swap out.
It remembers the query its rows answer
Nothing else can grade a record that arrives later: without the query, an update belonging to a different filter lands in the list anyway.
extension:audit grades the manifest
A permission is easy to add while debugging and invisible afterwards, right up until a store review.
The facts that have many possible answers and must have exactly one.
Substrate below the graph, not a member of it
That is what buys the import licence: because it depends on nothing, every package may import it without inverting the dependency direction the framework is built on.
No root entry — you import the kit you need
A grab-bag with one entry point is a package every consumer pulls whole. A subpath per kit means a browser bundle carries the two you used.
import { plural } from '@frontierjs/toolbelt/inflect'
import { canAtLevel } from '@frontierjs/toolbelt/gate'
/inflect — singular, plural, table, model, human
Three separate resolvers have to agree on what a model is called. One kit is how they do, rather than three regexes that agree until an irregular noun arrives.
/query and /directives — what a query string says
A string is a number only if it round-trips as one; true, false and null are themselves; structure is bracket notation. Three readers agree because there is one table.
/datetime · /units
An instant read in a zone and written back, with the runtime’s own zone rules; rounding, allocation and currency data that ship rather than being read off the host.
/gate · /predicate · /match · /signature · /redact · /cron
The access ladder and its grader, the schema expression language, whether a record still belongs in a query, a signed machine-to-machine request, whether a key is a credential, and what five fields mean.
inflectgatedirectivesqueryhistorymatchsignatureredactcronpredicatehooksjsonschemajsonunitsglowsearchdatetimeA real app, a real database, a real port when you want one. Stub nothing but the clock.
A template-cloned database, factories and a standing
Every check the Data layer ships works unchanged here, because they ask the data boundary directly rather than through a mock of it.
const env = await createTestEnv({ schema })
const orders = env.as(alice).service('orders')
await orders.create({ total: 5 })
env.as(user) binds the principal into every call
With no principal you get STRANGER, which is what an unauthenticated caller actually is — so the unhappy path is the default rather than something you remember to test.
env.announced() — what the act broadcast
A mutation that should have announced and did not is invisible to an assertion on the response, and it is exactly what breaks a live client.
The same call down HTTP and WS, compared
Two real transports grading each other. A service that behaves differently depending on how it was reached is a bug no single-transport suite can see.
Nothing — there is no common equivalent to swap out.
Asked for as zero and read back
For the tests that need an origin — a browser, a webhook, a redirect — without hard-coding a number that collides with the app you left running.
Above the API, and imported by nothing
Which is the whole reason it is a separate package: the Data layer’s own test environment cannot mount an app without importing the API layer, and that would invert the dependency direction.
Nothing — there is no common equivalent to swap out.
Every command is a markdown file. Help text and implementation cannot disagree.
project:new · make:model :service :resource :route
Generators append to the schema you already have rather than dropping a pile of boilerplate, so the seed stays the single source after generation.
$ fli make:model Invoice
✓ appended to db/schema.lite
✓ api/src/services/invoices.ts
✓ src/routes/invoices/
db:migrate :status :studio :seed :backup
The whole data lifecycle in one namespace — diff and apply migrations, check for drift, browse rows, seed fixtures and take backups.
ports:claim claims a port session per project
A broker assigns and remembers ports per project, so two apps on one machine never collide and the URLs are stable between restarts.
$ fli ports:claim
api → http://localhost:3200
web → http://localhost:5173
deploy infers the environment from your git branch
Build, upload, migrate and cut over in one command, with the target derived from the branch you are on rather than a flag you have to remember.
deploy:rollback · :status · :logs · :doctor
Releases are versioned, so going back is a command. Status reports what is actually running, and doctor checks the target before you find out the hard way.
env:set :get :list :pull, per environment
Environment values are managed per environment and never committed, with pull to bring them down and copy to promote between environments.
workspace: link · graph · version · publish
Workspace commands for linking packages, viewing the dependency graph, versioning together and publishing — without adding another build orchestrator.
make:command writes a new markdown command
Frontmatter declares flags, arguments and examples; the body is the script. Your team's operational knowledge becomes readable files instead of tribal npm scripts.
--- commands/reports/weekly.md ---
title: reports:weekly
flags:
since: { type: string, defaultValue: 7d }
---
The front door, and nothing else — it resolves the CLI and runs it.
It resolves @frontierjs/cli and runs fli new
What a new app looks like is written once, in the CLI. A create package with a scaffolder of its own is a second answer that drifts from the first release onward.
The target directory is pinned, not searched for
fli walks UP to find a project root, which is right when you are inside an app and wrong when npm create runs you inside an existing repo — without the pin the scaffold lands in that repo’s root.
Nothing — there is no common equivalent to swap out.
Arguments are forwarded untouched
Every flag the scaffolder has works here on the day it is added, because this package does not know what they are.
tsconfig, biome and editorconfig as a dependency — never as a copy.
One line in your tsconfig.json
A copied config is frozen at the moment it was written. A dependency moves when the framework does, which is the entire difference between a template and a package.
{ "extends": "@frontierjs/config/tsconfig" }
Linter only, and the refusal is measured
This house aligns columns and no formatter can express that. The code the scaffolder writes is aligned, so the first format run would rewrite the app the scaffold had just produced.
A linter owns generic JavaScript
It reads neither .mesa nor .lite, on purpose: everything derived from the seed is graded by the framework’s own checks, which can see the schema the linter cannot.
The app for managing your apps — and itself a FrontierJS app.
What is deployed where, and one button to undo it
Every environment, its current release, what changed since the last one, and a rollback that does not require finding the right host first.
Applied, pending and drifted, per environment
Read from each app's own manifest endpoint, so drift between the schema in your repo and the database in production is visible before it bites.
Queues across every app — in flight, failed, retry
One view over every Caravan queue you run, with failures and their reasons, and retries triggered from here.
Every trail, searchable by model, actor and window
The @@trail(audit) trails from all your apps in one place, so answering "who changed this record" does not start with a database console.
Each app's declared targets — everything it can reach
Because Conduit targets are declared, the complete outbound surface of an application is a list rather than an audit. Useful for review, and for self-hosters who want to know.
Registries, sizes, migration state, and a way in
Database-per-tenant means many databases. This is the list, their sizes, whether each is migrated, and a route into any one of them.
Liveness and metrics, including plugin-contributed
Every app exposes /health and /metrics, and plugins add their own providers — so a new battery appears here with no configuration.
Over the same channels your apps use — not polling
Basecamp subscribes the way any client does. It is a demonstration that the realtime layer works, because it is the thing depending on it.
The seed is as pleasant to write as the code it produces.
Errors as you type, from the parser the runtime uses
The extension runs the same parser the framework runs, so the editor cannot tell you something is fine that the build will reject.
Types, attributes, functions, and every model in scope
Field types, @attributes, @@model-attributes and your own schema functions — including models pulled in through schema imports.
Attribute and type docs, model and function signatures
What an attribute does and what it takes, in the file, without a documentation tab.
Model references and function calls, across imported files
Jump from a relation to the model it points at, or from a function call to its block, following schema imports across files.
Aligned field columns, on command or on save
Canonical formatting for schema files, so aligned columns are automatic rather than something reviewers ask for.
Markup, script, style, directives and interpolation
Component files highlight correctly across all three regions, including block directives and interpolation inside markup.
The process on the machine. A control plane’s hands, with a signature on every one of them.
Every route but unsigned liveness requires a signature
One of these routes runs a shell command as the process’s user, so refuse is the default and a route opts out rather than in.
pull · deploy · stop · health-check · exec · logs · prune
The route table is read off the control plane’s own engines rather than invented here, so a verb it can ask for is a verb this answers.
The thing that touches Docker is a parameter
Which is what makes the package testable with no daemon and no network, and lets the control plane’s own drive run the REAL server over a fake machine.
It builds on the target and answers what it built
The digest comes back from the machine that produced it, so what is running is identified by content rather than by a tag someone may have moved.