---
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

Start with `cubek init`, then routes, `schema.ts` and pages as below.
Porting is the same: handlers into
routes, tables into `schema.ts`, views into pages or `client/`, then 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**: statically typed TypeScript run by cubek's bytecode runtime,
same behavior under bun.

Sub-skills, read when needed (also `cubek skill <topic>`):
- https://cubek.dev/skills/database.md: queries, derived, races, transactions, SQL, migrations, seeds
- https://cubek.dev/skills/http.md: route files, middleware, cors, webhooks, fetch, Claude API, 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: design, web fonts, islands, Preact/React, SPA, live queries, realtime
- https://cubek.dev/skills/tasks.md: queues, delays, retries, cron, `task run`
- https://cubek.dev/skills/time.md: time zones, scheduling, per-day reports
- https://cubek.dev/skills/storage.md: uploads, signed URLs, downloads
- https://cubek.dev/skills/push.md: Web Push, iPhone too
- https://cubek.dev/skills/crypto.md: hashes, bcrypt, TOTP, JWT, encryption
- 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

    curl -fsSL https://cubek.dev/install | sh  # macOS, Linux; Windows: WSL
    cubek init my-app --empty  # a real app (no --empty: an example to delete)
    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 run src/lib/x.ts # runs its `main()`
    cubek login            # prints a URL: give it to the user; finishes by itself
    cubek deploy           # preview https://<name>--r<N>.cubek.app (members only)
    cubek promote <N>      # that preview to production (deploy --prod: a new one)

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] [--route /x]`, `sql "SELECT ..."`, `secrets set NAME VALUE`,
  `rollback <rev>` (deploy.md). Local: `sql --dev "..." [--write]`, `secrets
  --dev set`, `task run <name>` (tasks.md).
- **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`)

Local imports have no extension (`./lib/notes`).

## 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)` (every matching path, any order). The most
  specific route wins (`/tags/new` > `/tags/:id` > `/*`). Paths are literals at top
  level (no loops): one route each, or a param (`/mod/:action`).
- `c.param/query/header/cookie(name)` → `string | null`
  (`Number(c.query("page") ?? "1")`); `c.url()`, `c.path()` (no query),
  `c.origin()` (absolute links: RSS, email), `c.ip()` (not spoofable),
  `c.preview` (null in prod: deploy.md); `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` (before or after
  `c.render`).
- 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: `await c.formAll("ids")`.
- 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`. Nulls as in JS: `field: null` / `{ ne: null
  }` are IS (NOT) NULL; `{ ne: "x" }` keeps nulls.
- 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` (`<Page />`, never `await Page()`). HTML
  attribute names (`class`, `for`, `maxlength`), `selected={isOn}`, `style={{ marginTop: 4 }}`,
  `<>...</>`, `{...props}` (later attributes win); 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>` or a `client/` entry.

```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>
  )
}
```

**Design**: cubek is minimal in its runtime, not its look: build a
polished UI for the app's domain, not the stock look (cream background,
italic accent words, 01/02/03 or monospace labels, pill buttons). A type
pairing (display + text) as web fonts (how: frontend.md), a CSS-variable
palette with dark mode, type/spacing scales, every state (empty, loading,
error, success), responsive, AA contrast, visible focus, realistic data,
domain details.

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** (7 KB gz) as a
`*.hydrate.tsx` component (below, no setup) or in `client/` (TS/TSX, npm,
bundled at deploy; `import "preact"` there needs `bun add preact`). React
(`cubek/client/react`, 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) return null        // signed out: null, not a throw
  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, re-runs, `refresh`, SPAs: 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)`; private: a browser listens with `realtime.token("room:1")` from the page (`public:*`: no token, frontend.md).
- `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`.
- Email: POST to a provider's API with `fetch` + `secrets.get("EMAIL_API_KEY")` from a task (tasks.md).
- `validate.string/number/boolean/literal/enum/array/object/record`, `.optional() .nullable()`; `.parse` throws, `.safeParse` → `{ success, data | error }`; `Infer<typeof v>`.
- `crypto.randomUUID()`, `randomInt(max)`, `hmac` (crypto.md); `encoding.base64/hex.encode/decode`; `markdown.render(s)` → `SafeHtml`, `markdown.html(s)` → string (feeds); `csv.parse(text, { header: true })`; `fmt.date(iso, { format: "date", timeZone? })`, `fmt.number(n, { decimals: 2 })`, `fmt.currency(n, "EUR")`.
- Time zones (IANA, DST): `time.toLocal(iso, "Europe/Madrid")` → `{ year, month, day, hour, minute, weekday, ... }`,
  `time.fromLocal({ year, month, day, hour }, zone)` → UTC ISO, `time.format`, `time.isZone` (time.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

Classes (`extends Error`, `abstract`, `constructor(private repo: Repo)`,
`get`/`set`, `#private`), `enum`, `keyof typeof`, generics, destructuring,
`URL`/`URLSearchParams`, `s.normalize("NFD")` work. `cubek check` rejects (with the fix) server npm, `node:*`, `process`,
`Buffer`, `any`, `.then` (`await`, `Promise.all`), timers (server code; fine in
islands' `useEffect` and `client/`), `obj[key]` with a `string` key
on fixed fields (`k: keyof typeof obj`, `arr[i]`, `Record<string, T>` fine), `for...in`, `delete`,
`Intl`, local-time `Date`, `Date.parse` (`new Date(iso).getTime()`), `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 |
|---|---|
| `getUTCSeconds` | slice `toISOString()` |
| `Intl`, `getHours`, time zones | `time.toLocal/fromLocal/format` (time.md) |
| `className`, page `onClick`, `dangerouslySetInnerHTML` | `class`, a mount point or form, `raw(...)` |

Annotate the return types of named functions.
