FrontierJS

The tutorial

The tutorial
is a command.

Not a page you read with a terminal open beside it. fli tutor prints its own prose, runs the real commands, and then asks the running world whether they worked — a port that answers, a table in sqlite_master, a row read back out of the file the app wrote.

A step that cannot prove itself stops and tells you what it asked for and what it got instead. And the whole thing runs in this project's CI, so a command renamed out from under a step is a red build rather than a stale paragraph.

$ npx /cli@0.1 tutor:app

# or, keeping what it builds so you can read it afterwards
$ npx /cli@0.1 tutor:app  ~/frontier-tutorial

A step

Every line beginning ✓ is a question that was asked.

This is lesson 1 applying a model you just wrote and then using it. Nothing here is an echo of a command that exited 0.

What you see

  [7/10] 07-push
· Pushing schema to database...
✓ Schema applied
✓ the note table exists in db/app.db
✓ api answers /api/health
✓ GET /api/notes is served, and public

  [8/10] 08-write
✓ POST /api/notes with a token is accepted
✓ the same POST with no token is refused
✓ the row is in db/app.db
· open http://127.0.0.1:8000/notes/ and it is there

What produced it

await must(context, probe.httpJson({
  url:     apiUrl(context, '/notes'),
  method:  'POST',
  headers: { authorization: `Bearer ${token}` },
  body,
  expect:  (j) => j && j.title === title,
  name:    'POST /api/notes with a token is accepted',
}))

// The control. Same request, no token — and it must be
// refused, or the acceptance says nothing about the gate.
await must(context, probe.httpStatus({
  url:     apiUrl(context, '/notes'),
  method:  'POST',
  body,
  expect:  401,
  name:    'the same POST with no token is refused',
}))

// Not the response — the database. A service that answers
// 201 and writes nowhere passes every check above this.
await must(context, probe.sqliteRow({
  db:      join(appDir, 'db', 'app.db'),
  sql:     'select id, title from note where title = ?',
  params:  [title],
  expect:  (rows) => rows.length === 1,
  name:    'the row is in db/app.db',
}))
Two things worth noticing. The row is read out of the app's own SQLite file rather than off the response, because a service that answers 201 and writes nowhere passes every check above that line. And the refusal is a pair with the acceptance — the identical request without a token — because a refusal that cannot be shown to come from the rule it names proves nothing.

And when it does not work

✗ port 8000 is free for the API
    asked     port 8000 free
    got       something is already listening there
    likely    something is already listening on 8000 — another app, or an earlier run of this lesson
    reproduce fli ports:status
    continue  fli tutor:app
              fli tutor:app  1

✗ 01-preflight refused — nothing after it ran

The exit code is non-zero, nothing after the failing step ran, and the two ways forward are printed. Running the lesson again picks up where it stopped; --step 1 runs that one step on its own.

The thirteen lessons

Empty directory to a machine taking orders.

Each one is separately runnable and separately resumable. The first two need nothing but Bun — no Docker, no network, no account, no browser tab open on somebody else's site.

fli tutor:app 11 steps

An app that runs

Scaffold an app, start it, register an account, add a model of your own, push it, write a row through the API and read it back out of the SQLite file the app wrote. Then write a Mesa component, and ask the dev server for the compiled module. Then build it, and check the built page actually loads its JavaScript.

needs nothing ✓ the row is in db/app.db
fli tutor:tools 6 steps

The four tools, and when

Four surfaces whose whole job is to report on a running app, and one question each. The GUI is the front door — what is here, what is up, and does anything fail a check. Then the database as it really is, the call feed with a refusal in it named and coded, and the chain that handled the request.

needs nothing ✓ a 401 shows up as a 401
fli tutor:ui 6 steps

A screen you did not write

A form is not written, it is read off the schema — which is easy to nod at and easy to fake, so this one opens a real browser. Every control, the type each column implies, and nothing for the columns the server owns. Then one attribute is added to one column of the schema, no form is touched, and the same page starts refusing.

needs Chrome ✓ the empty submit never reaches the server
fli tutor:access 9 steps

Who may do what

Authorization is declared in the schema rather than written in hooks, which is easy to nod at and hard to believe. So this one changes one line of db/schema.lite at a time and shows the answer to the same HTTP request changing: a gate that refuses, a policy that filters, a column that silently declines a write.

needs nothing ✓ the same request answers 200, then 401
fli tutor:live 8 steps

A change reaching somebody else

A row is written over plain HTTP and a socket that asked for nothing is told about it. Then the second question, which most frameworks never ask: who is on the other end? Two sockets, one signed in and one not, watched against a single publish before and after the gate goes up.

needs nothing ✓ the anonymous one is not told
fli tutor:jobs 6 steps

Work that outlives the request

A queue that is a SQLite file, a job named by its own filename, and a dispatch from a service hook. What is asserted is the order — the response comes back with the work not yet done, and the row changes afterwards. A test that only checked the final state would pass with the work done synchronously.

needs nothing ✓ the response came back before the work was done
fli tutor:notify 8 steps

Telling somebody something

One notification, declared in one file, delivered as a row in the app and as an email — and the file names it, so the type nothing states is the type the database carries. Renaming the file renames the type and orphans every row already written, in silence, which is why a stated type exists. Then a transport declared with no formatter, refused before anything is delivered.

needs nothing ✓ a two-transport notification cannot half-land
fli tutor:site 8 steps

The public half

