{"protocol":"clank-doc/1","frameworkVersion":"0.8.0","slug":"full-stack","title":"Full-stack SSR, SQLite, and live sync","description":"Clank's full stack layer follows one rule: write the runtime contract once and let TypeScript infer the rest. There is no generated client, ORM model, RPC interface, or duplicate DTO type to maintain.","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/full-stack","source":"docs/full-stack.md","headings":["Define data once","Define server functions","Query documents","Open the backend","Mount RPC and live streams","Use the inferred browser client","SSR and cache seeding","Run on Node","Tailwind","Complete example"],"tableOfContents":[{"id":"define-data-once","title":"Define data once","level":2},{"id":"define-server-functions","title":"Define server functions","level":2},{"id":"query-documents","title":"Query documents","level":2},{"id":"open-the-backend","title":"Open the backend","level":2},{"id":"mount-rpc-and-live-streams","title":"Mount RPC and live streams","level":2},{"id":"use-the-inferred-browser-client","title":"Use the inferred browser client","level":2},{"id":"ssr-and-cache-seeding","title":"SSR and cache seeding","level":2},{"id":"run-on-node","title":"Run on Node","level":2},{"id":"tailwind","title":"Tailwind","level":2},{"id":"complete-example","title":"Complete example","level":2}],"markdown":"# Full-stack SSR, SQLite, and live sync\n\nClank's full-stack layer follows one rule: write the runtime contract once and let TypeScript infer the rest. There is no generated client, ORM model, RPC interface, or duplicate DTO type to maintain.\n\nThe implementation uses only platform modules: Node's built-in SQLite and HTTP APIs, Fetch `Request`/`Response`, Web `ReadableStream`, and browser `EventSource`. Tailwind remains a CSS choice and does not become a framework dependency.\n\n## Define data once\n\n```ts\nimport { defineDatabase, defineTable, s, type DocumentFor } from \"@clank.run/framework\";\n\nexport const schema = defineDatabase({\n  todos: defineTable({\n    title: s.string({ min: 1, max: 160 }),\n    done: s.boolean(),\n    note: s.optional(s.string()),\n  }).index(\"by_done\", [\"done\"]),\n});\n\nexport type Todo = DocumentFor<typeof schema, \"todos\">;\n```\n\n`Todo` is inferred as the declared fields plus `_id`, `_creationTime`, and `_version`. An optional validator becomes an optional property. `s.id(\"todos\")` produces a branded string compatible only with the `todos` table, which catches cross-table ID mistakes without changing the JSON wire format.\n\n`defineTable().index(name, fields)` creates a SQLite expression index over the stored JSON fields. Table and field names are checked at startup; all existing rows are revalidated when a database opens.\n\nAdd `.owned()` when every document belongs to one authenticated user:\n\n```ts\nconst schema = defineDatabase({\n  todos: defineTable({\n    title: s.string(),\n    done: s.boolean(),\n  }).owned(),\n});\n```\n\nOwned documents also include `_ownerId`. Auth-scoped database views add the owner predicate automatically to reads and writes.\n\n## Define server functions\n\n```ts\nimport { defineBackend, s } from \"@clank.run/framework\";\nimport { schema } from \"./schema.ts\";\n\nexport const backend = defineBackend({ schema }).functions(({ query, mutation }) => ({\n  todos: {\n    list: query({\n      args: { done: s.optional(s.boolean()) },\n      handler: ({ db }, { done }) => {\n        const rows = db.table(\"todos\").query().orderBy(\"_creationTime\");\n        return done === undefined ? rows.collect() : rows.where(\"done\", done).collect();\n      },\n    }),\n    add: mutation({\n      args: { title: s.string({ min: 1, max: 160 }) },\n      handler: ({ db }, { title }) => db.table(\"todos\").insert({ title, done: false }),\n    }),\n    toggle: mutation({\n      args: { id: s.id(\"todos\"), version: s.number({ integer: true, min: 1 }) },\n      handler: ({ db }, { id, version }) => {\n        const todo = db.table(\"todos\").get(id);\n        return todo\n          ? db.table(\"todos\").patch(id, { done: !todo.done }, { ifVersion: version })\n          : null;\n      },\n    }),\n  },\n}));\n```\n\nThe builders infer handler arguments from `args` and infer results from the handler. Add `returns: someSchema` when the output also needs runtime validation and JSON Schema publication; a separate TypeScript result annotation is not required.\n\nBackend queries and mutations are deliberately synchronous and deterministic:\n\n- A query receives a read-only database view and records its dependencies.\n- A mutation receives a writable view inside one `BEGIN IMMEDIATE` transaction.\n- Insert, patch, replace, and delete are validated before commit.\n- `ifVersion` rejects stale patch, replace, and delete operations with `DatabaseConflictError`.\n- A thrown error rolls back every write.\n- Invalid, non-JSON, or oversized mutation output also rolls back every write.\n- The global live revision increments inside the same transaction and persists across restarts.\n- Notifications are emitted only after a successful commit.\n\nExternal network calls and other asynchronous side effects belong in Clank `Action`s. Keeping database functions synchronous prevents a transaction from remaining open across arbitrary awaits and makes the published snapshot unambiguous.\n\n## Query documents\n\n```ts\nconst table = db.table(\"todos\");\nconst todo = table.get(id);\nconst open = table.query()\n  .where(\"done\", false)\n  .orderBy(\"_creationTime\", \"desc\")\n  .limit(20)\n  .collect();\nconst first = table.query().where(\"title\", \"eq\", \"Ship it\").first();\n```\n\nSupported comparisons are `eq`, `neq`, `lt`, `lte`, `gt`, and `gte`. `_id`, `_creationTime`, `_version`, and declared fields are queryable. Query values must be SQLite scalar values: string, number, bigint, boolean, or null.\n\n`get(id)` tracks that exact document. A builder query or `collect()` tracks the table. The latter is intentionally conservative: any committed write to that table reruns the query, preserving correctness even when a predicate's membership changes.\n\n## Open the backend\n\n```ts\nimport { createApi, openBackend } from \"@clank.run/framework\";\nimport { backend } from \"./backend.ts\";\n\nconst api = createApi<typeof backend>();\nconst runtime = await openBackend(backend, {\n  path: \"./data.sqlite\",\n  wal: true,\n  busyTimeout: 5_000,\n});\n\nconst initial = runtime.query(api.todos.list);\nconst id = runtime.mutation(api.todos.add, { title: \"Strongly typed\" }).value;\nruntime.mutation(api.todos.toggle, { id });\n```\n\n`createApi<typeof backend>()` is a type-only proxy: property access creates lightweight references such as `todos.list`. It performs no code generation, file watching, or network discovery. TypeScript knows whether each reference is a query or mutation, whether arguments are optional, and its exact result.\n\n`openBackend()` defaults to an in-memory database. A file path enables persistent storage; WAL, `synchronous=FULL`, startup integrity checks, private file permissions, cross-process change polling, and a five-second busy timeout are enabled by default. Call `runtime.close()` during shutdown.\n\nThe lower-level `openSQLite(schema, options)` and `createSQLiteDatabase(schema, compatibleConnection)` APIs are available for direct storage integrations.\n\n## Mount RPC and live streams\n\n`runtime.handle(request)` is a complete Fetch-standard backend endpoint. Mount it after application and asset routes:\n\n```ts\nconst app = createApp()\n  .get(\"/\", renderPage)\n  .route(\"*\", \"*\", ({ request }) => runtime.handle(request));\n```\n\nThe default protocol endpoints are:\n\n| Endpoint | Purpose |\n| --- | --- |\n| `GET /__clank/manifest` | Function names, kinds, argument schemas, and optional result schemas |\n| `POST /__clank/query/{path}` | Validated one-shot query |\n| `POST /__clank/mutation/{path}` | Validated atomic mutation |\n| `GET /__clank/live/{path}?args=...` | Server-sent query snapshots and heartbeats |\n\nChange the prefix with `openBackend(backend, { prefix: \"api\" })`. Requests reject cross-site origins by default, JSON bodies and live arguments are bounded, cache size and live connections are capped, and internal failures are redacted.\n\nFor private applications, pass `auth: defineAuth()` to `defineBackend`. Clank then mounts `/__clank/auth`, makes normal queries/mutations auth-required, verifies CSRF on mutations, partitions query caches by session, scopes `.owned()` tables, and revalidates live sessions. See [Authentication](auth.md).\n\nThe live transport uses standard SSE framing, sends the persisted revision as the event ID, disables buffering, bounds payloads, and emits a configurable heartbeat. Slow consumers are disconnected and EventSource reconnects with a complete current snapshot.\n\n## Use the inferred browser client\n\n```tsx\nimport { createApi, createSyncClient } from \"@clank.run/framework\";\nimport type { backend } from \"./backend.ts\";\n\nconst api = createApi<typeof backend>();\nconst client = createSyncClient();\nconst todos = client.live(api.todos.list);\n\nawait client.mutate(api.todos.add, { title: \"Streams everywhere\" });\nconsole.log(todos.data.value, todos.loading.value, todos.error.value);\n\n// When the component or application scope ends:\ntodos.dispose();\n```\n\n`live()` exposes four signals: `data`, `loading`, `error`, and `version`. Every committed write is compared with cached query dependencies. Only affected queries rerun; all subscribers to the same function and canonicalized arguments receive the resulting snapshot.\n\n`version` is the internal persisted database synchronization cursor. A value such as `36` means 36 change-producing transactions have committed; it is not a record count or connection count. See [Database revisions and correctness](database.md).\n\n`createSyncClient({ url, fetch, eventSource })` accepts a base URL and injectable platform implementations for non-browser runtimes and tests.\n\nFor an authenticated backend, use the combined client:\n\n```ts\nconst client = createClient<typeof backend>();\nconst todos = client.live(client.api.todos.list);\n\nawait client.auth.login({ email, password });\nawait client.mutate(client.api.todos.add, { title: \"Private and live\" });\n```\n\nThis client keeps session credentials in `HttpOnly` cookies and adds the in-memory CSRF token to mutations.\n\n## SSR and cache seeding\n\nUse one shared component for the server and browser. On the server:\n\n```tsx\nconst initial = runtime.query(api.todos.list);\nconst page = await renderDocument(<TodoApp todos={initial.value} />, {\n  title: \"Todos\",\n  state: { todos: initial.value, version: initial.version },\n  scripts: [\"/app.js\"],\n});\n```\n\nIn the browser, seed the exact live query before it opens and hydrate the same view:\n\n```tsx\nconst initial = readState<{ todos: Todo[]; version: number }>()!;\nconst client = createSyncClient();\n\nclient.seed(api.todos.list, {}, initial.todos, initial.version);\nconst todos = client.live(api.todos.list);\n\nhydrate(document.querySelector(\"#app\")!, (\n  <TodoApp todos={todos.data.value ?? initial.todos} />\n));\n```\n\nSeeding prevents a blank loading render and ensures the first client tree matches the server tree. The EventSource still opens immediately and replaces the seed with the authoritative current snapshot.\n\n`renderToString(view)` escapes text and attributes and awaits promised renderables. `renderDocument(view, options)` creates the doctype, metadata, root, optional stylesheets, serialized state, and module scripts. Dynamic expressions and keyed lists receive hydration markers by default. `serializeState()` escapes `<`, `>`, `&`, and Unicode line separators so serialized data cannot close its script element.\n\nPass a fresh `nonce` to apply a CSP nonce to the generated state and module-script tags.\n\nHydration attaches bindings, listeners, refs, directives, lifecycle callbacks, and keyed reconciliation to the existing DOM. A mismatch warns and remounts safely. Inspect `root.dataset.clankHydration` for `attached` or `remounted` during diagnostics.\n\nWhen browser code imports the package name, the document import map must use that exact specifier:\n\n```tsx\n<script\n  type=\"importmap\"\n  dangerouslySetInnerHTML={{\n    __html: JSON.stringify({ imports: { \"@clank.run/framework\": \"/dist/index.js\" } }),\n  }}\n/>\n```\n\nAn import-map key such as `clank` does not resolve `import ... from \"@clank.run/framework\"`; if it is mismatched, the browser never loads the client entry and hydration cannot begin.\n\n## Run on Node\n\n```ts\nimport { serve, staticFiles } from \"@clank.run/framework/node\";\n\nconst assets = staticFiles(\"./public\", { cacheControl: \"public, max-age=3600\" });\nconst server = await serve(app, { hostname: \"127.0.0.1\", port: 3000 });\nconsole.log(server.url);\n\n// Later:\nawait server.close();\n```\n\nThe adapter translates Node's built-in HTTP request and streaming response objects to Fetch. It supports streaming SSE responses, multiple `Set-Cookie` values, abort propagation, proxy-aware protocol handling, port `0`, and an error hook. `staticFiles()` handles GET/HEAD, MIME types, index files, cache control, URL decoding, and traversal rejection.\n\n## Tailwind\n\nClank preserves ordinary `class`, reactive `class`, `classList`, style objects, and CSS variables in both SSR and client rendering. Use a compiled Tailwind stylesheet in production. For a zero-install prototype, the full-stack example loads Tailwind's browser build in the document head; that network script is application content, not a Clank runtime dependency.\n\n## Complete example\n\nThe working implementation is split by responsibility:\n\n- [`examples/fullstack/backend.ts`](../examples/fullstack/backend.ts): inferred schema and function tree.\n- [`examples/fullstack/view.tsx`](../examples/fullstack/view.tsx): shared Tailwind component.\n- [`examples/fullstack/server.tsx`](../examples/fullstack/server.tsx): SQLite runtime, SSR template, routes, and Node server.\n- [`examples/fullstack/app.tsx`](../examples/fullstack/app.tsx): state read, live-query seed, hydration, and typed mutations.\n\nRun it with `npm run dev:fullstack`, then open `http://127.0.0.1:4180` in two tabs. Changes committed in one tab stream to the other while keyed todo rows retain DOM identity.\n\nThe auth-first version is under [`examples/auth-todo`](../examples/auth-todo):\n\n- [`backend.ts`](../examples/auth-todo/backend.ts): auth definition, owned table, required functions.\n- [`server.tsx`](../examples/auth-todo/server.tsx): request auth, private SSR, CSP nonce, secure headers, Tailscale-ready proxy settings.\n- [`app.tsx`](../examples/auth-todo/app.tsx): one combined auth/RPC/live client and cleanup.\n- [`view.tsx`](../examples/auth-todo/view.tsx): shared Tailwind and agent-semantic UI.\n\nRun it with `npm run dev:auth`.\n"}