FrontierJS

Litestone · shipped

Declare the data once.
Everything else follows.

Litestone is a schema-first SQLite layer for Bun. One .lite file says what your data is, who may touch it and what happens to it over its life — and the types, validators, migrations and access rules are derived from it, not written again in five other places.

One schema

Change a line once. Nothing else to keep in step.

Your data model usually lives in an ORM schema, a validator, TypeScript types, a JSON Schema and migration files, and they drift. In Litestone it lives in one .lite file, and the other four are written from it.

  • One edit, every output. Add a column and its migration, its TypeScript types and its JSON Schema all pick it up the next time you run them. The client reads the same file when your app starts.
  • A rule is written once.@email and @length(3, 120) refuse a bad write in the client and become the matching JSON Schema keywords, so a form or an API checks the same rule your database layer does.
  • Types you never maintain. Each model gets a row type and create, update, where and order-by types, and a field with a default is optional on create.
  • A stale migration fails in CI.litestone migrate check exits 1 when the migration files do not build the schema you declared, with no database needed, so the gap is found before you deploy.
// db/schema.lite, abridged
model Author {
  email    String   @unique @email
  // ...
}

model Post {
  id       Int      @id
  title    String   @length(3, 120)
  status   Status   @default(draft)
  summary  String?  @length(0, 280)   // the one line added
  authorId Int
  // ...
}
$ litestone migrate create summary
ALTER TABLE "post" ADD COLUMN "summary" TEXT;

$ litestone types
export interface Post {
  summary?: string | null
}
export interface PostCreate {
  status?: Status          // has a default
  summary?: string | null
}

$ litestone jsonschema
"summary": { "anyOf": [
  { "type": "string", "minLength": 0, "maxLength": 280 },
  { "type": "null" } ] }

db.author.create({ data: { email: 'not-an-email', ... } })
Validation failed — email: must be a valid email address
A real run of the litestone CLI over a two-model schema, then the one line marked above added to it. Shown abridged: only the lines that edit moved. @length and @email are enforced by the client and written into the JSON Schema, not into the table.

Read more: generated types · JSON Schema output · migrations · validators · the CLI

Access in the schema

Who may is written in the schema, and every path has to ask it.

Who may read a row, write a column or move a status is declared next to the data and enforced where the row is read and written, not in a handler someone has to remember. The way around it is spelled asSystem(), and raw SQL needs it.

  • A gate is a number per verb.@@gate(2.4.4.5) is the level read, create, update and delete each need, from 0 (signed out) to 9 (nobody, asSystem() included), and it is answered before any SQL runs.
  • A row policy is a WHERE clause nobody can leave off.@@allow and @@deny compile into the SQL of every read and update from a signed-in client, so another account's row is not refused, it is not there.
  • Columns and moves are refused by name. A caller cannot write an @system column or touch a @guarded one, and @@transitions refuses a move from the wrong state or below its own @gate.
  • Every path meets the same rules. A job runs as whoever queued it, an agent's MCP tool call is graded again when it runs, and a static page that reads a gated model fails the build unless it lists the columns it may publish.
model Order {
  id           Int         @id
  accountId    Int         @default(auth().accountId)
  total        Int
  trackingCode String?     @system
  stripeToken  String?     @guarded
  status       OrderStatus @default(pending)

  @@gate(2.4.4.5)
  @@allow(read, accountId == auth().accountId)
  @@allow(update, accountId == auth().accountId)
  @@allow(create, accountId == auth().accountId)
  @@transitions(status,
    pay:    pending -> paid,
    ship:   paid    -> shipped,
    refund: paid    -> refunded @gate(5))
}
// two orders: id 1 in account 1, id 2 in account 2
const ana = db.$setAuth({ id: 7, accountId: 1, role: 'member' })  // level 4

db.$setAuth(null).order.findMany()
  AccessDeniedError: "Order.read" requires level 2, user has level 0
ana.order.findMany()
  [{"id":1,"accountId":1,"total":4200,"trackingCode":null,"status":"pending"}]
ana.order.update({ where: { id: 2 }, data: { total: 1 } })
  null
ana.order.update({ where: { id: 1 }, data: { trackingCode: 'TRK-1' } })
  AccessDeniedError: Order: "trackingCode" is @system — written by the application rather than by its caller.
ana.order.findMany({ where: { stripeToken: { startsWith: 'tok' } } })
  AccessDeniedError: Order: "stripeToken" is @guarded — a system-context column on read as well as write, …
ana.order.transition(1, 'ship')
  TransitionViolationError: Cannot transition order.status from 'pending' to 'shipped' — valid transitions from 'pending': 'paid'
ana.order.transition(1, 'pay')
  "paid"
ana.order.transition(1, 'refund')
  TransitionGateError: Transition 'refund' on order.status requires level 5, user has level 4
ana.sql`SELECT * FROM "order"`
  Error: db.$setAuth(user).sql — this schema declares access rules, and raw SQL applies none of them.
