---
name: cubek
description: Build, check and deploy web apps on cubek.dev in cubek-ts (a typed TypeScript subset): Hono-style routes, server-rendered TSX, one SQLite database per app, npm only in browser code. Read before writing any code when a project has a cubek.json, or to build, port or deploy an app on cubek.
---

Read this file verbatim; if a tool summarized it, fetch it again with curl or run `cubek skill`.

# cubek

**Read this before writing any code.** cubek is built on from the first
line: `cubek init`, then routes, `schema.ts` and pages as below. Porting an
existing project is the same work: `cubek init`, move its handlers into
routes, its tables into `schema.ts`, its views into TSX pages or `client/`,
and fix what `cubek check` reports. cubek does not host an app as-is (no
Node, Docker or server npm). The work is fast: never estimate in days or
weeks; start building now and iterate with `cubek check`.

**cubek-ts**: valid TypeScript (static types, no dynamic JavaScript) run by
cubek's bytecode runtime, same behavior under bun.

Depth lives in sub-skills; read one only when you need that topic (also
`cubek skill <topic>`):
- https://cubek.dev/skills/database.md: queries, races, transactions, SQL, migrations, search, seeds
- https://cubek.dev/skills/http.md: route files, middleware, cors, webhooks, fetch, errors
- https://cubek.dev/skills/seo.md: meta tags, OpenGraph, JSON-LD, RSS/Atom feeds, sitemap, robots
- https://cubek.dev/skills/auth.md: sign-up, sessions, roles, API tokens, rate limits
- https://cubek.dev/skills/frontend.md: islands, Preact/React, SPA, live queries, realtime
- https://cubek.dev/skills/tasks.md: queues, delays, retries, cron, `task run`
- https://cubek.dev/skills/storage.md: uploads, signed URLs, downloads
- https://cubek.dev/skills/workspaces.md: several apps, RPC, events, shared db
- https://cubek.dev/skills/testing.md: unit tests, route checks with curl
- https://cubek.dev/skills/deploy.md: previews, prod, domains, secrets, logs, rollback

## Workflow

    cubek init my-app      # starter (--empty: no example table)
    cubek dev              # http://127.0.0.1:8787, recompiles on save
    cubek dev --stop       # this project's server only (never pkill)
    cubek check            # compile, after every edit
    cubek test             # bun test *.test.ts
    cubek login            # a human, once, in the browser (or --token T)
    cubek deploy [--prod]  # https://<name>--r<N>.cubek.app; --prod: <name>.cubek.app

Apply each error's `canonical form:`. `check` lists route classes (db writes
run on the primary: keep them in few routes). All commands take `--json`, `--help`.
- Deployed: `logs [--follow]`, `sql "SELECT ..."`, `secrets set NAME VALUE`,
  `rollback <rev>` (deploy.md). Local: `sql --dev "..." [--write]`, `secrets
  --dev set`, `task run <name>` (tasks.md); mail lands in `.cubek/mail/*.eml`.
- **MCP** (`claude mcp add cubek -- cubek mcp`): the CLI, `sdk_types` (the
  exact API as .d.ts), `whoami` (on 401/403).

