From 4668d9e092c796d9fcd1658d89481ff097b2e99c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?=
Date: Sun, 30 Aug 2026 20:42:45 +0200
Subject: [PATCH 1/4] Upgrade Blume and rewrite Zero docs
---
blume.config.ts | 4 +
bun.lock | 4 +-
content/database/index.mdx | 2 +-
content/functions/index.mdx | 10 +-
content/functions/php.md | 2 +-
content/guides/local-development.mdx | 4 +-
content/services/index.mdx | 45 +-
content/start/glossary.md | 6 +-
content/zero-runtime/authentication.mdx | 88 +++
content/zero-runtime/build.mdx | 193 ++++++
content/zero-runtime/index.mdx | 700 ++-------------------
content/zero-runtime/move-from-lakebed.mdx | 67 ++
content/zero-runtime/styling.mdx | 86 +++
package.json | 2 +-
14 files changed, 513 insertions(+), 700 deletions(-)
create mode 100644 content/zero-runtime/authentication.mdx
create mode 100644 content/zero-runtime/build.mdx
create mode 100644 content/zero-runtime/move-from-lakebed.mdx
create mode 100644 content/zero-runtime/styling.mdx
diff --git a/blume.config.ts b/blume.config.ts
index 759ce6e..3adff46 100644
--- a/blume.config.ts
+++ b/blume.config.ts
@@ -117,6 +117,10 @@ export default defineConfig({
root: "/zero-runtime",
items: [
"/zero-runtime",
+ "/zero-runtime/build",
+ "/zero-runtime/authentication",
+ "/zero-runtime/styling",
+ "/zero-runtime/move-from-lakebed",
"/functions",
"/functions/php",
"/database",
diff --git a/bun.lock b/bun.lock
index fbb0e6c..c37baab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "docs",
"dependencies": {
- "blume": "1.5.1",
+ "blume": "1.5.3",
},
"devDependencies": {
"@spacefast/sdk": "^0.0.24",
@@ -718,7 +718,7 @@
"bindings": ["bindings@1.5.0", "https://npmproxy.autoproxxy.com:2443/ask-in-opers-wbudsfo9i2e98e9wdu7s98grt9ui32rew/bindings/-/bindings-1.5.0.tgz", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
- "blume": ["blume@1.5.1", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-RnzoY+OHvchUJkXjF7s45SUW5rhuL81URWATEZoEl3psWeajsUu6JRyqDuy8bpYZjoth7P6gpS4IijZspv8Yrw=="],
+ "blume": ["blume@1.5.3", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-8oaUwDtMcEWdCxnUmnPnw6SJol93EAavUq7ihFcr6ur2mCPcdkMjMzphM5Y3qIAexwy2J/bsQKRzAWKqHqIUSw=="],
"body-parser": ["body-parser@2.3.0", "https://npmproxy.autoproxxy.com:2443/ask-in-opers-wbudsfo9i2e98e9wdu7s98grt9ui32rew/body-parser/-/body-parser-2.3.0.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
diff --git a/content/database/index.mdx b/content/database/index.mdx
index 5fe6772..2e91ff8 100644
--- a/content/database/index.mdx
+++ b/content/database/index.mdx
@@ -36,7 +36,7 @@ In a Functions worker, opt in through `sf.jsonc`:
The worker then receives `env.DB`. Use `prepare().bind()` with `.all()`,
`.first()`, `.run()`, or `.raw()`. `env.DB.exec()` runs DDL (data
definition language) statements, and `env.DB.batch()` groups statements.
-For the typed query contract, see [Zero's database](/zero-runtime#database).
+For a complete typed example, [build a Zero to-do app](/zero-runtime/build).
## Migrations
diff --git a/content/functions/index.mdx b/content/functions/index.mdx
index e3bdba5..a13876f 100644
--- a/content/functions/index.mdx
+++ b/content/functions/index.mdx
@@ -78,10 +78,10 @@ request. Next and OpenNext builds get `nodejs_compat` automatically.
Outbound network access is fail-closed. Spacefast denies it unless the
published version holds the `fetch` capability, so a `fetch()` call in your
-code does not silently add network authority. No Functions publish
-grants that capability. Outbound `fetch` is available only to
-[Zero](/zero-runtime) actions. To check which capabilities a published version
-holds, run `sf runtime status`.
+code does not add network authority. No Functions publish grants that
+capability. Zero server handlers cannot call `fetch` either. Use a Zero
+[platform service](/services) when one matches the job. To inspect a published
+Functions version, run `sf runtime status`.
## Variables
@@ -99,7 +99,7 @@ instead.
## Storage
Every worker receives `env.STORAGE`, the same space
-[object store](/storage) that [Zero](/zero-runtime#storage) uses. The binding has
+[object store](/storage) that Zero uses. The binding has
three methods:
- `upload(file)` takes a `Blob` and returns an object with `id`,
diff --git a/content/functions/php.md b/content/functions/php.md
index d194b9c..a2e0c47 100644
--- a/content/functions/php.md
+++ b/content/functions/php.md
@@ -36,7 +36,7 @@ suffix removed. A trailing `index` segment also drops:
Routes are literal. A segment that is empty, contains `[` or `]`, or starts
with `:` or `.` is not routed in this lane, so a pattern-named file such
as `functions/[id].php` stays an inert upload. A file you commit at the
-same key outranks the PHP route, and redirects and [Zero](/zero-runtime) actions
+same key outranks the PHP route, and redirects and [Zero](/zero-runtime) endpoints
win over it. This is the same precedence the
[JavaScript Functions](/functions) lane gets.
diff --git a/content/guides/local-development.mdx b/content/guides/local-development.mdx
index ef9e806..734d319 100644
--- a/content/guides/local-development.mdx
+++ b/content/guides/local-development.mdx
@@ -113,9 +113,7 @@ sf dev --state-backend sqlite
```
The local store is a development engine, not the database a published space gets. It is enough
-to build against, and two differences are worth holding on to. A failed `ctx.transaction()`
-does not roll back locally, so writes made inside it survive an error that would undo them in
-production. And the local engine has its own ceilings on rows, values, and stored objects,
+to build against. The local engine has its own ceilings on rows, values, and stored objects,
which are development guard rails rather than product limits. For those, see
[Plans](/account/plans).
diff --git a/content/services/index.mdx b/content/services/index.mdx
index 1cec47f..6bb6320 100644
--- a/content/services/index.mdx
+++ b/content/services/index.mdx
@@ -2,25 +2,26 @@
title: Email, spam, and Gravatar
search:
tags: [email, spam, gravatar, avatars, akismet]
-description: "Send email, check submissions for spam, and read Gravatar profiles from any Zero handler without holding a credential."
+description: "Use email, spam checks, and Gravatar from the Zero handlers that receive each service."
---
-Every [Zero](/zero-runtime) handler context carries three platform services:
-`ctx.gravatar` reads public profiles, `ctx.spam` classifies visitor
-submissions, and `ctx.email` sends mail. The runtime brokers each call and
-attaches the credential outside your code. There is no mail password, spam
-API key, or Gravatar token to configure, and none can leak from a
-published bundle.
+Zero handler contexts carry platform services without exposing their
+credentials. `ctx.gravatar` reads public profiles, `ctx.spam` classifies
+visitor submissions, and `ctx.email` sends mail. There is no mail password,
+spam API key, or Gravatar token to configure.
What a handler receives follows what it can do. A query can be replayed or
re-run to refresh a live subscription, so a query receives only the calls
that have no effects outside the database:
-| Handler | `ctx.gravatar` | `ctx.spam` | `ctx.email` |
-| ---------- | -------------- | --------------------------- | ------------------- |
-| `query` | Yes | `check` only | No |
-| `mutation` | Yes | `check` plus corrections | Yes, transactional |
-| `action` | Yes | `check` plus corrections | Yes |
+Every handler gets `ctx.auth`, `ctx.env`, `ctx.gravatar`, `ctx.log`, and
+`ctx.spam.check`. Mutations and write endpoints add write access,
+`ctx.email`, spam corrections, and `ctx.invalidate`.
+
+| Handler | Database | `ctx.gravatar` | `ctx.spam` | `ctx.email` | `ctx.invalidate` |
+| --- | --- | --- | --- | --- | --- |
+| `query` or read endpoint | Read only | Yes | `check` only | No | No |
+| `mutation` or write endpoint | Read and write | Yes | `check` plus corrections | Yes | Yes |
`sf dev` brokers none of them. A local call to `ctx.email`, `ctx.spam`, or
`ctx.gravatar.profile` throws `zero_email_unavailable`,
@@ -108,7 +109,7 @@ The verdict has two fields. `spam` says the submission classifies as spam
and belongs in a review queue. `discard` flags blatant, pervasive spam that
is safe to drop outright instead of holding for review.
-Mutations and actions also get the corrections. Call
+Mutations and write endpoints also get the corrections. Call
`ctx.spam.reportSpam(submission)` when something passed that should not
have. Call `ctx.spam.reportHam(submission)` when something was held that
should not have been. Corrections usually run later, outside the original
@@ -135,20 +136,10 @@ mutations: {
},
```
-In a mutation, the send is accepted into the space's outbox on the
-handler's own transaction. A mutation that writes a row and queues its
-mail commits both or neither, and a throw after `send()` rolls back the
-row and the message together. When you want to scope that boundary
-explicitly, use `ctx.transaction(handler)`. Actions run outside a parent
-transaction because they exist to touch the world. Their sends go out when
-the handler succeeds, not atomically with database writes.
-
-Under `sf dev` the boundary is the whole invocation rather than the block you
-declared: a mutation or endpoint that throws rolls back everything it wrote,
-including writes made outside `ctx.transaction()`, and an error you catch
-yourself leaves that block's writes in place. Actions get no rollback at all
-locally. Publish the capsule when a test depends on the exact transaction
-boundary.
+In a mutation or write endpoint, the send enters the space's outbox in the
+handler's transaction. A handler that writes a row and queues mail commits
+both or neither. If the handler throws after `send()`, Zero rolls back the row
+and the message.
The message shape:
diff --git a/content/start/glossary.md b/content/start/glossary.md
index 81a5698..d935a9a 100644
--- a/content/start/glossary.md
+++ b/content/start/glossary.md
@@ -53,10 +53,8 @@ start with `bld_`. See [Build from Git](/publish/git).
### Capsule
-One Zero app: a schema plus its queries, mutations, actions, and endpoints,
-compiled into a single artifact. Everything in a capsule runs inside one
-database transaction, which is what separates Zero from Functions. See
-[Zero](/zero-runtime).
+One Zero app: a schema plus its queries, mutations, and endpoints, compiled
+into one artifact. See [Zero](/zero-runtime).
### CDN
diff --git a/content/zero-runtime/authentication.mdx b/content/zero-runtime/authentication.mdx
new file mode 100644
index 0000000..cc4dce6
--- /dev/null
+++ b/content/zero-runtime/authentication.mdx
@@ -0,0 +1,88 @@
+---
+title: Add authentication to a Zero app
+description: Show the current identity, add hosted sign-in, and enforce row ownership on the server.
+---
+
+Every Zero visitor has an identity. New visitors start as guests, so you can
+store per-visitor data before they sign in.
+
+## Add the sign-in control
+
+Import the authentication helpers in the client:
+
+```tsx
+import {
+ SignInWithGoogle,
+ signOut,
+ useAuth,
+} from "@spacefast/zero/client";
+import { Button, Spinner } from "@spacefast/zero/kit";
+```
+
+Render a control for the current state:
+
+```tsx
+function AuthControls() {
+ const auth = useAuth();
+
+ if (auth.isLoading) return ;
+ if (auth.isGuest) return ;
+
+ return (
+
+ {auth.displayName}
+
+
+ );
+}
+```
+
+`SignInWithGoogle` is the compatibility name for the hosted Gravatar sign-in
+button. `signOut()` returns the client to a guest identity. Hosted sign-in is
+unavailable under `sf dev`, so publish the capsule to test this flow.
+
+## Scope reads to the visitor
+
+Store the server identity with each user-owned row, then filter the query by
+`ctx.auth.userId`:
+
+```ts
+queries: {
+ todos: query(async (ctx) =>
+ ctx.db.todos
+ .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
+ .collect()
+ ),
+},
+```
+
+Declare the matching index on the table:
+
+```ts
+todos: table({
+ text: string(),
+ ownerId: string(),
+}).index("by_owner", ["ownerId"]),
+```
+
+## Check ownership before every write
+
+Do not trust a row ID from the client. Read the row in the mutation and compare
+its owner before you update or delete it:
+
+```ts
+mutations: {
+ renameTodo: mutation(async (ctx, id: string, text: string) => {
+ const todo = await ctx.db.todos.get(id);
+ if (!todo || todo.ownerId !== ctx.auth.userId) return;
+ await ctx.db.todos.update(id, { text: text.trim().slice(0, 160) });
+ ctx.invalidate("todos");
+ }),
+},
+```
+
+Client state is presentation, not authorization. Hiding a button from a guest
+does not protect a row. The query and each write handler must enforce ownership
+with `ctx.auth`.
diff --git a/content/zero-runtime/build.mdx b/content/zero-runtime/build.mdx
new file mode 100644
index 0000000..d3bd78e
--- /dev/null
+++ b/content/zero-runtime/build.mdx
@@ -0,0 +1,193 @@
+---
+title: Build a Zero to-do app
+description: Build and publish a to-do app with a live query, mutations, and a read endpoint.
+---
+
+This tutorial builds a to-do app whose rows belong to the current visitor. The
+client updates after each mutation, and a read endpoint reports that the
+capsule is running.
+
+## Create the capsule
+
+Run:
+
+```bash
+sf init zero-todos --runtime zero
+cd zero-todos
+```
+
+Keep the generated `package.json`. Zero supplies its client, server, kit, and
+Preact modules when it builds the capsule.
+
+## Declare the entries
+
+Set `sf.jsonc` to:
+
+```jsonc
+{
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "name": "Zero todos",
+ "runtime": {
+ "kind": "zero",
+ "server": "server/index.ts",
+ "client": "client/index.tsx"
+ }
+}
+```
+
+Zero only accepts capsule source from `client/`, `server/`, and `shared/`.
+
+## Create the server capsule
+
+Replace `server/index.ts` with:
+
+```ts
+import {
+ boolean,
+ capsule,
+ endpoint,
+ mutation,
+ query,
+ string,
+ table,
+ text,
+} from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Zero todos",
+ schema: {
+ todos: table({
+ text: string(),
+ done: boolean().default(false),
+ ownerId: string(),
+ }).index("by_owner", ["ownerId"]),
+ },
+ queries: {
+ todos: query(async (ctx) =>
+ ctx.db.todos
+ .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
+ .order("desc")
+ .collect()
+ ),
+ },
+ mutations: {
+ addTodo: mutation(async (ctx, text: string) => {
+ const cleanText = text.trim().slice(0, 160);
+ if (!cleanText) return;
+ await ctx.db.todos.insert({
+ text: cleanText,
+ done: false,
+ ownerId: ctx.auth.userId,
+ });
+ ctx.invalidate("todos");
+ }),
+ setDone: mutation(async (ctx, id: string, done: boolean) => {
+ const todo = await ctx.db.todos.get(id);
+ if (!todo || todo.ownerId !== ctx.auth.userId) return;
+ await ctx.db.todos.update(id, { done });
+ ctx.invalidate("todos");
+ }),
+ },
+ endpoints: {
+ status: endpoint(
+ { mode: "read", method: "GET", path: "/api/status" },
+ () => text("ok")
+ ),
+ },
+});
+```
+
+The endpoint declares a literal method, a literal path, and `mode: "read"`.
+Read endpoints and queries get read-only database access. Use `mode: "write"`
+when an endpoint must write.
+
+Each mutation calls `ctx.invalidate("todos")`. Zero then refreshes every live
+subscription opened under the `todos` query name.
+
+## Create the client
+
+Replace `client/index.tsx` with:
+
+```tsx
+import { useMutation, useQuery } from "@spacefast/zero/client";
+import { Button, Card, Checkbox, EmptyState, Input } from "@spacefast/zero/kit";
+
+type Todo = {
+ id: string;
+ text: string;
+ done: boolean;
+ ownerId: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export function App() {
+ const todos = useQuery("todos");
+ const addTodo = useMutation<[text: string], void>("addTodo");
+ const setDone = useMutation<[id: string, done: boolean], void>("setDone");
+
+ async function onSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement;
+ const text = String(new FormData(form).get("text") ?? "");
+ await addTodo(text);
+ form.reset();
+ }
+
+ return (
+
+
+
+ {todos.length === 0 ? (
+
+ )}
+
+
+ );
+}
+```
+
+## Run the app
+
+Start the capsule:
+
+```bash
+sf dev
+```
+
+Open the private URL from the command output. Do not remove its
+`#zero-dev-capability=...` fragment. Add a to-do item, then open another tab at the
+same private URL. Both tabs update after a mutation.
+
+Check the endpoint at `/api/status` on the same private URL. It returns `ok`.
+
+## Publish the app
+
+Run:
+
+```bash
+sf publish
+```
+
+Open the live URL from the publish receipt. The published capsule has durable
+database state, while the default `sf dev` database resets when the process
+stops.
diff --git a/content/zero-runtime/index.mdx b/content/zero-runtime/index.mdx
index 00d9613..b518463 100644
--- a/content/zero-runtime/index.mdx
+++ b/content/zero-runtime/index.mdx
@@ -1,674 +1,62 @@
---
title: Zero
-description: Build apps with a database, storage, authentication, and typed server functions.
+description: Choose the full-stack runtime for apps built around shared data, identity, and live updates.
---
-Zero is Spacefast's full-stack runtime. One project contains a Preact client,
-a typed server capsule, a database schema, authentication, and storage. One
-publish command makes the whole app live as a normal space version.
-Zero is generally available.
+Zero is Spacefast's full-stack runtime. A Zero capsule puts a Preact client,
+server handlers, a typed database schema, authentication, and storage in one
+space. Its public server declarations are `schema`, `queries`, `mutations`, and
+`endpoints`. Zero is generally available.
-Spacefast Zero is based on the capsule apps concept created by the
-[Lakebed](https://docs.lakebed.dev/) project, close enough that a Lakebed
-capsule compiles unchanged. See [Compare to Lakebed](#compare-to-lakebed).
+Choose Zero when the app is built around shared data. Queries keep the client
+current, mutations write to the database, and every visitor gets an identity.
+Choose [Functions](/functions) when you need a Web-standard `fetch` handler,
+your own framework, or direct control over request routing. Zero server code
+cannot call `fetch`.
-
-
- A schema in code, migrations on publish, live queries in the client.
-
-
- Guest identity for every visitor, hosted sign-in to upgrade it.
-
-
- Browser uploads with revocable read URLs and no credential setup.
-
-
- Tailwind classes in JSX, the platform kit, and SVG charts.
-
-
- Raw HTTP routes for webhooks, feeds, and generated images.
-
-
- Status, logs, backups, and rollbacks through the `sf` CLI.
-
-
-
-## Quick start
-
-
-
-
- ```bash
- sf init my-app --runtime zero
- cd my-app
- sf dev
- ```
-
- Open the private URL `sf dev` prints, not `http://localhost:4173`: the
- dev server is capability-gated, and the printed URL carries the capability
- in its fragment. `http://localhost:4173` on its own serves a page that
- tells you to go back and copy it.
-
- ```
- Open this private URL: http://127.0.0.1:4173/#zero-dev-capability=
- ```
-
- Edit `client/index.tsx` and `server/index.ts`. The local server reloads
- when you save a file. State is in memory unless you pass
- `--state-backend sqlite`.
-
-
-
-
- ```bash
- sf publish
- ```
-
- The publish command compiles the capsule, plans and applies the database
- migration, uploads the client, and activates the version.
-
-
-
-
-## Project layout
-
-
-
-- my-app/
- - client/
- - index.tsx
- - server/
- - index.ts
- - shared/
- - .env.server
- - sf.jsonc
- - package.json
-
-
-
-The runtime is explicit:
-
-```jsonc sf.jsonc
-{
- "$schema": "https://spacefast.com/schemas/sf.json",
- "name": "My app",
- "runtime": {
- "kind": "zero",
- "server": "server/index.ts",
- "client": "client/index.tsx",
- },
-}
-```
-
-Both entries are required. The publish fails when Spacefast cannot resolve an
-entry. Spacefast does not expose the source as static files.
-
-## The capsule
-
-A capsule is the default export of the Zero server entry. It declares the
-database schema and every callable server handler.
-
-```ts server/index.ts
-import { boolean, capsule, mutation, query, string, table }
- from "@spacefast/zero/server";
-
-export default capsule({
- name: "Todos",
- schema: {
- todos: table({
- text: string(),
- done: boolean().default(false),
- ownerId: string(),
- }).index("by_owner", ["ownerId"]),
- },
- queries: {
- todos: query(async (ctx) =>
- ctx.db.todos
- .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
- .order("desc")
- .collect()
- ),
- },
- mutations: {
- addTodo: mutation(async (ctx, text: string) => {
- await ctx.db.todos.insert({ text, done: false, ownerId: ctx.auth.userId });
- }),
- },
-});
-```
-
-Handler types:
-
-- **`query()`**: reads data and supports live client subscriptions.
-- **`mutation()`**: writes data.
-- **`action()`**: performs a one-shot server call.
-- **`endpoint()`**: exposes a raw HTTP method and path.
+## The shortest path
-Endpoint helpers include `json()`, `text()`, `empty()`, `redirect()`, and
-`ImageResponse` for images rendered from Preact nodes. Paths begin with `/`.
-Spacefast reserves the authentication and platform namespaces.
-
-Every handler receives `ctx` with:
-
-- **`ctx.auth`**: identity.
-- **`ctx.db`**: the declared tables.
-- **`ctx.env`**: server-only variables.
-- **`ctx.log`**: structured logging.
-- **`ctx.email`**, **`ctx.spam`**, **`ctx.gravatar`**: platform services with
- no credential to configure. See
- [Email, spam, and Gravatar](/services).
-
-Call named handlers from the client with hooks:
-
-```tsx client/index.tsx
-import { useAction, useMutation, useQuery } from "@spacefast/zero/client";
-
-const todos = useQuery("todos");
-const addTodo = useMutation<[text: string], void>("addTodo");
-```
-
-`useMutation()` and `useAction()` take the handler's argument tuple and its
-result type, so `addTodo("Buy milk")` is typed end to end and
-`addTodo(42)` fails to compile. `useQuery()` subscribes and
-re-renders when the result changes. For long lists, use `usePaginatedQuery()`
-and its `loadMore()` method. The client also exports `Router`, `Routes`,
-`Route`, `Link`, `useNavigate()`, `useParams()`, and `useLocation()` for
-client-side routes.
-
-## A complete app
-
-A guestbook that uses the whole runtime: a schema, a live query, a validated
-mutation with a spam check and Gravatar avatars, an action that emails a
-digest, a webhook, hosted sign-in, a photo upload to storage, two
-client-side routes, and a stats chart, styled with the platform kit and
-Tailwind classes. Two files are the whole app.
-
-
-
-```ts server/index.ts
-import { action, capsule, endpoint, json, mutation, query, string, table, text }
- from "@spacefast/zero/server";
-
-export default capsule({
- name: "Guestbook",
- schema: {
- entries: table({
- body: string(),
- photoUrl: string().default(""),
- avatarUrl: string().default(""),
- authorId: string(),
- authorName: string(),
- }),
- },
- queries: {
- entries: query(async (ctx) =>
- ctx.db.entries.withIndex("by_creation").order("desc").take(50)
- ),
- },
- mutations: {
- sign: mutation(async (ctx, body: string, photoUrl: string) => {
- const trimmed = body.trim().slice(0, 500);
- if (!trimmed) return;
- const verdict = await ctx.spam.check({
- content: trimmed,
- type: "comment",
- authorName: ctx.auth.displayName,
- });
- if (verdict.spam) return;
- await ctx.db.entries.insert({
- body: trimmed,
- photoUrl,
- avatarUrl: ctx.auth.email
- ? ctx.gravatar.avatarUrl(ctx.auth.email, { size: 64, default: "retro" })
- : "",
- authorId: ctx.auth.userId,
- authorName: ctx.auth.displayName,
- });
- }),
- },
- actions: {
- emailDigest: action(async (ctx) => {
- if (ctx.auth.isGuest || !ctx.auth.email) return;
- const recent = await ctx.db.entries
- .withIndex("by_creation").order("desc").take(10);
- await ctx.email.send({
- from: { email: "guestbook@example.com", name: "Guestbook" },
- to: ctx.auth.email,
- subject: `Your guestbook digest: ${recent.length} recent entries`,
- text: recent
- .map((entry) => `${entry.authorName}: ${entry.body}`)
- .join("\n"),
- });
- }),
- },
- endpoints: {
- incoming: endpoint({ method: "POST", path: "/webhooks/entries" },
- async (ctx, req) => {
- if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
- return text("unauthorized", { status: 401 });
- }
- const payload = await req.json<{ body: string }>();
- await ctx.db.entries.insert({
- body: payload.body,
- photoUrl: "",
- authorId: "webhook",
- authorName: "Webhook",
- });
- return json({ ok: true });
- }),
- },
-});
-```
-
-```tsx client/index.tsx
-import { Link, Route, Router, Routes, SignInWithGoogle, signOut, storage,
- useAction, useAuth, useMutation, useQuery } from "@spacefast/zero/client";
-import { Sparkline } from "@spacefast/zero/charts";
-import { Button, Card, EmptyState, Input, Spinner } from "@spacefast/zero/kit";
-import { useState } from "preact/hooks";
-
-type Entry = {
- id: string;
- body: string;
- photoUrl: string;
- avatarUrl: string;
- authorId: string;
- authorName: string;
- createdAt: string;
- updatedAt: string;
-};
-
-function SignForm() {
- const sign = useMutation<[body: string, photoUrl: string], void>("sign");
- const [draft, setDraft] = useState("");
- const [photo, setPhoto] = useState(null);
-
- return (
-
- );
-}
-
-function HomePage() {
- const entries = useQuery("entries");
- return (
-
- );
-}
-
-function StatsPage() {
- const auth = useAuth();
- const entries = useQuery("entries");
- const emailDigest = useAction<[], void>("emailDigest");
- const perDay = [...Array(7)].map((_, index) => {
- const day = new Date(Date.now() - (6 - index) * 86_400_000).toDateString();
- return entries.filter((entry) => new Date(entry.createdAt).toDateString() === day).length;
- });
- return (
-
-
- {auth.isAuthenticated ? (
-
- ) : null}
-
- );
-}
-
-export function App() {
- const auth = useAuth();
- return (
-
-
-
-
- {auth.isLoading ? (
-
- ) : auth.isGuest ? (
-
- ) : (
-
- )}
-
-
- } />
- } />
-
-
-
- );
-}
-```
-
-
-
-Everything on the page is one of the runtime's features doing its job:
-
-- **Live queries.** `useQuery("entries")` starts as an empty array and
- re-renders in every open tab whenever a mutation changes the rows,
- including rows the webhook writes. There is no cache wiring anywhere:
- write a row and every live query that reads it refreshes on its own.
-- **Validation lives on the server.** The `sign` mutation trims and caps the
- body. The client never writes rows directly.
-- **Spam checking is one call.** `ctx.spam.check()` classifies the entry
- before it is written. The runtime supplies the visitor's network evidence
- from the trusted request envelope. A browser cannot claim its own IP.
-- **Avatars come from Gravatar.** `ctx.gravatar.avatarUrl()` is synchronous
- and credential-free. The mutation stores the URL with the row.
-- **Actions send email.** The `emailDigest` action reads the same tables and
- sends through `ctx.email.send()` with no mail credential to configure, and
- `useAction()` wires it to a button. In a mutation, a send commits
- atomically with the writes. See
- [Email, spam, and Gravatar](/services).
-- **Sign-in is one component.** `SignInWithGoogle` upgrades the visitor's
- guest identity, and `signOut()` drops back to it.
-- **Uploads are one call.** `storage.upload(photo)` returns a read URL the
- row can keep. The photo is optional, and the form works without it.
-- **Routing is included.** `Router`, `Routes`, and `Link` give the app two
- pages without a dependency.
-- **Charts are included.** The stats page derives a week of counts from the
- same live query and hands them to `Sparkline`.
-- **The webhook is plain HTTP.** It checks a shared secret from
- `.env.server` before it writes, and answers with the endpoint helpers.
-
-Run it:
+Create a capsule, start it, and open the private URL that `sf dev` prints:
```bash
-sf init guestbook --runtime zero
-cd guestbook
-echo 'WEBHOOK_SECRET=pick-a-long-random-value' > .env.server
+sf init my-app --runtime zero
+cd my-app
sf dev
```
-Open the private URL `sf dev` prints. Signing the guestbook from the browser
-calls `ctx.spam.check`, which `sf dev` does not broker, so that path answers
-`zero_spam_unavailable` until you publish; the webhook writes without it.
-
-Post an entry from outside. Application routes need the capability, so send it
-as a bearer token alongside the webhook's own secret:
+The URL contains a capability in its fragment. The bare local host does not
+open the app. Edit the files under `client/`, `server/`, and `shared/`, then
+publish the capsule:
```bash
-curl -X POST http://localhost:4173/webhooks/entries \
- -H "authorization: Bearer " \
- -H "content-type: application/json" \
- -H "x-webhook-secret: pick-a-long-random-value" \
- -d '{"body":"hello from a webhook"}'
+sf publish
```
-The capability is the `zero-dev-capability` value in the URL `sf dev` printed.
-A published Space needs no capability: there the endpoint's own secret is the
-only check.
-
-`sf publish` makes it live: the capsule compiles, the migration applies,
-`.env.server` syncs as secret variables, and the version activates.
-
-## Styling
-
-Write Tailwind utility classes directly in JSX on the `class` attribute.
-There is nothing to install or configure. Zero compiles every class used
-anywhere in `client/`, `server/`, and `shared/` into the app's stylesheet,
-light and dark variants included. Classes nobody uses produce nothing. A
-`theme.json` at the project root adjusts the palette and typography the
-utilities compile against.
-
-The client also ships batteries:
-
-- **`@spacefast/zero/kit`**: the platform interface kit, with `Button`,
- `Card`, `Input`, `Badge`, `Tabs`, `Table`, `CodeBlock`, and more, plus
- `Icon` with the Lucide icon set.
-- **`@spacefast/zero/charts`**: `LineChart`, `BarChart`, and `Sparkline`,
- drawn as plain SVG with no charting library.
+`sf publish` compiles both entries, applies the schema change, uploads the
+client, and activates the new version.
-```tsx client/index.tsx
-import { LineChart } from "@spacefast/zero/charts";
-import { Badge, Card } from "@spacefast/zero/kit";
+## Build with Zero
-
- Live
-
-;
-```
-
-## Authentication
-
-Every Zero visitor starts with a stable guest identity: enough to own rows,
-return to them later, and keep anonymous users separate. Hosted sign-in
-upgrades the same browser session to an authenticated identity.
-
-```tsx client/index.tsx
-import { SignInWithGoogle, signOut, useAuth } from "@spacefast/zero/client";
-
-function AuthControls() {
- const auth = useAuth();
- if (auth.isLoading) return null;
- if (auth.isGuest) return ;
- return ;
-}
-```
-
-Hosted sign-in uses Gravatar. `SignInWithGoogle` is a compatibility alias that
-renders the "Sign in with Gravatar" button. `useAuth()` returns `userId`,
-`displayName`, `provider` (`"guest"` or `"gravatar"`), `isGuest`,
-`isAuthenticated`, `email`, `picture`, and `isLoading`.
-
-On the server, the same identity is `ctx.auth` in every handler. Check
-`ctx.auth.isGuest` when a handler requires sign-in. For row ownership, store
-`ctx.auth.userId` with the row, filter reads through an owner index, and
-verify that value before an update or delete. Never accept an owner id from
-client arguments:
-
-```ts server/index.ts
-setDone: mutation(async (ctx, id: string, done: boolean) => {
- const todo = await ctx.db.todos.get(id);
- if (!todo || todo.ownerId !== ctx.auth.userId) return;
- await ctx.db.todos.update(id, { done });
-}),
-```
-
-`sf dev` supplies a local guest identity, so authorization logic works the same
-locally and hosted.
-
-## Database
-
-Every Zero app has its own database. Declare the schema in the capsule, and
-the publish command compares the declaration with the live schema and applies
-the migration. Fields support `string()`, `boolean()`, and `id(table)`, with
-`.default(value)`. Every row also has `id`, `createdAt`, and `updatedAt`.
-
-Every table has a built-in `by_creation` index that reads rows in insertion
-order. Add `.index(name, fields)` for your own indexed reads with
-`.withIndex()`:
-
-```ts server/index.ts
-// One row, by id.
-const todo = await ctx.db.todos.get(id);
-
-// Newest rows first, via the built-in creation index.
-const latest = await ctx.db.todos.withIndex("by_creation").order("desc").take(20);
-
-// An owner's rows, via a declared index.
-const mine = await ctx.db.todos
- .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
- .collect();
-```
-
-Indexed queries finish with `.collect()`, `.take(count)`, `.first()`, or
-`.paginate()`, and support `.order("asc")` and `.order("desc")`. Mutation
-contexts add `.insert()`, `.update(id, patch)`, and `.delete(id)`.
-
-Normal additive changes apply during `sf publish`. Destructive changes require
-an explicit migration command, because a publish cannot silently drop data. Express
-the rename or drop in the capsule schema first, then allow the planned
-migration to include it with the matching boolean flag:
-
-```bash
-sf db migrate --rename
-sf db migrate --drop
-```
-
-Inspect and back up:
-
-```bash
-sf db dump --table projects --limit 100
-sf db console
-sf db export --out ./backup.json
-```
-
-The export contains every declared table in a versioned JSON format and only
-replaces the destination file after the complete export succeeds. A rollback
-promotes older code. It does not rewind database rows. Check the current
-schema before you roll back across a migration.
-
-## Storage
-
-Zero client storage handles browser uploads without a separate storage
-credential. Objects are addressed by a random 128-bit id. Read URLs carry a
-runtime read key that Spacefast can rotate. Rotation immediately invalidates
-every URL minted under the old key.
-
-```tsx client/index.tsx
-import { storage } from "@spacefast/zero/client";
-
-const uploaded = await storage.upload(file);
-uploaded.url; // a read URL to store or render
-await storage.delete(uploaded.id);
-```
-
-Uploads and deletes require an identified visitor. Anonymous commenters are
-admitted where Comments admits them, against the daily anonymous budget. Only
-the uploader can delete an object. The space owner gets an inventory across
-uploaders with `sf storage ls` and can force-delete with
-`sf storage rm 0123456789abcdef0123456789abcdef --yes`. That delete is
-destructive and unrecoverable.
-
-Safety and limits: 5 MiB per object, a 200 MiB rolling daily budget for
-anonymous uploads per space, no empty uploads, and no executable or active web
-content (HTML, JavaScript, PHP, binaries). Total storage counts against the
-plan's storage limit.
-
-## Variables
-
-Keep secrets that belong to your app in `.env.server`. The guestbook's webhook
-uses one to authenticate incoming requests:
-
-```dotenv .env.server
-WEBHOOK_SECRET=replace_with_a_random_secret
-```
-
-The publish command syncs the file as secret variables, and server handlers
-read them through `ctx.env`. Platform services are already configured: use
-`ctx.email`, `ctx.spam`, and `ctx.gravatar` without SMTP or provider API keys.
-See [Variables](/publish/variables) for shared and space-level management.
-
-## Operate a live app
-
-```bash
-sf runtime status
-sf logs runtime --follow
-```
-
-Every space already has its own site, so a capsule publish onto an existing
-space is an ordinary publish, and nothing migrates. Code belongs to the
-version. Database rows and stored objects belong to the space and do not roll
-back with it.
-
-The compiled server bundle is capped at 768 KiB and the client bundle at
-8 MiB.
-
-## Compare to Lakebed
-
-A Zero project is a [Lakebed](https://docs.lakebed.dev/) capsule: the same
-layout (`server/index.ts` default-exporting `capsule()`, `client/index.tsx`
-exporting `App`, `shared/` for both sides), the same schema and handler API
-(`table()`, `string()`, `boolean()`, `id(table)`, queries, mutations,
-`endpoint()` with `json()` and `text()`), and the same client hooks
-(`useQuery`, `useMutation`, `useAuth`, the router, `SignInWithGoogle`,
-`signOut`). Tailwind classes in JSX work the same way.
-
-Compatibility is built into the compiler, not left to convention:
-
-- Imports from `lakebed/server` and `lakebed/client` resolve to the Zero
- runtime. `@spacefast/zero/server` and `@spacefast/zero/client` are the
- canonical names; both work.
-- `.env.lakebed.server` is read wherever `.env.server` is.
-
-To port a capsule, copy the project and swap the CLI: `sf dev` to run it
-locally and `sf publish` instead of a deploy.
-
-What changes on Spacefast:
-
-- Hosted sign-in is Gravatar, and `SignInWithGoogle` renders the "Sign in
- with Gravatar" button.
-- The project is a space: `sf.jsonc` config, versions, rollback, custom
- domains, and the rest of the platform.
-
-What Zero adds beyond Lakebed:
-
-- The platform kit and charts, covered in [Styling](#styling).
-- `empty()`, `redirect()`, and `ImageResponse` endpoint helpers.
+
+
+ Run a complete capsule with a schema, live query, mutations, and an HTTP
+ endpoint.
+
+
+ Use guest identity, hosted Gravatar sign-in, and server-side ownership
+ checks.
+
+
+ Use static Tailwind classes, semantic tokens, the component kit, and
+ charts.
+
+
+ Move a core Lakebed capsule to the canonical Spacefast imports.
+
+
+ Define tables and indexes, inspect rows, and manage schema changes.
+
+
+ Upload objects from the client and manage them with the CLI.
+
+
diff --git a/content/zero-runtime/move-from-lakebed.mdx b/content/zero-runtime/move-from-lakebed.mdx
new file mode 100644
index 0000000..57fc6d1
--- /dev/null
+++ b/content/zero-runtime/move-from-lakebed.mdx
@@ -0,0 +1,67 @@
+---
+title: Move a Lakebed capsule to Zero
+description: Move a core Lakebed capsule to Spacefast Zero imports and publish it with sf.
+---
+
+Spacefast supports the core Lakebed client and server APIs used by capsules
+with schemas, queries, and mutations. Move to the canonical Spacefast imports
+before you publish. Treat any Lakebed feature outside that core as a separate
+migration.
+
+## Put source in the Zero roots
+
+Keep the client entry under `client/`, the server entry under `server/`, and
+shared pure TypeScript under `shared/`.
+
+Declare both entries in `sf.jsonc`:
+
+```jsonc
+{
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "name": "My capsule",
+ "runtime": {
+ "kind": "zero",
+ "server": "server/index.ts",
+ "client": "client/index.tsx"
+ }
+}
+```
+
+## Replace the imports
+
+Replace `lakebed/server` or `@spacefast/compat-lakebed/server` with:
+
+```ts
+import { capsule, mutation, query, string, table } from "@spacefast/zero/server";
+```
+
+Replace `lakebed/client` or `@spacefast/compat-lakebed/client` with:
+
+```ts
+import { useMutation, useQuery } from "@spacefast/zero/client";
+```
+
+Use `@spacefast/zero/kit` and `@spacefast/zero/charts` for Spacefast client
+components. Do not keep compatibility imports in new code.
+
+## Run the capsule locally
+
+Run:
+
+```bash
+sf dev
+```
+
+Open the private URL that the command prints. Exercise every query and mutation
+that you moved.
+
+## Publish the capsule
+
+Run:
+
+```bash
+sf publish
+```
+
+Open the live URL from the publish receipt and repeat the checks against the
+published database and identity.
diff --git a/content/zero-runtime/styling.mdx b/content/zero-runtime/styling.mdx
new file mode 100644
index 0000000..d3a2687
--- /dev/null
+++ b/content/zero-runtime/styling.mdx
@@ -0,0 +1,86 @@
+---
+title: Style a Zero app
+description: Use compiled Tailwind classes, semantic tokens, theme.json, the component kit, and charts.
+---
+
+Zero compiles Tailwind classes from your client source. Do not add a Tailwind,
+PostCSS, or CSS build step.
+
+## Write complete class names
+
+Use static class strings so the compiler can find them:
+
+```tsx
+const toneClass = urgent ? "bg-danger text-white" : "bg-surface text-ink";
+
+return
Status
;
+```
+
+Do not construct part of a class name, such as `bg-${tone}-500`. The compiler
+cannot discover that class.
+
+## Use semantic tokens
+
+Use `canvas` for the page, `surface` for panels, `ink` and `ink-muted` for
+text, `line` for borders, and `accent` for the main action. Zero also provides
+`success`, `warning`, and `danger`.
+
+```tsx
+
+
+
Last updated just now
+
+
+
+```
+
+These tokens adapt to light and dark color schemes. Common `shadcn` names such
+as `bg-background`, `text-muted-foreground`, and `border-border` map to the
+same roles.
+
+## Change tokens with theme.json
+
+Add `theme.json` at the capsule root to override tokens or add named presets:
+
+```json
+{
+ "version": 3,
+ "settings": {
+ "color": {
+ "palette": [
+ { "slug": "accent", "color": "#7a1fa2", "name": "Accent" },
+ { "slug": "brand", "color": "#b3592a", "name": "Brand" }
+ ]
+ },
+ "typography": {
+ "fontFamilies": [
+ { "slug": "display", "fontFamily": "Georgia, serif", "name": "Display" }
+ ]
+ }
+ }
+}
+```
+
+The example changes `bg-accent` and adds `bg-brand` and `font-display`.
+
+## Use the component kit
+
+Import pre-styled controls from the platform kit:
+
+```tsx
+import { Button, Card, Dialog, Input, Table } from "@spacefast/zero/kit";
+```
+
+The kit uses the same semantic tokens, so `theme.json` changes both your
+classes and the kit components.
+
+## Add charts and icons
+
+Use the Zero chart components for common charts:
+
+```tsx
+import { BarChart, LineChart, Sparkline, StatTile } from "@spacefast/zero/charts";
+```
+
+Zero also provides `recharts`, `lucide-react`, and Preact as platform imports.
+Import only what the client uses. Do not add an install step for these modules.
diff --git a/package.json b/package.json
index 563fd80..5c03e4d 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"doctor": "blume doctor"
},
"dependencies": {
- "blume": "1.5.1"
+ "blume": "1.5.3"
},
"devDependencies": {
"@spacefast/sdk": "^0.0.24",
From 73f53bda663e75abe001b67d60d920756352d1a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?=
Date: Sun, 30 Aug 2026 21:10:33 +0200
Subject: [PATCH 2/4] Document the complete Zero feature set
---
blume.config.ts | 6 +
content/zero-runtime/build.mdx | 24 ++--
content/zero-runtime/client.mdx | 155 ++++++++++++++++++++++
content/zero-runtime/commands.mdx | 79 ++++++++++++
content/zero-runtime/components.mdx | 102 +++++++++++++++
content/zero-runtime/endpoints.mdx | 115 +++++++++++++++++
content/zero-runtime/index.mdx | 31 ++++-
content/zero-runtime/managed-content.mdx | 157 +++++++++++++++++++++++
content/zero-runtime/server.mdx | 147 +++++++++++++++++++++
9 files changed, 802 insertions(+), 14 deletions(-)
create mode 100644 content/zero-runtime/client.mdx
create mode 100644 content/zero-runtime/commands.mdx
create mode 100644 content/zero-runtime/components.mdx
create mode 100644 content/zero-runtime/endpoints.mdx
create mode 100644 content/zero-runtime/managed-content.mdx
create mode 100644 content/zero-runtime/server.mdx
diff --git a/blume.config.ts b/blume.config.ts
index 3adff46..3dbf0e0 100644
--- a/blume.config.ts
+++ b/blume.config.ts
@@ -118,8 +118,14 @@ export default defineConfig({
items: [
"/zero-runtime",
"/zero-runtime/build",
+ "/zero-runtime/client",
+ "/zero-runtime/server",
+ "/zero-runtime/endpoints",
"/zero-runtime/authentication",
"/zero-runtime/styling",
+ "/zero-runtime/components",
+ "/zero-runtime/commands",
+ "/zero-runtime/managed-content",
"/zero-runtime/move-from-lakebed",
"/functions",
"/functions/php",
diff --git a/content/zero-runtime/build.mdx b/content/zero-runtime/build.mdx
index d3bd78e..0cd8390 100644
--- a/content/zero-runtime/build.mdx
+++ b/content/zero-runtime/build.mdx
@@ -109,22 +109,17 @@ subscription opened under the `todos` query name.
Replace `client/index.tsx` with:
```tsx
-import { useMutation, useQuery } from "@spacefast/zero/client";
+import { createClient, useQuery } from "@spacefast/zero/client";
import { Button, Card, Checkbox, EmptyState, Input } from "@spacefast/zero/kit";
-type Todo = {
- id: string;
- text: string;
- done: boolean;
- ownerId: string;
- createdAt: string;
- updatedAt: string;
-};
+import type app from "../server/index";
+
+const api = createClient();
export function App() {
- const todos = useQuery("todos");
- const addTodo = useMutation<[text: string], void>("addTodo");
- const setDone = useMutation<[id: string, done: boolean], void>("setDone");
+ const todos = useQuery(api.todos());
+ const addTodo = api.useMutation("addTodo");
+ const setDone = api.useMutation("setDone");
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
@@ -166,6 +161,11 @@ export function App() {
}
```
+`createClient()` carries the capsule's query names, mutation names,
+arguments, and results into the client without importing server code at
+runtime. The compiler rejects a misspelled handler name or an argument with the
+wrong type.
+
## Run the app
Start the capsule:
diff --git a/content/zero-runtime/client.mdx b/content/zero-runtime/client.mdx
new file mode 100644
index 0000000..6bda845
--- /dev/null
+++ b/content/zero-runtime/client.mdx
@@ -0,0 +1,155 @@
+---
+title: Zero client API
+description: Use the typed client, live queries, pagination, mutations, routing, identity, storage, and error boundaries.
+---
+
+`@spacefast/zero/client` is the browser API for a Zero capsule. It connects the
+Preact client to the capsule's declared queries and mutations, the hosted
+identity, realtime updates, and the space's object store.
+
+## Create the typed client
+
+Import the capsule as a type, then pass that type to `createClient`:
+
+```tsx
+import { createClient, useQuery } from "@spacefast/zero/client";
+
+import type app from "../server/index";
+
+const api = createClient();
+
+function MessageList({ topic }: { topic: string }) {
+ const messages = useQuery(api.messages({ topic }));
+ return
{messages.length} messages
;
+}
+```
+
+The type-only import adds no server code to the client bundle. The client knows
+each query name, argument list, result, mutation name, and mutation argument
+list from the capsule type.
+
+The string form remains available for Lakebed compatibility:
+
+```tsx
+const messages = useQuery("messages", { topic });
+```
+
+Use the typed client in new code. It catches a renamed handler or a wrong
+argument before publish.
+
+## Run mutations
+
+Get a typed mutation function from the client:
+
+```tsx
+const sendMessage = api.useMutation("sendMessage");
+await sendMessage(roomId, body);
+```
+
+The returned promise resolves to the mutation result. A mutation error rejects
+the promise.
+
+## Paginate a query
+
+A paginated query accepts one object argument with a `pagination` field and
+returns `PaginationResult` on the server. The client supplies the pagination
+field:
+
+```tsx
+const messages = api.usePaginatedQuery(
+ "messages",
+ { roomId },
+ { initialNumItems: 20 },
+);
+
+return (
+ <>
+ {messages.page.map((message) => (
+
{message.body}
+ ))}
+ {!messages.isDone && }
+ >
+);
+```
+
+`page` contains all loaded rows. `continueCursor` is the next server cursor.
+`loadMore()` requests the next page, and `reset()` returns to the first page.
+
+## Route between pages
+
+The built-in router handles same-origin navigation without a page reload:
+
+```tsx
+import {
+ Link,
+ Route,
+ Router,
+ Routes,
+ useParams,
+} from "@spacefast/zero/client";
+
+function Project() {
+ const { id } = useParams() as { id?: string };
+ return
Project {id}
;
+}
+
+export function App() {
+ return (
+
+
+
+ Home
} />
+ } />
+ Not found} />
+
+
+ );
+}
+```
+
+Routes support literal segments, `:name` parameters, and `*` wildcards. The
+router also exports `navigate`, `useNavigate`, `useLocation`, and `useParams`.
+`Link` uses client navigation for same-origin URLs and normal browser
+navigation for external URLs.
+
+## Catch render errors
+
+Wrap a section with `ErrorBoundary` when the user can recover without
+reloading the whole app:
+
+```tsx
+import { ErrorBoundary } from "@spacefast/zero/client";
+
+ (
+
+ )}
+>
+
+;
+```
+
+The fallback receives the error and a function that resets the boundary.
+
+## Use identity and storage
+
+Use [`useAuth`, hosted sign-in, and sign-out](/zero-runtime/authentication) for
+the current visitor. `getIdentity()` reads the stored identity token metadata,
+and `decodeIdentityClaims()` decodes claims without verifying authorization.
+Server handlers must still enforce access with `ctx.auth`.
+
+The exported `storage` client supports `upload`, `get`, and `delete`. See
+[Storage](/storage) for object limits, authorization, and CLI operations.
+
+## Client exports
+
+| Group | Exports |
+| --- | --- |
+| Typed data | `createClient`, `useQuery`, `usePaginatedQuery`, `useMutation` |
+| Routing | `Router`, `Routes`, `Route`, `Link`, `navigate`, `useNavigate`, `useLocation`, `useParams` |
+| Authentication | `useAuth`, `SignInWithGoogle`, `signInWithGoogle`, `signOut`, `getIdentity`, `decodeIdentityClaims` |
+| Files | `storage` |
+| Recovery | `ErrorBoundary` |
diff --git a/content/zero-runtime/commands.mdx b/content/zero-runtime/commands.mdx
new file mode 100644
index 0000000..0fd9f8d
--- /dev/null
+++ b/content/zero-runtime/commands.mdx
@@ -0,0 +1,79 @@
+---
+title: Zero CLI commands
+description: List and call Zero Abilities, generate live types, and import Payload CMS or EmDash projects.
+---
+
+The `sf zero` command group works with a published Zero capsule and with local
+CMS projects. Use `--space` when the current directory is not linked to the
+target space.
+
+## List Abilities
+
+```bash
+sf zero abilities
+```
+
+The result lists every Ability published by the live capsule. The table shows
+the Ability name, category, read or write mode, and required authorization. Use
+`--json` to read each Ability's input and output schema.
+
+## Generate live types
+
+```bash
+sf zero types
+```
+
+The command writes `zero-types.ts` by default. The file contains the live
+content model types, the Ability catalog, the authorization required by each
+Ability, and a row type for each Zero database table.
+
+Commit the file, then check it in CI:
+
+```bash
+sf zero types --check
+```
+
+Use `--out ` to choose another destination.
+
+## Call an Ability
+
+```bash
+sf zero call content.posts.list
+```
+
+Pass input as inline JSON or from a file:
+
+```bash
+sf zero call content.posts.get --input '{"slug":"hello"}'
+sf zero call content.posts.save --input @post.json
+```
+
+The CLI mints a short-lived token, calls the Ability through the space's own
+WordPress REST API, and never prints the token. If the space has more than one
+machine credential, pass `--credential `.
+
+## Import another CMS
+
+Translate a Payload CMS project:
+
+```bash
+sf zero import payloadcms ../my-payload-app --out .
+```
+
+Translate an EmDash project:
+
+```bash
+sf zero import emdash ../my-emdash-site --out .
+```
+
+The import writes an authored Zero capsule and any supported content files.
+Payload CMS import refuses the whole write when the config contains a construct
+that Zero cannot represent. EmDash import reports mapped, renamed, downgraded,
+and refused data so omitted content is visible.
+
+Use `--capsule ` to choose the server module. Use `--force` only when you
+intend to replace existing files.
+
+Imported content declarations require the internal
+[managed content](/zero-runtime/managed-content) feature when you publish them.
+See the complete flags and JSON behavior in the [CLI reference](/cli).
diff --git a/content/zero-runtime/components.mdx b/content/zero-runtime/components.mdx
new file mode 100644
index 0000000..dd28428
--- /dev/null
+++ b/content/zero-runtime/components.mdx
@@ -0,0 +1,102 @@
+---
+title: Zero components and charts
+description: Reference every component, chart, and rendering helper bundled with the Zero client.
+---
+
+Zero bundles a Preact component kit and chart library. Import them from
+`@spacefast/zero/kit` and `@spacefast/zero/charts`. Do not add copies of these
+packages to the capsule.
+
+## Forms and actions
+
+| Export | Use |
+| --- | --- |
+| `Button` | Primary, secondary, ghost, destructive, link, and icon actions |
+| `Field` | A label, control, description, and validation message |
+| `Input` | Single-line input |
+| `TextArea` | Text input with multiple lines |
+| `Select` | Native select control |
+| `Label` | A form label |
+| `Checkbox` | Boolean checkbox |
+| `Switch` | Boolean switch |
+| `RadioGroup`, `RadioGroupItem` | One choice from a set |
+
+## Status and feedback
+
+| Export | Use |
+| --- | --- |
+| `Alert`, `AlertTitle`, `AlertDescription` | Informational, success, warning, or danger message |
+| `Badge` | Neutral, accent, success, warning, or danger label |
+| `EmptyState` | Empty result with an optional action |
+| `Spinner` | Indeterminate work |
+| `Skeleton` | Loading placeholder |
+| `Progress` | Determinate progress |
+
+## Content and layout
+
+| Export | Use |
+| --- | --- |
+| `Card` | Titled panel with optional subtitle and footer |
+| `Avatar` | Image or generated initials |
+| `Icon` | A bundled Lucide icon by name |
+| `Separator` | Horizontal or vertical divider |
+| `Kbd` | Keyboard input label |
+| `CodeBlock` | Highlighted code |
+| `Markdown` | Parsed and sanitized Markdown |
+| `Breadcrumb` | Path navigation |
+| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableCaption` | Data table parts |
+
+## Disclosure and overlays
+
+| Export | Use |
+| --- | --- |
+| `Accordion`, `AccordionItem` | Collapsible sections |
+| `Tooltip` | Hover or focus description |
+| `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | Tabbed content |
+| `Dialog` | Modal content |
+
+## Rendering helpers
+
+| Export | Result |
+| --- | --- |
+| `avatarInitials(name)` | Initials for an avatar fallback |
+| `highlightCode(code, language?)` | Highlighted code tokens |
+| `sanitizeUrl(url, kind?)` | A safe link or image URL, or `null` |
+| `parseMarkdownInline(source)` | Inline Markdown nodes |
+| `parseMarkdown(source, options?)` | Block Markdown nodes |
+| `markdownHeadingId(text)` | A heading ID |
+
+## Charts
+
+| Export | Use |
+| --- | --- |
+| `LineChart` | One or more line series |
+| `BarChart` | One or more bar series |
+| `Sparkline` | Compact trend line |
+| `StatTile` | Label, value, and optional trend |
+
+The chart module also exports `seriesColor`, `formatCompactValue`, `niceTicks`,
+`lineChartLayout`, `barChartLayout`, and `chartInk` for custom chart rendering.
+
+```tsx
+import { LineChart, StatTile } from "@spacefast/zero/charts";
+
+;
+;
+```
+
+Read [Style a Zero app](/zero-runtime/styling) for Tailwind compilation,
+semantic tokens, and `theme.json`.
diff --git a/content/zero-runtime/endpoints.mdx b/content/zero-runtime/endpoints.mdx
new file mode 100644
index 0000000..ef438e6
--- /dev/null
+++ b/content/zero-runtime/endpoints.mdx
@@ -0,0 +1,115 @@
+---
+title: Zero endpoints
+description: Declare HTTP routes, inspect requests, and return text, JSON, redirects, empty responses, or generated images.
+---
+
+An endpoint exposes an HTTP route from the capsule. Declare its method, path,
+and execution mode as literals so the compiler can build the route index.
+
+## Declare a read endpoint
+
+```ts
+import { endpoint, json } from "@spacefast/zero/server";
+
+endpoints: {
+ status: endpoint(
+ { mode: "read", method: "GET", path: "/api/status" },
+ (ctx, request) =>
+ json({
+ ok: true,
+ requestId: request.headers.get("x-request-id"),
+ userId: ctx.auth.userId,
+ }),
+ ),
+},
+```
+
+A read endpoint receives read-only `ctx.db`. It also receives `ctx.auth`,
+`ctx.env`, `ctx.log`, `ctx.gravatar`, and `ctx.spam.check`.
+
+## Declare a write endpoint
+
+Use `mode: "write"` when the endpoint changes data or uses a write-only
+service:
+
+```ts
+import { endpoint, json } from "@spacefast/zero/server";
+
+endpoints: {
+ createMessage: endpoint(
+ { mode: "write", method: "POST", path: "/api/messages" },
+ async (ctx, request) => {
+ if (!ctx.auth.userId) {
+ return json({ error: "Sign in required" }, { status: 401 });
+ }
+ const input = await request.json<{ body: string }>();
+ const message = await ctx.db.messages.insert({
+ authorId: ctx.auth.userId,
+ body: input.body.trim(),
+ });
+ ctx.invalidate("messages");
+ return json(message, { status: 201 });
+ },
+ ),
+},
+```
+
+Write endpoints get the same context as mutations, including `ctx.email`, spam
+corrections, and `ctx.invalidate`.
+
+## Read the request
+
+| Value | Type or result |
+| --- | --- |
+| `request.method` | Uppercase HTTP method |
+| `request.path` | Request path |
+| `request.url` | Complete request URL |
+| `request.headers` | `get`, `has`, and `entries` |
+| `request.query` | `URLSearchParams` |
+| `request.text()` | UTF-8 request body |
+| `request.json()` | Parsed JSON body |
+| `request.bytes()` | Request body as `Uint8Array` |
+
+Validate untrusted input before writing it. A TypeScript type argument on
+`request.json()` does not validate the body at runtime.
+
+## Return a response
+
+| Helper | Default result |
+| --- | --- |
+| `json(value, options?)` | JSON body, status `200`, JSON content type |
+| `text(value, options?)` | Text body, status `200`, UTF-8 text content type |
+| `empty(options?)` | Empty body, status `204` |
+| `redirect(url, options?)` | Empty body, status `302`, `Location` header |
+
+Each `options` object accepts `status` and `headers`. Returning `null` or
+`undefined` also produces an empty `204` response. A string, number, or boolean
+becomes text. Another JSON-compatible value becomes JSON.
+
+## Generate an image
+
+`ImageResponse` renders a Preact element into an image:
+
+```ts
+import { h } from "preact";
+import { endpoint, ImageResponse } from "@spacefast/zero/server";
+
+endpoints: {
+ card: endpoint(
+ { mode: "read", method: "GET", path: "/card.png" },
+ () =>
+ new ImageResponse(
+ h(
+ "div",
+ { style: { background: "#111", color: "white", padding: 64 } },
+ "Project board",
+ ),
+ { width: 1200, height: 630 },
+ ),
+ ),
+},
+```
+
+The options accept `width`, `height`, `status`, and `headers`. Images default to
+1200 by 630 pixels and one hour of public caching. Set a `cache-control` header
+to replace that default.
diff --git a/content/zero-runtime/index.mdx b/content/zero-runtime/index.mdx
index b518463..1a5fe97 100644
--- a/content/zero-runtime/index.mdx
+++ b/content/zero-runtime/index.mdx
@@ -5,8 +5,8 @@ description: Choose the full-stack runtime for apps built around shared data, id
Zero is Spacefast's full-stack runtime. A Zero capsule puts a Preact client,
server handlers, a typed database schema, authentication, and storage in one
-space. Its public server declarations are `schema`, `queries`, `mutations`, and
-`endpoints`. Zero is generally available.
+space. Zero is generally available. Managed content is internal and default
+off.
Choose Zero when the app is built around shared data. Queries keep the client
current, mutations write to the database, and every visitor gets an identity.
@@ -42,6 +42,18 @@ client, and activates the new version.
Run a complete capsule with a schema, live query, mutations, and an HTTP
endpoint.
+
+ Use the typed client, live and paginated queries, mutations, routing,
+ identity, storage, and error boundaries.
+
+
+ Declare the capsule, database schema, queries, mutations, and handler
+ contexts.
+
+
+ Declare read and write routes, inspect requests, and return text, JSON,
+ redirects, empty responses, or generated images.
+
Use guest identity, hosted Gravatar sign-in, and server-side ownership
checks.
@@ -50,6 +62,17 @@ client, and activates the new version.
Use static Tailwind classes, semantic tokens, the component kit, and
charts.
+
+ Look up every component, chart, and rendering helper bundled with Zero.
+
+
+ List and call Abilities, generate live types, and import Payload CMS or
+ EmDash projects.
+
+
+ Declare collections, pages, file sync, and generated Abilities. This
+ feature is internal and default off.
+
Move a core Lakebed capsule to the canonical Spacefast imports.
@@ -59,4 +82,8 @@ client, and activates the new version.
Upload objects from the client and manage them with the CLI.
+
+ Send email, check spam, read Gravatar profiles, use server variables, and
+ write runtime logs.
+
diff --git a/content/zero-runtime/managed-content.mdx b/content/zero-runtime/managed-content.mdx
new file mode 100644
index 0000000..068ab9a
--- /dev/null
+++ b/content/zero-runtime/managed-content.mdx
@@ -0,0 +1,157 @@
+---
+title: Managed content in Zero
+description: Declare collections, pages, file sync, generated Abilities, and CMS imports in a Zero capsule.
+---
+
+::::warning[Internal and default off]
+Managed content requires the Spacefast team to activate it and a compatible
+runtime. General customer spaces cannot publish these declarations yet. The GA
+Zero runtime does not require managed content.
+::::
+
+Managed content compiles capsule declarations into a version-bound WordPress
+content model. Publishing activates that model. Promoting or rolling back a
+version activates the model stored with that version.
+
+## Declare collections
+
+Add literal `collections` to the capsule:
+
+```ts
+import { capsule } from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Journal",
+ collections: {
+ posts: {
+ label: "Writing",
+ fields: {
+ deck: {
+ kind: "text",
+ label: "Deck",
+ required: true,
+ maxLength: 200,
+ },
+ body: {
+ kind: "blocks",
+ source: "content/posts/*.md",
+ },
+ },
+ },
+ projects: {
+ publicRead: true,
+ fields: {
+ status: {
+ kind: "enum",
+ values: ["draft", "shipped"],
+ },
+ lead: {
+ kind: "reference",
+ to: "posts",
+ },
+ cover: {
+ kind: "media",
+ multiple: true,
+ },
+ },
+ },
+ },
+});
+```
+
+WordPress supplies native `posts`, `pages`, and `media` collections. Declaring
+`posts` or `pages` adds fields to that native collection. Another collection
+becomes its own managed WordPress resource.
+
+A custom collection is private by default. Set `publicRead: true` to allow
+anonymous reads. Native `posts`, `pages`, and `media` stay public unless the
+declaration sets another policy.
+
+## Field kinds
+
+| Kind | Options |
+| --- | --- |
+| `text` | `multiline`, `maxLength` |
+| `number` | Common field options |
+| `boolean` | Common field options |
+| `datetime` | Common field options |
+| `blocks` | `allowed`, `source` |
+| `media` | `multiple` |
+| `reference` | Required `to`, optional `multiple` |
+| `enum` | Required `values`, optional `multiple` |
+
+Every field accepts `label` and `required`. A reference must name another
+declared collection.
+
+The compiler reads content declarations without running the server module.
+Use literal objects, arrays, strings, numbers, and boolean values. The compiler
+refuses helper calls, object spreads, variable references, and interpolated
+source paths.
+
+## Bind repository files
+
+A `source` on a `blocks` field binds Markdown or HTML files to that field:
+
+```ts
+body: {
+ kind: "blocks",
+ source: "content/posts/*.md",
+},
+```
+
+The glob can match files in one directory. The compiler creates one sync
+binding per file. Use `.md` for Markdown or `.html` for HTML. Another extension
+fails the build.
+
+Use `sync` when the field-level shorthand does not fit:
+
+```ts
+sync: {
+ homeBody: {
+ collection: "pages",
+ field: "body",
+ source: "content/home.md",
+ },
+},
+```
+
+The sync ledger reconciles the repository file and the WordPress field after
+publish.
+
+## Declare rendered pages
+
+Map a page name to a client component:
+
+```ts
+pages: {
+ projects: "client/ProjectsPage.tsx",
+ handbook: {
+ component: "client/HandbookPage.tsx",
+ path: "/docs/start",
+ title: "Handbook",
+ },
+},
+```
+
+The string form uses the page name as its route. `projects` serves
+`/projects`. The object form sets the component, route, and title explicitly.
+
+## Use generated Abilities and types
+
+The compiler publishes read and write Abilities for the content model. Inspect
+the live catalog and generate its types:
+
+```bash
+sf zero abilities
+sf zero types
+```
+
+Call an Ability by name:
+
+```bash
+sf zero call content.posts.list
+sf zero call content.posts.get --input '{"slug":"hello"}'
+```
+
+Read [Zero CLI commands](/zero-runtime/commands) for tokens, type checks, input
+files, and CMS import commands.
diff --git a/content/zero-runtime/server.mdx b/content/zero-runtime/server.mdx
new file mode 100644
index 0000000..4d8ff3e
--- /dev/null
+++ b/content/zero-runtime/server.mdx
@@ -0,0 +1,147 @@
+---
+title: Zero server API
+description: Declare a capsule, database schema, queries, mutations, and handler contexts.
+---
+
+`@spacefast/zero/server` defines the code that runs inside the Zero runtime.
+The compiler reads the capsule, compiles its handlers, and applies its database
+schema when you publish.
+
+## Declare a capsule
+
+Export one `capsule()` call from the configured server entry:
+
+```ts
+import { capsule } from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Project board",
+ favicon: "/favicon.svg",
+ schema: {},
+ queries: {},
+ mutations: {},
+ endpoints: {},
+});
+```
+
+The GA declarations are `name`, `favicon`, `schema`, `queries`, `mutations`,
+and `endpoints`. `collections`, `pages`, and `sync` belong to
+[managed content](/zero-runtime/managed-content), which is internal and default
+off.
+
+## Define the database schema
+
+Use `table`, `string`, `boolean`, and `id`:
+
+```ts
+import { boolean, id, string, table } from "@spacefast/zero/server";
+
+const rooms = table({
+ name: string(),
+});
+
+const messages = table({
+ roomId: id("rooms"),
+ authorId: string(),
+ body: string(),
+ pinned: boolean().default(false),
+}).index("by_room", ["roomId"]);
+```
+
+Every row also has `id`, `createdAt`, and `updatedAt`. Do not declare those
+field names. The database also reserves the `by_creation` index.
+
+`id("rooms")` records the referenced table in the generated type. It does not
+add an automatic join.
+
+## Read rows
+
+A query gets read-only database access:
+
+```ts
+queries: {
+ messages: query(async (ctx, roomId: string) =>
+ ctx.db.messages
+ .withIndex("by_room", (range) => range.eq("roomId", roomId))
+ .order("desc")
+ .take(50)
+ ),
+},
+```
+
+| API | Result |
+| --- | --- |
+| `table.get(id)` | One row or `null` |
+| `table.withIndex(name, range?)` | A query builder for a declared index |
+| `range.eq`, `gt`, `gte`, `lt`, `lte` | An index range |
+| `query.order("asc" | "desc")` | The same builder with an order |
+| `query.collect()` | All matching rows |
+| `query.take(count)` | Up to `count` rows |
+| `query.first()` | The first row or `null` |
+| `query.paginate(options)` | One cursor page |
+
+For pagination, accept one object that contains `pagination`:
+
+```ts
+import type { PaginationOptions } from "@spacefast/zero/server";
+
+messages: query(
+ async (
+ ctx,
+ input: { roomId: string; pagination: PaginationOptions },
+ ) =>
+ ctx.db.messages
+ .withIndex("by_room", (range) => range.eq("roomId", input.roomId))
+ .order("desc")
+ .paginate(input.pagination),
+),
+```
+
+The matching client call uses `usePaginatedQuery`. See the
+[Zero client API](/zero-runtime/client#paginate-a-query).
+
+## Write rows
+
+A mutation gets `insert`, `update`, and `delete` in addition to every read
+operation:
+
+```ts
+mutations: {
+ pinMessage: mutation(async (ctx, id: string, pinned: boolean) => {
+ const message = await ctx.db.messages.get(id);
+ if (!message || message.authorId !== ctx.auth.userId) return null;
+
+ const updated = await ctx.db.messages.update(id, { pinned });
+ ctx.invalidate("messages");
+ return updated;
+ }),
+},
+```
+
+`insert(value)` returns the new row. `update(id, patch)` returns the updated row
+or `null`. `delete(id)` returns whether it deleted a row.
+
+Call `ctx.invalidate()` with the query names that changed. If a mutation calls
+no invalidation, Zero refreshes every live query on the page. Narrow
+invalidation avoids unnecessary reads.
+
+## Handler contexts
+
+| Context value | Query or read endpoint | Mutation or write endpoint |
+| --- | --- | --- |
+| `ctx.db` | Read only | Read and write |
+| `ctx.auth` | Yes | Yes |
+| `ctx.env` | Yes | Yes |
+| `ctx.log` | Yes | Yes |
+| `ctx.gravatar` | Yes | Yes |
+| `ctx.spam.check` | Yes | Yes |
+| Spam corrections | No | Yes |
+| `ctx.email` | No | Yes |
+| `ctx.invalidate` | No | Yes |
+
+Read [Email, spam, and Gravatar](/services) for the service contracts and local
+development limits. Read [Zero endpoints](/zero-runtime/endpoints) for request
+and response handling.
+
+Zero server code cannot call `fetch`. Use [Functions](/functions) when the
+server must call an arbitrary HTTP service.
From b924ff1055ac0d2754f0eb37d5f2fc17093434c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?=
Date: Mon, 31 Aug 2026 22:42:42 +0200
Subject: [PATCH 3/4] Split the docs into header sections with derived
reference navigation
One flat sidebar carried every page, so reading an API operation showed the
whole product tree and no sibling endpoints, and the CLI reference was a
single page of 211 commands with no index anywhere in the navigation.
Header tabs now split the site into Platform, Zero, CLI, API, and Platform
API. A tab's path is a URL prefix and the sidebar group rooted at that path
is the section it scopes to, so every frozen generated prefix already is a
section root and no route moves.
Both reference sections derive their navigation instead of listing it:
the API sections read the OpenAPI snapshot through Blume's own extractor
(one collapsible group per tag, 319 and 329 operations with their method
badges), and the CLI section reads the command headings out of the
generated CLI page and links their anchors. An endpoint or command that
ships lands in the sidebar without an edit.
Those derived entries are computed above the config object rather than
written as sidebar literals, so the route manifest that verify-routes and
the llms.txt curation both read stays exactly the 72 authored routes.
---
AGENTS.md | 22 +++++--
blume.config.ts | 164 +++++++++++++++++++++++++++++++++++++++-------
content/index.mdx | 7 +-
3 files changed, 165 insertions(+), 28 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 94f9c5a..2ec9777 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,15 +11,29 @@ Assume every commit and every line of history will be public.
an encyclopedia of alternatives.
- Do not add navigation to a section until that section has a real page or
generated source.
-- The sidebar in `blume.config.ts` is the public route manifest.
- `bun run verify:routes` checks every sidebar route against the built output.
+- The routes written as string literals in the `blume.config.ts` sidebar are
+ the public route manifest. `bun run verify:routes` checks every one against
+ the built output, and `scripts/build-llms-index.mjs` keeps exactly those
+ entries in `llms.txt`. Both read the config source between `sidebar:` and
+ `openapi:`, so the generated sections — API operations, CLI commands — are
+ computed above the config object and spread into the sidebar. Writing one of
+ those routes as a literal inside the sidebar would claim a manifest entry it
+ cannot keep, and break both.
+- Header tabs are the top-level sections: Platform (`/`), Zero, CLI, API, and
+ Platform API. A tab's `path` is a URL prefix, and the sidebar group rooted at
+ that same path is the section the tab scopes the sidebar to — a new tab means
+ both, or it renders an empty sidebar. Frozen generated prefixes are what make
+ tabs work here: they are already stable section roots, so no route moves.
+- Reference navigation is derived, never hand-listed. The API sections read the
+ OpenAPI snapshot through Blume's own extractor, and the CLI section reads the
+ command headings out of the generated CLI page, so an endpoint or command
+ that ships appears in the sidebar without an edit here.
- Route policy: one page per task, not one page per toggle; a page lives in one
primary place and is cross-linked elsewhere. Authored URLs may move freely
before launch, without compatibility redirects. Generated routes are frozen
public contracts and can never move: `/api/reference`,
`/platforms/api/reference`, `/cli`, `/errors` (RFC 9457 error type URIs point
- at `/errors/`), and `/changelog`. Because those prefixes can never be
- unified, the site uses a single sidebar with no tabs.
+ at `/errors/`), and `/changelog`.
- Test behavior and built output. Do not read implementation source and assert
that a string exists as a substitute for exercising the result.
diff --git a/blume.config.ts b/blume.config.ts
index 3dbf0e0..8af63f9 100644
--- a/blume.config.ts
+++ b/blume.config.ts
@@ -1,4 +1,8 @@
+import { readFileSync } from "node:fs";
+
import { defineConfig } from "blume";
+import { componentSlug } from "blume/components/slug.ts";
+import { extractOperations } from "blume/openapi/model.ts";
const deploymentBase = "/docs";
const haskoy = {
@@ -12,6 +16,89 @@ const haskoy = {
],
};
+/**
+ * Sidebar groups for one OpenAPI spec: a collapsible group per tag, holding
+ * that tag's operation pages in spec order.
+ *
+ * Blume generates those pages and their routes from the spec, so the sidebar
+ * reads the same spec through Blume's own extractor rather than a hand-kept
+ * list that drifts every time an endpoint lands.
+ */
+const referenceGroups = (spec: string, route: string) => {
+ const { operations, tags } = extractOperations(
+ JSON.parse(readFileSync(spec, "utf8")),
+ route
+ );
+ return tags.map((tag) => ({
+ collapsed: true,
+ display: "group" as const,
+ items: operations
+ .filter((operation) => operation.tagSlug === tag.slug)
+ .map((operation) => operation.route),
+ label: tag.name,
+ }));
+};
+
+/**
+ * Sidebar entries for the CLI: one group per top-level command, holding that
+ * command's subcommands.
+ *
+ * The CLI reference is a single generated page with a heading per command, so
+ * the entries link to heading anchors and are read back out of that page —
+ * a command that ships is in the sidebar without anyone editing this file.
+ * Anchors are slugged the way Blume slugs headings, so they always agree.
+ */
+const cliCommands = () => {
+ let page: string;
+ try {
+ page = readFileSync("./content/cli/index.md", "utf8");
+ } catch {
+ // The page is an ignored build overlay, so a bare `blume` run without the
+ // prepare step would otherwise fail here as a bare ENOENT.
+ throw new Error(
+ "content/cli/index.md is missing. Run scripts/prepare-generated.mjs before Blume; the CLI sidebar is built from that page."
+ );
+ }
+ const commands = [...page.matchAll(/^## `sf ([^`]+)`$/gmu)].map((heading) => ({
+ href: `/cli#${componentSlug(`sf ${heading[1]}`)}`,
+ // Argument placeholders (`BUILD`, `[PATH]`) name the call, not the
+ // command, so they are dropped from the label and the grouping.
+ words: heading[1].split(/\s+/u).filter((word) => !/^[A-Z[]/u.test(word)),
+ }));
+
+ const groups = new Map();
+ for (const command of commands) {
+ const name = command.words[0];
+ groups.set(name, [...(groups.get(name) ?? []), command]);
+ }
+
+ return [...groups].map(([name, entries]) => {
+ const [first] = entries;
+ if (entries.length === 1) {
+ return { href: first.href, label: `sf ${first.words.join(" ")}` };
+ }
+ return {
+ collapsed: true,
+ display: "group" as const,
+ items: entries.map((entry) => ({
+ href: entry.href,
+ label:
+ entry.words.length === 1 ? "Overview" : entry.words.slice(1).join(" "),
+ })),
+ label: `sf ${name}`,
+ };
+ });
+};
+
+const restReference = referenceGroups(
+ "./generated/openapi/api.json",
+ "/api/reference"
+);
+const platformReference = referenceGroups(
+ "./generated/openapi/platform.json",
+ "/platforms/api/reference"
+);
+
export default defineConfig({
title: "Spacefast Docs",
description: "Publish sites and build apps with Spacefast through the CLI, API, SDK, and MCP.",
@@ -69,6 +156,17 @@ export default defineConfig({
{ label: "Agent setup", href: "/agents", icon: "bot" },
],
repo: true,
+ // Header sections. A tab's `path` is a URL prefix, and the sidebar group
+ // rooted at that prefix is the section it scopes to — so reading the CLI
+ // reference shows CLI navigation, not the whole site. The `/` tab spans
+ // everything the others don't claim.
+ tabs: [
+ { label: "Platform", path: "/" },
+ { label: "Zero", path: "/zero-runtime" },
+ { label: "CLI", path: "/cli" },
+ { label: "API", path: "/api" },
+ { label: "Platform API", path: "/platforms" },
+ ],
sidebar: {
display: "flat",
items: [
@@ -114,19 +212,8 @@ export default defineConfig({
},
{
label: "Primitives",
- root: "/zero-runtime",
+ root: "/functions",
items: [
- "/zero-runtime",
- "/zero-runtime/build",
- "/zero-runtime/client",
- "/zero-runtime/server",
- "/zero-runtime/endpoints",
- "/zero-runtime/authentication",
- "/zero-runtime/styling",
- "/zero-runtime/components",
- "/zero-runtime/commands",
- "/zero-runtime/managed-content",
- "/zero-runtime/move-from-lakebed",
"/functions",
"/functions/php",
"/database",
@@ -182,11 +269,6 @@ export default defineConfig({
"/guides/local-development",
],
},
- {
- label: "Platforms",
- root: "/platforms",
- items: ["/platforms"],
- },
{
label: "Account & teams",
root: "/account",
@@ -202,17 +284,55 @@ export default defineConfig({
root: "/reference",
items: [
"/reference",
- "/api",
- "/api/versioning",
- { label: "REST API reference", href: "/api/reference" },
- { label: "Platform API reference", href: "/platforms/api/reference" },
- "/cli",
"/reference/sdk",
"/errors",
{ label: "Changelog", href: "/changelog" },
"/changelog/packages",
],
},
+ // Header-tab sections. Each is rooted at the matching tab's path, which
+ // is what scopes the sidebar to it.
+ {
+ label: "Zero",
+ root: "/zero-runtime",
+ items: [
+ "/zero-runtime",
+ "/zero-runtime/build",
+ "/zero-runtime/client",
+ "/zero-runtime/server",
+ "/zero-runtime/endpoints",
+ "/zero-runtime/authentication",
+ "/zero-runtime/styling",
+ "/zero-runtime/components",
+ "/zero-runtime/commands",
+ "/zero-runtime/managed-content",
+ "/zero-runtime/move-from-lakebed",
+ ],
+ },
+ {
+ label: "CLI",
+ root: "/cli",
+ items: ["/cli", ...cliCommands()],
+ },
+ {
+ label: "API",
+ root: "/api",
+ items: [
+ "/api",
+ "/api/versioning",
+ { label: "All endpoints", href: "/api/reference" },
+ ...restReference,
+ ],
+ },
+ {
+ label: "Platform API",
+ root: "/platforms",
+ items: [
+ "/platforms",
+ { label: "All endpoints", href: "/platforms/api/reference" },
+ ...platformReference,
+ ],
+ },
],
},
},
diff --git a/content/index.mdx b/content/index.mdx
index e577bc3..f5f5368 100644
--- a/content/index.mdx
+++ b/content/index.mdx
@@ -38,8 +38,11 @@ Claim the space afterward to keep it.
Spaces are private by default. Grant access to people, links,
passwords, or the public, and collect comments on previews.
-
- Zero, Functions, and the space's database, storage, and email.
+
+ Build a full app on a space: server code, endpoints, auth, and styling.
+
+
+ Functions and the space's own database, storage, and email.
Attach your own hostname. DNS checks and SSL are automatic.
From 46eec22caaaa86d2ac509c9722060c900b2cab0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?=
Date: Wed, 2 Sep 2026 00:37:55 +0200
Subject: [PATCH 4/4] Fix a sign-in guard that never rejects a guest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The write-endpoint example gated on `!ctx.auth.userId`. A signed-out visitor
reaches a handler with a real guest identity — the runtime host fills the
context with `guest:anonymous` when no visitor token is present — so that
test is never true and anonymous callers passed straight through it.
Gate on `ctx.auth.isGuest` instead, and say so on the authentication page,
which covered scoping and ownership but never how to require sign-in at all.
---
content/zero-runtime/authentication.mdx | 13 +++++++++++++
content/zero-runtime/endpoints.mdx | 2 +-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/content/zero-runtime/authentication.mdx b/content/zero-runtime/authentication.mdx
index cc4dce6..692fda6 100644
--- a/content/zero-runtime/authentication.mdx
+++ b/content/zero-runtime/authentication.mdx
@@ -43,6 +43,19 @@ function AuthControls() {
button. `signOut()` returns the client to a guest identity. Hosted sign-in is
unavailable under `sf dev`, so publish the capsule to test this flow.
+## Require sign-in
+
+A guest is an identity, not the absence of one: a signed-out visitor reaches
+your handler with a real `ctx.auth.userId` of its own. Testing that field
+therefore rejects nobody. Check `ctx.auth.isGuest` when a handler needs a
+signed-in visitor:
+
+```ts
+if (ctx.auth.isGuest) {
+ return json({ error: "Sign in required" }, { status: 401 });
+}
+```
+
## Scope reads to the visitor
Store the server identity with each user-owned row, then filter the query by
diff --git a/content/zero-runtime/endpoints.mdx b/content/zero-runtime/endpoints.mdx
index ef438e6..4f5f0b4 100644
--- a/content/zero-runtime/endpoints.mdx
+++ b/content/zero-runtime/endpoints.mdx
@@ -39,7 +39,7 @@ endpoints: {
createMessage: endpoint(
{ mode: "write", method: "POST", path: "/api/messages" },
async (ctx, request) => {
- if (!ctx.auth.userId) {
+ if (ctx.auth.isGuest) {
return json({ error: "Sign in required" }, { status: 401 });
}
const input = await request.json<{ body: string }>();