FrontierJS

Specifications

Answers any framework could adopt.

Every framework answers the same questions. Who can touch this row? What does a write announce? What comes back over the wire, and what happens when it fails? FrontierJS gives each answer exactly one owner, and one owner is what you need to write an answer down as a specification. The goal is a set any framework could implement, with FrontierJS as one implementation of it.

None of them is written yet. FrontierJS is pre-alpha, and a spec freezes things. Nothing gets frozen until something other than FrontierJS passes the tests.

What makes something a spec

  1. It holds without the stack. You can state it without mentioning SQLite, Bun, TypeScript, .lite or .mesa.
  2. It has a JSON form. A second implementation reads or writes a document: a schema, a descriptor, an envelope, a record. The prose describes the JSON, not the other way round.
  3. It ships its own tests. Input and expected output as data, runnable against anyone's implementation. Every case comes with a case that must fail.
  4. Somebody else passes them. A spec with one implementation is a README.

Ten that carry the rest

There are about forty candidates. These ten come first because the others lean on them, and each one leans on the ones above it.

  1. 01

    Model description blocked

    What's worth saying about a model: its fields and types, its identity, relations, constraints, defaults, derived values, labels, and which fields only the app itself may write. Everything else on this list derives from it.

    The catch is that a spec describes JSON, not syntax, and there's no JSON form of a model separate from .lite yet. generateJsonSchema comes closest, but it's a projection of the model, not the model.

    Lives in
    Litestone's parser
    Closest
    Prisma schema, ZenStack's ZModel, Ash resources, and JSON Schema's vocabularies for the shape half
  2. 02

    The gate ladder

    Access as a number. A model's gate says how high a caller has to stand to read, create, update or delete, and a session is graded onto the same scale. Rungs 0 to 7 are callers. Rung 8 is the app acting on its own behalf, and 9 means there's no code path at all, so those two are never compared with >=.

    On the client, an answer the UI doesn't know is treated as a yes, because the data layer enforces the gate either way.

    Lives in
    @frontierjs/toolbelt/gate
    Closest
    RBAC, Zanzibar and Cedar. None of them is ordinal, and that's the new part.
  3. 03

    Row and field policy

    A gate answers who. A policy answers which rows. @@allow and @@deny are predicates over a row and its fields, and one evaluator gives two kinds of answer: yes or no when everything is known, and a leftover condition the query applies when the row hasn't been read yet.

    Protected fields belong here too. @encrypted, @guarded and @secret each make a promise, and every one of them shows up as [redacted] in the audit trail.

    Lives in
    Litestone's policy evaluator
    Closest
    partial evaluation in Cedar and OPA, and PostgreSQL row-level security
  4. 04

    The expression language

    Policies and flows are written in the same small language. The spec covers its grammar in two dialects: one over a single record, for policies, and one with paths, calls and lambdas, for flows. It also says what every operator does with null.

    Lives in
    @frontierjs/toolbelt/predicate
    Closest
    Google's CEL, which gets a hard look before anyone writes a new one
  5. 05

    Write announcement

    Every write emits exactly one event: create, update, remove or transition. The audit trail, live channels and the data on the client all hang off it. The event's origin is the row, never the caller.

    Lives in
    Litestone's $tapEvents
    Closest
    CloudEvents, as an envelope this could narrow
  6. 06

    Service description

    A service is CRUD plus the methods you declare, and declaring methods narrows what's exposed. The description says who may call each method and what the call announces. Junction generates OpenAPI too, but OpenAPI has no field for either of those.

    Lives in
    Junction's svc.describe()
    Closest
    OpenAPI
  7. 07

    The result envelope

    One shape on both sides of the wire: kind, data, errors, total, limit, offset, and a cursor for the next page. kind is always stated and never guessed. A list keeps its envelope and a single record unwraps. The same function does both, on the server and in the client.

    Lives in
    Junction's envelope
    Closest
    JSON:API, and OData's collection responses
  8. 08

    The error contract

    How a thrown error becomes an HTTP status, in a fixed order. Which errors are retryable, meaning re-read and try again, and which are refusals. And the shape of field errors that a form reads. Junction already commits a table of these cases, which is most of a test suite.

    Lives in
    Junction's errors, with errors.snapshot.md as the table
    Closest
    RFC 9457, Problem Details for HTTP APIs
  9. 09

    Directives and query values

    What a list request means. There are five directives, $limit, $offset, $orderBy, $select and $after, and a request carrying any other $ key is refused by name. A query-string value is a number only if it round-trips, and brackets carry structure.

    Ordering and paging go here too: how ties are broken, and why a list that can't break its ties gives no next-page cursor rather than a wrong one.

    Lives in
    @frontierjs/toolbelt/directives and /query
    Closest
    OData query options, PostgREST and FIQL
  10. 10

    Transport binding

    One call, two transports: the HTTP mapping, the WebSocket frame, and the short list of headers a call may carry, since a socket has no headers per call. The test is simple. Make the same call both ways and get the same answer.

    Lives in
    Junction's bridge, checked by verifyTransportParity
    Closest
    JSON-RPC 2.0 for the frame, RFC 9110 for HTTP

Right behind them

Events and channels
A channel is a subscription, not a permission. Every frame is graded for the person receiving it.
The live node store
Does a record that was just pushed still belong in this list? The answer is yes, no, or ask the server. There's one node per row, and a list is a view over nodes.
Resource
How a screen binds to a service: load, save, a field's options, and a single record. The rest of the UI sits on it.

Where the tests come from

A schema implies its own checks: the gate ladder, constraints, field protection, row policies and tenant isolation. Generate those for any app, each with a case that must fail, and you have test vectors for most of the list above. Litestone's verify checks and fli test:access are the start of it.

Where writing starts

Provider
The smallest one, and its tables are already closed. A send never throws, its errors come from a fixed list, and each kind says whether it's safe to retry.
Queue
The most novel one. BullMQ, Sidekiq, Oban and Temporal each answer it differently, and there's no standard to narrow.

Nothing in the ten depends on either one, so both can be written first.

Already a standard

Where a standard exists, FrontierJS implements it or narrows it, and doesn't write a competitor.

What stays FrontierJS

.lite and .mesa are syntaxes, and a spec describes the JSON they compile to. SQLite, Bun, fli, file-tree routing, the app layout and the port formula are FrontierJS's own choices. Another implementation makes its own.

A spec with one implementation is a README.

See: The story · The seams · Landscape