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 759ce6e..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,9 +212,8 @@ export default defineConfig({ }, { label: "Primitives", - root: "/zero-runtime", + root: "/functions", items: [ - "/zero-runtime", "/functions", "/functions/php", "/database", @@ -172,11 +269,6 @@ export default defineConfig({ "/guides/local-development", ], }, - { - label: "Platforms", - root: "/platforms", - items: ["/platforms"], - }, { label: "Account & teams", root: "/account", @@ -192,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/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/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. 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..692fda6 --- /dev/null +++ b/content/zero-runtime/authentication.mdx @@ -0,0 +1,101 @@ +--- +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. + +## 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 +`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..0cd8390 --- /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 { createClient, useQuery } from "@spacefast/zero/client"; +import { Button, Card, Checkbox, EmptyState, Input } from "@spacefast/zero/kit"; + +import type app from "../server/index"; + +const api = createClient(); + +export function App() { + const todos = useQuery(api.todos()); + const addTodo = api.useMutation("addTodo"); + const setDone = api.useMutation("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 ( +
+ +
void onSubmit(event)}> + + +
+ {todos.length === 0 ? ( +
+ +
+ ) : ( +
    + {todos.map((todo) => ( +
  • + void setDone(todo.id, !todo.done)} + /> + + {todo.text} + +
  • + ))} +
+ )} +
+
+ ); +} +``` + +`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: + +```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/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..4f5f0b4 --- /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.isGuest) { + 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 00d9613..1a5fe97 100644 --- a/content/zero-runtime/index.mdx +++ b/content/zero-runtime/index.mdx @@ -1,674 +1,89 @@ --- 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. Zero is generally available. Managed content is internal and default +off. -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. - -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. +## The shortest path -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 ( -
{ - event.preventDefault(); - const uploaded = photo ? await storage.upload(photo) : null; - await sign(draft, uploaded?.url ?? ""); - setDraft(""); - setPhoto(null); - }} - > - setDraft(event.currentTarget.value)} - placeholder="Leave a note" - /> - setPhoto(event.currentTarget.files?.[0] ?? null)} - /> - -
- ); -} - -function HomePage() { - const entries = useQuery("entries"); - return ( -
- - {entries.length === 0 ? ( - - ) : ( -
    - {entries.map((entry) => ( -
  • - -

    - {entry.avatarUrl ? ( - - ) : null} - {entry.authorName} {entry.body} -

    - {entry.photoUrl ? ( - - ) : null} -
    -
  • - ))} -
- )} -
- ); -} - -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: - -```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"}' -``` - -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. - -```tsx client/index.tsx -import { LineChart } from "@spacefast/zero/charts"; -import { Badge, Card } from "@spacefast/zero/kit"; - - - 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 +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 -sf runtime status -sf logs runtime --follow +sf publish ``` -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. +`sf publish` compiles both entries, applies the schema change, uploads the +client, and activates the new version. -What changes on Spacefast: +## Build with Zero -- 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 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. + + + 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. + + + Define tables and indexes, inspect rows, and manage schema changes. + + + 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/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/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. 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",