## Project structure

    cubek.json        { "name", "entry": "src/app.tsx", "schema": "schema.ts" }
    schema.ts         tables; `db` is typed from it
    src/app.tsx       routes (the entry: keep this name)
    src/routes/*.tsx  more routes (top-level `app.get`), `import "./routes/auth"` in app.tsx
    src/pages/*.tsx   TSX templates (JSX only in .tsx)
    src/lib/*.ts      logic; *.test.ts beside it (not deployed)
    src/mounts.ts     mount points for browser code
    client/           browser code (TS, npm), bundled by bun
    public/           static files at /<path>
    .cubek/sdk        SDK types (`cubek types` rewrites them)

Local imports have no extension (`./lib/notes`); named exports only.

## Routes and the context `c`

```tsx
import { app, crypto, db, validate, type Context } from "cubek"
import { Home } from "./pages/home"

app.get("/", async (c) => c.render(<Home notes={await db.notes.find({ limit: 20 })} />))

app.get("/api/notes/:id", async (c) => {
  const note = await db.notes.get(c.param("id"))
  return note === null ? c.notFound() : c.json(note)
})

const noteInput = validate.object({ title: validate.string({ min: 1, max: 200 }), body: validate.string() })

app.post("/notes", async (c) => {
  const parsed = noteInput.safeParse(await c.body())  // JSON, urlencoded or multipart; malformed: 400 for you
  if (!parsed.success) return c.json({ error: parsed.error }, 400)
  const note = await db.notes.insert(parsed.data)
  return c.redirect(`/notes/${note.id}`, 303)  // 303 after a POST
})

app.use("/admin/*", async (c, next) => c.header("x-admin") === "yes" ? next() : c.text("forbidden", 403))
```

- `app.get/post/put/patch/delete(path, [middleware,] handler)`, `app.use([path,]
  ...middleware)` (also for unmatched paths). First registered wins: `/:slug`
  and the catch-all `/*` (after `public/`) go last.
- `c.param/query/header/cookie(name)` → `string | null`
  (`Number(c.query("page") ?? "1")`); `c.url()`, `c.path()` (no query),
  `c.origin()` (absolute links: RSS, mail, ICS), `c.ip()` (not spoofable);
  `await c.body<T>()` is unvalidated; `await c.rawBody()` → exact `Bytes`; file
  fields: `{ name, type, size, bytes }` (storage.md).
- Responses (`type Response`): `c.json/text/html(body, status?, headers?)`
  (headers replace the content-type), `c.body(bytes, status?, headers?)`,
  `c.render(jsx, status?)`; `c.status(code)`, `c.setHeader`.
- Middleware: `rateLimit({ limit: 20, window: "1m" })`, `cors({ origin })` (http.md).
- Forms send strings: `validate.number({ coerce: true })`, `validate.boolean({
  coerce: true })`; `tags[]` fields → `string[]` (repeated fields: http.md).
- A thrown `Error` is an empty 500 (details: `cubek dev` output, `cubek logs`).

## Schema and `db`

```ts
// schema.ts
import { table, t } from "cubek"

export const notes = table("notes", {
  id: t.id(),  // string ULID, generated
  title: t.string(),
  body: t.string(),
  tags: t.json<string[]>().nullable(),  // any JSON value
  pinned: t.boolean().default(false),
  views: t.number().default(0).min(0),
  status: t.enum(["draft", "published"]).default("draft"),
  authorId: t.string().nullable(),
  createdAt: t.timestamp().default("now"),  // set on insert only
}).index("status", "createdAt").search("title", "body")

export const comments = table("comments", { id: t.id(), noteId: t.ref(notes), text: t.string() })
export const sessions = table("sessions", { id: t.id(), tokenHash: t.string().unique(), userId: t.string() })
```

- Also `t.bytes()`, `.unique({ nocase: true })`, `.max(n)` (strings: length),
  table `.unique("pollId", "voterId")`. Broken constraints throw in `insert`/`update`.
- schema.ts changes migrate themselves (tables, nullable/defaulted columns,
  indexes, unique, search); dropping, retyping or a new check needs `deploy --allow-destructive`.

```ts
import { db, type Row } from "cubek"
import type { notes } from "../../schema"

export type Note = Row<typeof notes>

const recent = await db.notes.find({
  where: { status: "published", createdAt: { gte: since }, or: [{ pinned: true }, { title: { contains: q } }] },
  orderBy: [{ pinned: "desc" }, { createdAt: "desc" }, { id: "desc" }],  // id breaks ties
  limit: 20,
})
const one = await db.notes.findOne({ where: { title: "Hello" } })  // Note | null
const hits = await db.notes.search(q, { where: { status: "published" }, limit: 10 }) // best first
const n = await db.notes.count({ search: q })  // total search hits
const note = await db.notes.insert({ title: "Hi", body: "" })  // returns the row
await db.notes.update(note.id, { pinned: true })  // row | null; pass updatedAt yourself
await db.sql<{ views: number }>`UPDATE notes SET views = views + 1 WHERE id = ${note.id}`  // atomic
await db.comments.deleteWhere({ noteId: note.id })  // count
await db.transaction(async (tx) => {  // a throw rolls it back
  const a = await tx.notes.insert({ title: "A", body: "" })
  await tx.comments.insert({ noteId: a.id, text: "first" })
})
const rows = await db.sql<{ status: string; n: number }>`SELECT status, COUNT(*) AS n FROM notes GROUP BY status`
```

- Also `get(id)`, `delete(id)` → boolean, `insertMany(rows)`,
  `updateWhere(where, patch)`, `sum(col, { where })`, `min`/`max`.
- Where: `ne gt gte lt lte in isNull`, text `contains startsWith endsWith`
  (literal), `like`, `and`/`or`.
- Timestamps are UTC ISO strings (compare with `toISOString()` values).
- `db.transaction` runs alone (serializable, ≤ 100 ms, no `fetch`); outside,
  read-then-write can interleave. No joins: two queries or `db.sql` (database.md).

## TSX templates (not React)

- Components: plain functions, may be `async` and query `db`. HTML
  attribute names (`class`, `for`, `maxlength`), `selected={isOn}`,
  `<>...</>`; no `{...props}`, no `<Ui.Button>`. Text is escaped; trusted
  HTML: `raw(markdown.render(text))`. SVG is TSX (`<path d={d} />`); JSON-LD:
  `{jsonLd({ "@type": "Event", name })}`; `asset(path)` links `public/<path>`.

```tsx
// src/pages/home.tsx
import { asset, type JSX } from "cubek"
import type { Note } from "../lib/notes"

export function Layout(props: { title: string; children?: JSX.Child }): JSX.Element {
  return <html lang="en"><head><meta charset="utf-8" /><title>{props.title}</title><link rel="stylesheet" href={asset("styles.css")} /></head><body>{props.children}</body></html>
}

export function Home(props: { notes: Note[] }): JSX.Element {
  return (
    <Layout title="Notes">
      <form method="post" action="/notes"><input name="title" required maxlength={200} /><textarea name="body"></textarea><button>Add</button></form>
      {props.notes.length === 0 ? <p>No notes yet.</p> : <ul>{props.notes.map((n) => <li key={n.id}><a href={`/notes/${n.id}`}>{n.title}</a></li>)}</ul>}
    </Layout>
  )
}
```

Sessions are app code (sign-up, sign-in, sign-out: copy auth.md): a
random token in `c.setCookie("sid", token, { httpOnly: true, sameSite:
"lax", path: "/" })`, its SHA-256 in `sessions`, passwords via
`crypto.password.hash/verify`.

```ts
async function currentUserId(c: Context): Promise<string | null> {
  const token = c.cookie("sid")
  if (token === null) return null
  const s = await db.sessions.findOne({ where: { tokenHash: crypto.hash("sha256", token) } })
  return s === null ? null : s.userId
}
```

## Browser code and live queries

Pages render on the server. Interactivity: prefer **Preact** (`bun add
preact`, 7 KB gz) as a `*.hydrate.tsx` component (below) or in `client/`
(TS/TSX, npm, bundled at deploy). React (`cubek/client/react`)
adds 65 KB gz: only for React libraries. A server file declares a mount
point; a page renders it:

```tsx
// src/mounts.ts
export const search = mount<{ q: string }>("search")
// page: <Mount at={search} props={{ q }} live={[await live(notesQuery, { q })]}><p>…</p></Mount>
```

```ts
// src/queries.ts: live, re-runs on change
export const notesQuery = query(async (c, args: { q: string }) => {
  const userId = await currentUserId(c)   // anyone may subscribe: check it
  if (userId === null) throw new Error("signed out")
  return await db.notes.find({ where: { authorId: userId, title: { contains: args.q } }, limit: 50 })
})
```

```tsx
// client/search.tsx
/** @jsxImportSource preact */
import { render } from "preact"
import { onMount } from "cubek/client"
import { useQuery } from "cubek/client/preact"
import { notesQuery, search } from "cubek/server"   // typed from the server
function Search(props: { q: string }) {
  const notes = useQuery(notesQuery, { q: props.q }) ?? []
  return <ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>
}
onMount(search, (el, props) => render(<Search {...props} />, el))
```

Props are plain data; queries only read (writes: POST routes, fetch or forms).
No props or args, what re-runs a query, `refresh`, single-page apps: frontend.md.

**Preact hydration** (no mount point): a `*.hydrate.tsx` file in `src/`
(cubek-ts, hooks from "cubek/client/preact"); pages render it, the browser hydrates it.

```tsx
// src/components/counter.hydrate.tsx
import type { JSX } from "cubek"
import { useState } from "cubek/client/preact"
export function Counter(props: { start: number }): JSX.Element {
  const [n, setN] = useState(props.start)
  return <button onClick={() => setN(n + 1)}>{n}</button>
}
// a page: import { Counter } from "../components/counter.hydrate"  →  <Counter start={0} />
```

## Other primitives (all from "cubek")

- `kv.get<T>(k)`, `set(k, v, { ttl: "10m" })`, `incr(k, by?, { ttl: "1m" })` (ttl from creation), `delete(k)`.
- `task.on("name", async (p: { id: string }) => {...})`, `task.run("name", { id }, { delay: "5m", retries: 3 })`, `task.schedule("name", "0 8 * * *")` (cron, UTC); tasks may run twice: be idempotent.
- `storage.put(key, bytesOrString, { contentType })`, `get(key)` → `Bytes | null`, `delete`, `list(prefix)`, signed `url(key)` / `uploadUrl(key, { maxSize })`.
- `realtime.publish("room:1", event)` → `EventSource("/_cubek/realtime?channel=room:1")`.
- `fetch(url, { method, headers, body, timeout: "5s" })` (≤ 10 s) → `res.status`, `res.json<T>()`, `res.text()`; public addresses only (http.md).
- `secrets.get("NAME")` → `string | null`; `log.info/warn/error(msg, data?)`, `console.log`.
- `mail.send({ to, subject, text, html?, attachments? })` (tasks.md).
- `validate.string/number/boolean/literal/enum/array/object/record`, `.optional() .nullable()`; `.parse` throws, `.safeParse` → `{ success, data | error }`; `Infer<typeof v>`.
- `crypto.hmac/timingSafeEqual` (webhooks: http.md), `crypto.randomUUID()`, `randomInt(max)` (0 ≤ n < max); `encoding.base64/hex.encode/decode`; `markdown.render(s)` → `SafeHtml`, `markdown.html(s)` → string (feeds); `csv.parse(text, { header: true })`; `fmt.date(iso, { format: "date" })`, `fmt.number(n, { decimals: 2 })`, `fmt.currency(n, "EUR")`.
- Several apps: `cubek init --workspace photos --apps web,media`; `apps.media.fn()`
  calls, `events.publish/on` (workspaces.md).

## Tests

`cubek test` runs `*.test.ts` (bun:test) beside the code; I/O primitives
throw there: keep logic pure, try routes with curl on `cubek dev` (testing.md).

## The subset

`cubek check` rejects (with the fix) server npm, `node:*`, `process`,
`Buffer`, `any`, `.then` (`await`, `Promise.all`), timers, `obj[key]` on fixed
fields (`Record<"a" | "b", T>` too; `arr[i]`, `Record<string, T>` fine), `for...in`, `delete`, user generics,
`Intl`, local-time `Date`, `URL`, `Object.groupBy`, reading `unknown`...
Untrusted data (`JSON.parse`, caught errors) is checked, never cast `as T`:
`input.safeParse(JSON.parse(text))` or narrow (`Array.isArray(v)`,
`"k" in v`, `e instanceof Error`). Not taught by an error:

| Not in cubek-ts | Use |
|---|---|
| `class E extends Error` | return `{ ok: false, error }` |
| `getUTCSeconds` | slice `toISOString()` |
| `className`, page `onClick`, `dangerouslySetInnerHTML` | `class`, a mount point or form, `raw(...)` |

Annotate the return types of named functions.