A run against Litestone's own client: the schema's Order model above (its User and enum left out), the calls and their answers below, from a scratch directory on 2026-11-02. Output is verbatim except the @guarded message, cut where marked. Account 2's order comes back null, not denied, so a refusal never confirms a row exists, and stripeToken is left out of the read.

Read more: the Warden, layer by layer · gates, policies and what refuses · state machines · raw SQL and asSystem()

Tenants and secrets

Each workspace sees only its own rows. The audit trail keeps no protected value.

One block in the schema says what a tenant is, and one attribute on a field says what a stolen file or a log line may give away. It all rests on a key you keep yourself.

  • A workspace is a wall. With strategy row, another workspace's rows read as empty, an update across the line changes nothing, and a create into it is refused by name. strategy database gives each tenant its own SQLite file instead, and can give each its own key.
  • Three attributes, three promises.@encrypted stores AES-256-GCM ciphertext that only the system reads back, and @hashed stores a one-way HMAC-SHA256 you can match in a where but nobody can read. @guarded encrypts nothing: it makes a column system-only for reads and writes, and @secret is @encrypted plus @guarded, its writes logged wherever the schema declares a trail.
  • The trail records that, not what. A protected field's entry says who wrote which rows and when, with the value as [redacted], and a @personal column logs as [personal]. The other fields on the row are kept in full.
  • Lose the key and the data is gone. An encrypted value reads back only under the key that wrote it, and nothing in Litestone can recover it without that key. $rotateKey moves every encrypted column to a new key, and refuses while a @hashed column would be stranded under the old one.
// schema.lite
tenancy { strategy row  column workspaceId  claim workspaceId }
database audit { path "./audit/"  driver trail }

model Customer {
  id          Int     @id
  workspaceId Int
  name        String
  ssn         String? @encrypted
  apiKey      String? @secret
  loginToken  String? @hashed
  @@trail(audit)
}

$ bun proof.mjs
workspace 10 reads [{"id":1,"workspaceId":10,"name":"Ada"}]
workspace 20 reads []
workspace 20 writes into 10: AccessDeniedError Outside your workspaceId
trail {"operation":"create","records":[1],"actorId":"u1","after":{"id":1,"workspaceId":10,"name":"Ada","ssn":"[redacted]","loginToken":"[redacted]"}}
on disk {"ssn":"v2.59514c9d.4SmPTqFvY5NaOZVlILshUxMA2awKA-AWf_c9VkCix0Qf-2KCb6dP","loginToken":"v2h.59514c9d.4BaG8y-pPHcKZ-hIl0bcIi1Z5BINl2K-4YvL780H43k"}
another key: DecryptionFailedError Customer.ssn: Cannot decrypt: the value names key '59514c9d' and this client holds 'bdd16940'
A run of Litestone's own client against this schema, under two principals in two workspaces. The output is verbatim, the last line cut at its first sentence: on disk is the column read straight from SQLite, and another key is the same file opened with a different key.

Read more: the two tenancy strategies · encrypted, hashed, guarded and secret · key rotation · what the trail redacts

A row's whole life

The delete flag, the audit table and the version check are already written.

Declare what should happen to a row when it is deleted, edited twice at once or numbered, and the engine does it on every write path. Put the shared columns in a trait once and every model that carries it gets them.

  • Delete takes the children and gives them back.@@softDelete(cascade) stamps every child with the parent's own timestamp, and restore() returns the children that stamp marked, leaving one deleted earlier on its own still deleted.
  • The history writes itself.@@trail(audit) logs every create, update and delete with the row before and after, and a field marked @secret or @encrypted is logged as [redacted].
  • A stale save is a 409, not a lost edit. A @version column is checked in the update's own WHERE, so the second writer gets a retryable VersionConflictError instead of silently overwriting the first.
  • Per-account numbers that cannot collide.@sequence(scope: accountId) draws the next number inside the write transaction, and an update that tries to change it is refused.
database audit { path "./audit/"  driver trail }

trait Life {
  version   Int       @version
  deletedAt DateTime?
  @@trail(audit)
}

model Account {
  id       Int @id
  name     String
  invoices Invoice[]
  @@trait(Life)
  @@softDelete(cascade)
}

model Invoice {
  id        Int @id
  accountId Int
  account   Account @relation(fields: [accountId], references: [id])
  number    Int     @sequence(scope: accountId)
  amount    Int
  @@trait(Life)
  @@softDelete
}
// three invoices: two for account 1, one for account 2
number    1, 2, 1        version 1

// Alice and Bob both read invoice 1 at version 1. Alice saves.
update { amount: 120, version: 1 }   ->  version: 2

// Bob saves from the copy he read.
update { amount: 90, version: 1 }
VersionConflictError  409, retryable
  Version conflict on Invoice: expected version 1, row is at 2 -
  it was modified after you read it

