# Lith
> Lith is a web language for AI agents: TypeScript + JSX + Prisma-style queries plus the declarations
> `table page api crud layout` and `state`/`effect`/`auth`. One global namespace, server/client split
> inferred, compiles to standard JS/HTML/CSS on Node. Status: v0 specification.
This file holds the language card followed by the full specification.
## Language card
Lith = TypeScript + JSX + Prisma-style queries + the declarations `table page api crud layout` (at line start) and
`state`/`effect`/`auth`. All `.lith` files share one global namespace: never import your own files or Lith names; import
npm packages normally. JS built-ins (`Date`, `Set`, `fetch`, `setTimeout`, regex) work as in TS. Server code needs no
`await` (harmless).
## Tables
```tsx
table Post {
title: string(1..200) // refinement: length 1..200, checked on every write
body: string // any text, "" allowed
slug: string(1..80) unique // modifiers (unique, index, renamedFrom("old")) go anywhere after the base type
views: int(0..) = 0 // int(a..b) / number(a..b): value range
done: boolean = false
status: "Draft" | "Live" = "Draft"
due: day | null // day = "YYYY-MM-DD" (from ); compare with today()
author: User | null // reference: field authorId; include: { author: true } loads it
tags: Tag[] // many-to-many (join table is automatic)
created: Date = now()
}
```
- `id: int` is automatic; never declare it. `T | null` = optional. A new column with a default or `| null` needs no
migration. To rename a column and keep its data: `unitPriceCents: int(0..) renamedFrom("priceCents")`.
- `table Note own { ... }` adds `owner: User`, fills it on create and limits every query, update and delete to the
logged-in user's rows (others act as missing; login required). Use it only for private data. Shared rows with an owner
get `owner: User` plus checks in code. `system(() => ...)` runs code without `own` scoping; it is allowed only in
`auth(...)` pages/functions, `test` and `seed { }` blocks.
- `User` exists (`email: email unique`, `name: string`, `passwordHash`; `email` = text "@" text "." text). Add columns
with `table User { role: "Admin" | "Member" = "Member" }`. `passwordHash` never reaches the browser or JSON. Table
names may be any word (`Image`, `Event`).
## Queries (Prisma Client)
`db.post.findMany findFirst findUnique findUniqueOrThrow count create update delete updateMany deleteMany` with `where
data orderBy skip take include`; operators `equals not in contains startsWith endsWith lt lte gt gte AND OR NOT`
(`contains` and string sorting ignore case). `findUniqueOrThrow` → the row or a 404; `update`/`delete` of a missing row
→ 404. Nulls: `orderBy: [{ done: "asc" }, { due: { sort: "asc", nulls: "last" } }]`. Many-to-many:
`data: { tags: [tagA, tagB] }` sets the list, `where: { tags: { some: { name } } }` filters, `include: { tags: true }`.
A row loaded with `include` has those fields in its type: a `posts: Post[]` prop may read `p.tags` if the page loaded them.
## Pages and layout
```tsx
auth page "/posts/:id/:slug" (id: int, slug: string, q: string = "") { // path params typed and URL-decoded;
const post = db.post.findUniqueOrThrow({ where: { id } }) // other params come from the query string
if (slug !== post.slug) redirect(`/posts/${id}/${post.slug}`, 301) // default status 303
return {post.title}
}
page "/todos/:id/:s" (id: int, s: string) {
const t = db.todo.findUniqueOrThrow({ where: { id } })
if (s !== slug(t.title)) redirect(`/todos/${id}/${slug(t.title)}`, 301)
return
{t.title}
{t.created}
}
layout {
return
{children}
}
function addTodo(title: string, due: day | null) { db.todo.create({ data: { title, due } }) }
function setDone(id: int, done: boolean) { db.todo.update({ where: { id }, data: { done } }) }
function signOut() { logout(); redirect("/login") }
function slug(s: string) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-") }
function Counter(start: int) {
state n = start
return
}
crud "/api/todos" Todo: list, get
test("adding a todo stores it", () => {
const u = db.user.create({ data: { email: "a@b.c", name: "A", passwordHash: hashPassword("x") } })
asUser(u, () => addTodo("milk", null))
expect(system(() => db.todo.count())).toBe(1)
})
```
What the compiler infers here, with nothing written:
- `addTodo`, `setDone` and `signOut` use `db` or `logout`, so they are server functions, and the page's handlers and
form become RPC and form posts.
- `slug` is pure and ships wherever it is used.
- `Counter` uses `state`, so it is an island.
- `/todos/:id/:s` has no island and ships 0 kB JS.
- `Todo` is `own`, so every query on it gets `ownerId = :me` and the pages require login.
- The test runs against an in-memory database.
## 3. Lexical structure and grammar
Lith source is the TypeScript 5 TSX grammar (strict) plus a few contextual declarations. Files end in `.lith`. All files
of a project form one module with one global namespace. Importing an own file or a Lith name is E0206 (safe patch:
delete). npm packages are imported with ES `import`. Declaration keywords are keywords only at the start of a top-level
statement, so `page`, `table`, `state`, `path` and `day` remain valid names elsewhere (an r0 measurement: a reserved
`page` keyword made a task unfixable).
```
decl = tableDecl | pageDecl | apiDecl | crudDecl | layoutDecl | seedDecl | authFn | tsTopLevel
seedDecl = "seed" block // runs once on an empty database
tableDecl = "table" Ident ["own"] "{" { field (NEWLINE | ",") } "}"
field = ident ":" ktype { modifier } ["=" expr] { modifier }
modifier = "unique" | "index" | "renamedFrom" "(" STRING ")"
ktype = base [range] { "|" base } [ "|" "null" ] | Ident "[]" // Ident[] = many-to-many
range = "(" [num] ".." [num] ")"
base = "string" | "int" | "number" | "boolean" | "Date" | "day" | "email" | Ident | STRING
pageDecl = [auth] "page" STRING [params] block
apiDecl = [auth] "api" METHOD STRING [params] block // METHOD = GET POST PUT PATCH DELETE
crudDecl = [auth] "crud" STRING Ident [":" op { "," op }] // op = list get create update delete
layoutDecl = "layout" block // children, me, path in scope
authFn = auth "function" ... // TS function declaration
auth = "auth" ["(" expr ")"]
params = "(" [ param { "," param } ] ")" param = ident ":" ktype ["=" expr]
```
Inside function bodies, `state ident [":" type] "=" expr` is a statement (components only). Refinement types
(`string(1..200)`) are allowed in every parameter annotation.
**Modifier order is free** (`string(1..32) unique | null` and `string(1..32) | null unique` both parse). In r3, 5 of 8
blind testers wrote `unique` before `| null`, and three designs rejected it. `lith fmt` rewrites to the canonical
order: type, `| null`, modifiers, default. Empty `()` after a page path is accepted and removed by `fmt`.
**Restricted TypeScript.** No `enum`, `namespace`, decorators, `var`, class components, or `==`/`!=` (E0110, with a
safe patch to `===`/`!==`); no `any` written in source (E0302); no `className` (E0605, safe patch: `class`); no `style`
attribute (E0606, a patch to Tailwind classes when they map).
**Formatter canon.** Prettier style (no semicolons, double quotes, width 110, no trailing commas) extended to the
declarations. `fmt(fmt(x)) == fmt(x)`.
## 4. Types and standard library
TypeScript strict mode, with these Lith types:
| Type | Meaning |
| --- | --- |
| `int`, `number` | integer / float JS numbers; `int` is checked at every boundary |
| `string(a..b)`, `int(a..b)`, `number(a..b)` | refinements (length / value); assignable to the base type |
| `email` | string matching text `@` text `.` text, with no spaces |
| `day` | `"YYYY-MM-DD"` string; `today()` gives the current UTC day; compares and sorts as a string |
| `Date` | JS Date (timestamp); JSON is ISO 8601; renders in JSX as `YYYY-MM-DD HH:mm` |
| `T \| null` | absence (no `undefined` in table or param types) |
| `"A" \| "B"` | string-literal union (no `enum`) |
| table `T` | row type with implicit `id: int`; a reference field `author: User` adds `authorId: int`; relation fields (`author`, `tags`) are in the type only where loaded with `include` (6.2) |
| `Html` | JSX value; the type of `children` |
**Annotations at boundaries only:** parameters of top-level functions, components, pages and apis, and table fields.
Everything else is inferred.
**Boundary validation.** Page params, api params and bodies, server-function arguments from forms and RPC, and every
`create`/`update` are checked against the declared types, refinements included. All failures are collected at once
(section 6.6). `p: Table.field` reuses that column's type and refinement; `p: Table.field unique` also checks that
no row has the value yet (opt-in, so login and profile-edit lookups do not fail with "already exists").
**Standard library (global, never imported).**
- `db` (section 6.2).
- `me`, `path`.
- `login`, `logout`, `loginWithPassword`, `hashPassword`.
- `redirect`, `notFound`, `forbidden`, `unauthorized`, `fail`.
- `today`, `now`, `system`.
- Tests: `test`, `expect`, `asUser`.
- `state`/`effect` for components.
- The web platform: `Response`, `Request`, `fetch`, `URL`, `setTimeout`.
## 5. Effects and the server/client split
Effects are inferred, never written, and shown by `lith sig`.
| Effect | Introduced by | Tier |
| --- | --- | --- |
| `db` | any `db.*` call, `system` | server |
| `auth` | `me`, `login`, `logout`, `loginWithPassword`, `hashPassword` | server |
| `env` | `process.env.X` (except `PUBLIC_*`, inlined) | server |
| `client` | `state`, `effect`, `window`, `document`, `localStorage` | client |
1. `page`, `api`, `crud`, `layout` and tests run on the server, as does any component that is not an island. Such a
component may read `db` and `me`.
2. A top-level function with a server effect is a **server function**. On the server it is a plain synchronous call. A
server function that client code references (a form `action`, a server-markup `on*` handler, or an island) gets an
endpoint and becomes a typed RPC (`POST /_lith/rpc/`, JSON array of arguments, validated), returning a Promise in
the browser. Server functions that only server code calls get no endpoint.
3. A component that declares `state` or calls `effect` is an **island**: rendered on the server, then hydrated.
Components rendered inside an island are client code too. Reading `db` or `me` in island render is E0401 (message:
load in the page and pass as a prop). Islands may `fetch` the app's own routes and any URL.
4. In server markup (a page, layout or component without `state`), an `on*` handler must be a single call to one server
function. Its arguments may be any expressions; they are evaluated on the server at render (`!t.done` is fine). It
compiles to a delegated listener that posts those values, and no island is needed. Any other handler body needs a
component with `state` (E0406). This rule is on the card.
5. Function props may pass between components on the same tier. A function cannot cross from server to client (E0403),
except a server function, which becomes an RPC. Rows crossing to the client never include `passwordHash`.
6. **Revalidation.** After a browser-invoked server function succeeds, the runtime re-fetches the current page and
morphs the DOM. Islands receive the new props from the server render, island state is preserved by position and
key, and the submitted form resets (the final blind test had 2 of 2 testers relying on props updating without the
card saying so). After a failed **form**
submission (enhanced or not), the page re-renders with messages and kept values (6.6). After a failed `on*` or island
call nothing re-renders, and the Promise rejects (see 7).
7. **Await.** Server code is written synchronously; `await` is accepted and ignored there. The compiler lowers every `db`
call to a suspension point, so async drivers (edge targets) need no source change. In islands, server calls and
`fetch` are Promises. A Promise used as a condition or as a value (`if (save())`, `x = save()`) is E0311 (safe
patch: `await`). A server-call Promise that is discarded without a handler is W0311 (warning). In that case the island's
error boundary handles a rejection: it restores the island's state to what it was before the handler ran and shows
the default inline message "Couldn't save — try again". `await` in `try`/`catch` stays the recommended form for custom
messages.
## 6. Declare layer
### 6.1 table
- Fields are `name: type [modifiers] [= default]`, separated by newlines or commas. Defaults are literals, `now()`
(Date) and `today()` (day). A field is required on create when it is non-null and has no default.
- `id: int` is implicit; declaring it is E0508 (safe patch: delete).
- `unique` adds a unique index and allows several nulls. `index` adds an index. `renamedFrom("old")` keeps data across a
rename.
- **Many-to-many:** `tags: Tag[]` creates a hidden join table `PostTag(postId, tagId)`. Writes take
`data: { tags: Tag[] | int[] }` and replace the set. Reads use `include: { tags: true }`. Filters use
`where: { tags: { some: { … } } }`.
- **`User`** is predeclared `{ email: email unique, name: string, passwordHash: string }`. `table User {…}` adds columns
(redeclaring a built-in is E0509). `passwordHash` is never serialized to JSON, RPC or island props.
- **`own`:** `table Note own {…}` adds `owner: User`. Every read appends `ownerId = :me` (joins included),
`update`/`delete` match only owned rows, and `create` fills `ownerId` and rejects it in `data`. Without a login any
access is `unauthorized()`, which redirects pages to `/login` and gives 401 elsewhere. `system(() => …)` runs a block
without `own` scoping. It is allowed only inside `test` blocks, `seed { … }` blocks (run once on an empty database)
and functions or pages declared `auth(pred)`; anywhere else it is error E0510. `lith check --list-system` lists every
use. For shared rows with an owner, use a plain `owner: User` reference and check it in
code. Lith therefore has one declarative row rule (`own`) plus code checks (`auth(pred)`, `forbidden()`), so the
authorization axis is `both`. General `allow` rules were measured and not adopted (`provenance.md`).
- **Table names** live in the Lith module scope and shadow browser globals (`Image`, `Event`). Naming a table `Request`
or `Response` is W0207.
### 6.2 Queries (`db`)
`db.` exposes this Prisma Client subset:
| Call | Returns |
| --- | --- |
| `findMany({ where, orderBy, skip, take, include, select })` | `T[]` |
| `findFirst(args)` / `findUnique({ where })` | `T \| null` |
| `findUniqueOrThrow({ where })` | `T`, or throws `notFound()` |
| `count({ where })` | `int` |
| `create({ data })` / `update({ where, data })` | `T` (update on a missing or not-owned row: `notFound()`) |
| `delete({ where })` | `T` (missing: `notFound()`) |
| `updateMany` / `deleteMany` | `{ count }` |
Where operators are `equals not in contains startsWith endsWith lt lte gt gte AND OR NOT` and relation filters
`some/none/every`. `contains`/`startsWith`/`endsWith` ignore case, and string columns sort `NOCASE`. `orderBy` entries
take `"asc" | "desc"` or `{ sort, nulls: "first" | "last" }`. `findUnique`/`update`/`delete` may add non-unique
filters beside `id` (Prisma ≥ 5), e.g. `update({ where: { id, archived: false } })`. A `NaN` in `where` matches
nothing.
**Included relations in types.** A query with `include: { tags: true, author: true }` returns rows of type
`Post & { tags: Tag[], author: User | null }`. A parameter typed with the table type (`posts: Post[]`) may read a
relation field (`p.tags`). The compiler then requires every call site to pass rows loaded with that `include`; a call
site whose rows lack it is E0513 (patch: add the field to the query's `include`, safe when the rows come from one
query). The final blind test had 2 of 2 testers type an island prop `Card[]` and read `c.labels`; this rule makes that
program correct as written. Every query compiles at build time to one parameterized SQL string. Unknown fields or operators are E0502
(nearest-name patch).
### 6.3 Migrations
`db/schema.lock.json` records the applied schema. `lith dev` applies additive changes automatically: new tables,
nullable or defaulted columns, and renames declared with `renamedFrom`. `lith migrate` writes
`db/migrations/NNNN.sql`. Drops and type changes need `--allow-destructive` (E0505, with a patch that suggests
`renamedFrom` when one column disappears and one appears).
### 6.4 page and layout
- `page "/a/:id/:b" (id: int, b: string, q: string = "", n: int = 1) { … }`. Path params must be declared; they are
URL-decoded and coerced, and a value that does not parse gives 404. Other params come from the query string and need
a default or `| null` (E0601). Pages are GET only and return JSX, a `Response`, or throw a helper.
- `auth page` requires a login (else 303 to `/login`, configurable). `auth(pred) page` also requires `pred`, else 403.
`me: User` inside.
- `layout { … }` (at most one) wraps every page, including error pages. In scope: `children: Html`, `me`, and
`path: string` (the request path without the query string; r3 measured a 4/4-design failure without it). If it returns
``, those attributes go on the document's ``.
- `` and `` anywhere move into ``. Literal `href`s that start with `/` are checked against declared
routes (E0602).
### 6.5 api and crud
- `api METHOD "/path" (params) { … }`. Params come from the path, the query string or the JSON body and are validated
(400 `{"error", "field"}`). A plain value returns 200 JSON (rows: all columns, `Date` as ISO); a `Response` passes
through (`Response.json(v, { status })`, `new Response(csv, { headers })`). Literal segments may contain dots
(`/api/orders.csv`).
- `crud "/api/x" T [: ops]` generates:
- list: a JSON array in `id` order;
- get: the row, or 404 `{"error":"not found"}`;
- create: 201 with the row, or 400 `{"error","field"}`;
- update: PATCH, partial, same validation, or 404;
- delete: 204, or 404.
`own` scoping and `auth` apply (401 logged out).
- Non-GET routes require a same-origin `Origin` or an `Authorization` header.
### 6.6 Server functions, forms and failure
- **Forms.** `