A prerendered site is a peer surface, not a folder in the SPA. One HTML file per page with the data already in it — and a build that refuses to emit a page whose load() read something gated, because a published page cannot be recalled. You watch the refusal, then the override that gets past it.

needs nothing ✓ the build refuses to publish a gated model
fli tutor:deploy 10 steps

A deploy you can take back

A deploy target may be localhost, so this is the real pipeline: an image, a journal on disk, a health poll, a swap. Deploy, change a line and deploy again, then revert — and revert the revert. Asserted against the image id the container is on, because a pipeline that reports success while leaving the old container up passes everything else.

needs Docker · git ✓ the previous release is serving again
fli tutor:change 6 steps

The schema you already deployed

Month two: rows in production and a release already serving them. Three changes, each classified — an optional column you can take back, a required one that makes the deploy a pivot and comes back with the three-deploy split, and a raised gate that touches no column and is still a contract.

needs nothing ✓ a gate raise is a contract, with no column touched
fli tutor:test 6 steps

Knowing it is right

Most of what you would write tests for has already been declared, and a declaration can be executed: four calls run every gate at every level, every protected column, every validator and every row policy, with no fixtures to author. Then the schema is mutated on purpose — a gate dropped, a guard removed — and the checks derived from the ORIGINAL are run against the mutant, so anything that survives is a hole in the checks and names itself.

needs nothing ✓ a survivor is a fact about the checks, not the schema
fli tutor:fleet 8 steps

A machine that takes orders

The other release story. A control plane holds the fleet as rows, a machine runs an outpost so the control plane has hands on it, and nobody types a deploy. A signed command from the control plane really runs on your machine — and then a release: sources built into an image there, a container started, and the bytes recorded, because a tag is a name two builds share.

needs a checkout · Docker ✓ the control plane recorded which bytes ran
fli tutor:adopt 7 steps

A database you already have

The other door. A SQLite database made by somebody else, with rows in it, read into a schema — and the half a converter can quietly skip: every construct the reading could not carry, graded changed, lost or noted. It checks the reading against itself by building a database from it and reading that, then serves a row written by raw SQL before there was an app. The last step is the finding an inherited schema comes with, and it is kept rather than fixed: a baseline the number may never pass.

needs nothing ✓ a row that predates the framework is served

Why it is different

Three claims, and we have not found the third anywhere else.

You cannot fake a step

Progression is gated on a probe, the way rustlings gates on the compiler accepting your code. What is different is what the probe asks: not did my command exit 0 but is the row there.

It teaches deployment

Most tutorials stop at localhost, because they cannot give you a server. This one does not need to: a deploy target may be localhost, so lesson 3 is a real image, a real journal on disk, a real swap and a real revert — on your own machine, and reversible.

Our CI grades it

All thirteen lessons run in bun run ci. Tutorials rot when the product moves — ours already had, and its own header disagreed with the README about whether it had ever been executed. That is what this replaces.

─── tutor ─────────────────────────────────────────────
  ✓ tutor:app (6.9s)
  ✓ tutor:tools (8.5s)
  ✓ tutor:ui (43.4s)
  ✓ tutor:access (9.8s)
  ✓ tutor:live (12.1s)
  ✓ tutor:jobs (6.8s)
  ✓ tutor:notify (12.4s)
  ✓ tutor:site (7.7s)
  ✓ tutor:deploy (74.0s)
  ✓ tutor:change (4.3s)
  ✓ tutor:test (15.7s)
  ✓ tutor:fleet (2.9s)
  ✓ tutor:adopt (2.3s)

What it found

Six defects, and every one of them exited 0.

Writing a tutorial that asserts is the first time anybody walks the whole path as a stranger would. These came out of doing it, and they are the argument for probing rather than trusting a status code: a tutorial graded on exit codes would have passed against all six and taught each of them.

IdWhat was wrong
FJS-735Nine of ten refusals in the scaffolder exited 0, so every wrapper around it read “scaffolded”.
FJS-736The auth commands had rotted through five layers, ending with one that created an account and reported failure.
FJS-737Every app scaffolded with auth booted with its policy claims graded against nothing — and said so on every boot, to nobody.
FJS-738Every command the deploy pipeline sent to a machine did nothing at all under node, and reported success.
FJS-752Every scaffolded app announced its writes on a channel no connection had joined — real-time wired everywhere, delivered nowhere.
FJS-758Every file the static-site scaffold generated was broken, four different ways, because nothing had ever executed one.

The last one is the one worth reading. execSync with stdio: 'inherit' ignores its input on Node and honors it on Bun — and every command a deploy sends travels on stdin. So under Node the container swap, the lock release and the nginx write all reported success and did nothing. Every defect is filed in the repo's own register with the measurement that found it.

Start

One command, and nothing to clean up.

It builds in a throwaway directory by default and removes it when it is done. Pass --workspace ~/somewhere to keep what it makes, which is the point if you want to read the app afterwards — and to stop and come back to it, since running a lesson again picks up where you left off.

Every step says what it is about to do and waits for you. Press enter to go on, or n to stop — the step you stopped at is the one the next run starts from. --yes runs a lesson straight through, which is what CI passes.

$ npx /cli@0.1 tutor:app

# or, keeping what it builds so you can read it afterwards
$ npx /cli@0.1 tutor:app  ~/frontier-tutorial
Every sample on this page is a transcript or a verbatim lift from the step that produced it.The seams →