Litestone · shipped
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
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.
@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. 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 addresslitestone 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 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.
@@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. @@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. @system column or touch a @guarded one, and @@transitions refuses a move from the wrong state or below its own @gate. 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.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
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.
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. @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. [redacted], and a @personal column logs as [personal]. The other fields on the row are kept in full. $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'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
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.
@@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. @@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]. @version column is checked in the update's own WHERE, so the second writer gets a retryable VersionConflictError instead of silently overwriting the first. @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() -> 3asSystem(). 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
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.
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. 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. 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. 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: nopackages/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
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.
| Call | Raw floor | Litestone | Drizzle | Prisma | ZenStack |
|---|---|---|---|---|---|
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 |
@version adds 7% to a create and 35% to an update, and the audit lists the rest. Read more: the performance audit · performance and tuning
Start
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.