// delete the account
account.remove({ where: { id: 1 } })
invoice.count()                       ->  1   live
invoice.count({ withDeleted: true })  ->  3
invoice.findMany({ onlyDeleted: true })
  -> ids 1 and 2, deletedAt "2026-11-03T05:24:19.209Z" on both

// the trail, one entry for the account
operation delete   model Account   records [1]
before { version: 1, deletedAt: null, id: 1, name: "acme" }
after  { version: 1, deletedAt: "2026-11-03T05:24:19.209Z", ... }

account.restore({ where: { id: 1 } })
invoice.count()                       ->  3
A real run against that schema with the package's own client, as asSystem(). Abridged: the calls are shortened and the trail row is cut to the fields that matter. The cascade wrote no trail entry of its own for the invoices, and a soft delete leaves version where it was.

Read more: soft delete and cascade · the audit trail · @version · sequences · traits

Run it in production

One file you can change, copy and watch.

The database is one SQLite file, so you can back it up, ship it in a container or open it from a Bun script. Around it: migrations you read before they run, a replica in your own bucket, a browser UI and a log of every statement.

  • Migrations are diffed, then written down.litestone migrate dev diffs your schema against the migration history and writes plain SQL you can review. A file that would delete values is refused until it says, in the file, that you accept the loss.
  • A replica in a bucket you own.litestone replicate streams every database the schema declares to its own path in an S3-compatible bucket, through the Litestream v0.5 or newer you install. Its tests drive a stand-in for Litestream; a live-bucket run is still owed.
  • Studio, for the database in front of you.litestone studio opens a browser UI to browse and edit rows as any principal you pick, run SQL, and see applied and pending migrations. --readonly refuses every write, and listening beyond localhost takes a --token.
  • Every statement, and who ran it. An onQuery listener gets each statement's SQL, parameters, verb, model, the acting principal's id and its milliseconds in SQLite. A logger that throws never fails the query it was watching.
$ litestone migrate dev add-slug     # added: slug String?, published Boolean @default(false)
  ✓  db/migrations/20261103052436_add_slug.sql
    ~ post  [alter]
        + col  slug TEXT
        + col  published INTEGER
  ✓  20261103052436_add_slug.sql  (16ms)
  1 migration applied

# the file it wrote, abridged
ALTER TABLE "post" ADD COLUMN "slug" TEXT;
ALTER TABLE "post" ADD COLUMN "published" INTEGER NOT NULL DEFAULT 0;

$ litestone migrate dev drop-slug    # removed: slug
  ✓  db/migrations/20261103052442_drop_slug.sql
    ~ post  [rebuild]
        - col  slug
  !  deletes the values in post.slug
  ✗  20261103052442_drop_slug.sql deletes column values and nobody
     has accepted the loss, so nothing was applied. …
# exit 1, and the file it wrote opens with
-- ║ DESTRUCTIVE — applying this deletes the values in these columns …
-- ║     post.slug
-- ║ Accept data loss: no
A real run in a scratch directory, through the package's own bin (packages/litestone/src/tools/cli.js): a Post model holding one row gains two fields, then loses one. Abridged: colors, blank lines and the migrate apply headings are cut, the refusal is wrapped, and the # lines are ours. The row came through the first migration with slug null and published 0, and the refused one left the database unchanged. The box is described in docs/migrations.md.

Read more: migrations · replication and restore · Studio · onQuery logging · the CLI

Speed

Closest to raw SQLite on every call.

The same four calls through four layers over one SQLite file, next to raw bun:sqlite as the floor. The fastest layer in each row is marked; the bar is that call's time against the slowest in its row.

Microseconds per call, lower is better
CallRaw floorLitestoneDrizzlePrismaZenStack
findUniqueone row by id 3.6µs 9.7µs 2.7× raw ✓ fastest 70.2µs 20× raw 197µs 55× raw 228µs 63× raw
findMany100 rows, filtered 51.9µs 83.6µs 1.6× raw ✓ fastest 153µs 3.0× raw 498µs 9.6× raw 279µs 5.4× raw
createinsert one row 32.8µs 85.0µs 2.6× raw ✓ fastest 129µs 3.9× raw 251µs 7.7× raw 244µs 7.4× raw
updateupdate one by id 7.5µs 23.2µs 3.1× raw ✓ fastest 63.1µs 8.4× raw 151µs 20× raw 131µs 18× raw
Median of 7 interleaved rounds, 10,000 rows, WAL, Bun 1.4.4 on Linux, 2026-11-02. Drizzle 0.45 (bun:sqlite), Prisma 7.10 (libsql adapter), ZenStack 3.10 (Kysely over bun:sqlite). Timings drift about 30% from run to run on this machine; the order and the ratios held.

Read more: the performance audit · performance and tuning

Start

A schema is the whole setup.

Install it on its own or inside a FrontierJS app, where the same file also seeds the API and the UI.

@frontierjs/litestone on npm, MIT licensed, Bun 1.0 or later. No server to run beside your app: the database is one file.