diff --git a/content/(account)/billing.mdx b/content/(account)/billing.mdx
index 29af7a7..39bcfde 100644
--- a/content/(account)/billing.mdx
+++ b/content/(account)/billing.mdx
@@ -19,7 +19,7 @@ Free needs no card and is for personal, non-commercial projects. Enterprise plan
See [Usage](/usage) for Free allowances, shared pools, and paid usage rates.
-PHP, Zero, Workers, SQL, private sharing, managed publishing, Git, CLI, API, MCP and exports are available across plans. Usage limits still apply. Connecting a domain has no extra fee. Domain registration, renewal and third-party licenses are separate purchases.
+PHP, First, Workers, SQL, private sharing, managed publishing, Git, CLI, API, MCP and exports are available across plans. Usage limits still apply. Connecting a domain has no extra fee. Domain registration, renewal and third-party licenses are separate purchases.
Domains registered through Spacefast or WordPress.com, and domains using Spacefast DNS, do not count toward the Free plan's {{free-customDomains}}-domain allowance. Other connected domains and individually connected subdomains count toward it. Managed HTTPS is included. Wildcards such as `*.example.com` require Plus or Enterprise.
diff --git a/content/(account)/usage.mdx b/content/(account)/usage.mdx
index 101293d..42d7fbe 100644
--- a/content/(account)/usage.mdx
+++ b/content/(account)/usage.mdx
@@ -13,7 +13,7 @@ These are per team each month, except storage, which is a retained-byte allowanc
/_pricing/usage.md
-The CPU allowance combines PHP, Zero and SQL. Each other allowance stands on its own; unused build minutes cannot buy more transfer. Free has no dollar credit and no automatic overage bill.
+The CPU allowance combines PHP, First and SQL. Each other allowance stands on its own; unused build minutes cannot buy more transfer. Free has no dollar credit and no automatic overage bill.
Exceeding an allowance alerts Spacefast for review. A Space may be suspended until the applicable allowance resets, retained storage falls below its limit, or the team upgrades. Data stays and exports remain available.
@@ -23,9 +23,9 @@ Prices are USD before tax. The included quantities below belong to the whole tea
CPU means active processing time, excluding waits. There is no memory charge. Build minutes measure elapsed machine time, including installation and idle time; platform-caused retries are not billed.
-An invocation is one PHP or Zero origin execution, including a request that bypasses the cache or cron run. Cached hits and Worker requests are excluded. Workers have their own request meter.
+An invocation is one PHP or First origin execution, including a request that bypasses the cache or cron run. Cached hits and Worker requests are excluded. Workers have their own request meter.
-Cached delivery counts cached response bytes. Non-cached transfer counts origin misses, bypasses and PHP or Zero response bytes. Worker responses are excluded from that transfer charge. Bytes are counted once on the path that produced them.
+Cached delivery counts cached response bytes. Non-cached transfer counts origin misses, bypasses and PHP or First response bytes. Worker responses are excluded from that transfer charge. Bytes are counted once on the path that produced them.
Email counts recipient deliveries, so one message to three people counts as three. Daily delivery safeguards still apply. A credit balance does not override them; check the current entitlement before sending.
diff --git a/content/(dynamic)/crons.mdx b/content/(dynamic)/crons.mdx
index d9e53a0..ebebed8 100644
--- a/content/(dynamic)/crons.mdx
+++ b/content/(dynamic)/crons.mdx
@@ -9,7 +9,7 @@ There's no dashboard editor for crons. Declare schedules in `sf.jsonc`, then pub
## How a run happens
-A cron is a scheduled `GET` against a path on your own site. It goes through the same front door a visitor gets, so whatever serves that path answers it: a [Zero](/zero) page, a [Functions](/functions) route, or a static file.
+A cron is a scheduled `GET` against a path on your own site. It goes through the same front door a visitor gets, so whatever serves that path answers it: a [First](/first) page, a [Functions](/functions) route, or a static file.
A response under 400 is a success and is silent. A response of 400 or worse, or a fatal error, is a failure and is reported.
diff --git a/content/(dynamic)/database.mdx b/content/(dynamic)/database.mdx
index 8e3e3ac..d219cff 100644
--- a/content/(dynamic)/database.mdx
+++ b/content/(dynamic)/database.mdx
@@ -1,32 +1,32 @@
---
title: Database
-description: Every Space gets its own MySQL. A Zero app's collections and tables live there, Spacefast migrates it on publish, and a single-use SQL console opens it when you need raw SQL.
+description: Every Space gets its own MySQL. A First app's collections and tables live there, Spacefast migrates it on publish, and a single-use SQL console opens it when you need raw SQL.
sidebar:
order: 2
---
-There's no connection string for this database. Your code reaches it through Zero's `db` or a worker's `env.DB`, and you reach it through a single-use SQL console.
+There's no connection string for this database. Your code reaches it through First's `db` or a worker's `env.DB`, and you reach it through a single-use SQL console.
:::note[Don't have a database yet?]
-Start a [Zero](/zero) app with `sf init --runtime zero`. Its `zero.config.ts` declares the collections and tables, and Spacefast creates them when you publish. A [Functions](/functions#what-is-on-env) worker also gets the database, through `env.DB`.
+Start a [First](/first) app with `sf init --runtime first`. Its `first.config.ts` declares the collections and tables, and Spacefast creates them when you publish. A [Functions](/functions#what-is-on-env) worker also gets the database, through `env.DB`.
:::
## What it is
-Every Space has one database, the MySQL that lives on the machine serving the site. A [Zero](/zero) app keeps its collections and tables there. A [Functions](/functions) worker gets it as `env.DB`.
+Every Space has one database, the MySQL that lives on the machine serving the site. A [First](/first) app keeps its collections and tables there. A [Functions](/functions) worker gets it as `env.DB`.
You do not create it, size it, or connect to it. Your config declares the schema, and Spacefast applies it when a version initializes.
:::warning[There is no external connection string]
-No route, flag, or dashboard field returns a DSN. Your code reaches the database through `db` (Zero) or `env.DB` (Functions). For everything else there is the [SQL console](#the-sql-console).
+No route, flag, or dashboard field returns a DSN. Your code reaches the database through `db` (First) or `env.DB` (Functions). For everything else there is the [SQL console](#the-sql-console).
:::
## Query it from code
-In a Zero app, hooks and actions read and write through `db`. Your schema is the API, and its column and relation names are checked at build time.
+In a First app, hooks and actions read and write through `db`. Your schema is the API, and its column and relation names are checked at build time.
-```ts zero.config.ts
-import { action, db, defineConfig, HttpError, integer, table, text } from "zero";
+```ts first.config.ts
+import { action, db, defineConfig, HttpError, integer, table, text } from "first";
// Each signed-in account reads and writes only its own rows, through every route.
const notes = table(
@@ -99,12 +99,12 @@ export default {
## Migrations
-Your `zero.config.ts` is the schema. When you publish a version that changes it, Spacefast diffs it against the schema the Space already runs and applies the difference while the new version initializes, before it can go live.
+Your `first.config.ts` is the schema. When you publish a version that changes it, Spacefast diffs it against the schema the Space already runs and applies the difference while the new version initializes, before it can go live.
-To see the SQL a change produces, build locally and read the newest file in `zero/migrations/`:
+To see the SQL a change produces, build locally and read the newest file in `first/_generated/migrations/`:
```bash
-sf zero build
+sf first build
```
:::warning[Rollback does not roll back the schema]
@@ -130,7 +130,7 @@ sf db console --show-secret
`sf db console` opens a browser by default, because the URL grants full SQL authority over the Space's database. `--show-secret` prints it instead, and is the deliberate override. The URL is masked in `--json` receipts unless you pass it. Opening the console needs write access to the Space.
-A Zero app's tables share a prefix that starts with `sfz_`.
+A First app's tables share a prefix that starts with `sfz_`.
## Limits
diff --git a/content/(dynamic)/environment-variables.mdx b/content/(dynamic)/environment-variables.mdx
index 6f813e3..64a10a6 100644
--- a/content/(dynamic)/environment-variables.mdx
+++ b/content/(dynamic)/environment-variables.mdx
@@ -92,10 +92,10 @@ EOF
sf publish
```
-A [Zero](/zero) action or hook reads it with `env()`, which returns `null` for a name that isn't set.
+A [First](/first) action or hook reads it with `env()`, which returns `null` for a name that isn't set.
-```ts zero.config.ts
-import { action, defineConfig, env } from "zero";
+```ts first.config.ts
+import { action, defineConfig, env } from "first";
export default defineConfig({
actions: {
@@ -109,7 +109,7 @@ export default defineConfig({
});
```
-A Zero app sees **Space** variables only. Team variables do not reach it, so set what the app needs on the Space.
+A First app sees **Space** variables only. Team variables do not reach it, so set what the app needs on the Space.
A [Functions](/functions) worker reads the same values from `env`, its second handler argument.
diff --git a/content/(dynamic)/first.mdx b/content/(dynamic)/first.mdx
new file mode 100644
index 0000000..67c5809
--- /dev/null
+++ b/content/(dynamic)/first.mdx
@@ -0,0 +1,272 @@
+---
+title: Dynamic sites with First
+description: First compiles one first.config.ts (collections, tables, actions, and TSX pages) into WordPress, and Spacefast hosts it on your Space.
+sidebar:
+ order: 0
+---
+
+First (formerly Zero v2) turns one `first.config.ts` into a WordPress app: collections, tables, actions, access rules, and pages. `sf publish` sends the source, and Spacefast compiles it and runs it on the Space's own WordPress. First is available on every account, with [usage allowances and metered rates](/usage).
+
+## What First is
+
+First is a compiler. You describe your data and your server logic in TypeScript, and First compiles it to WordPress plugins: a post type for each collection, a SQL table for each table, REST routes for both, and plain PHP for your hooks and actions. Nothing runs JavaScript on the server.
+
+A project is a First app when it has a `first.config.ts` at its root. There is no runtime block to declare and no switch to turn on.
+
+The other runtime is [Functions](/functions), a worker for npm-heavy code and framework output like OpenNext.
+
+## Start an app
+
+```bash
+sf init my-app --runtime first
+cd my-app
+sf first dev
+sf publish
+```
+
+`sf init --runtime first` writes a starter `first.config.ts` named after the directory. `sf first init` writes the same file. `sf first dev` runs the app on a local WordPress and rebuilds when you save. `sf publish` publishes it to a new Space.
+
+The starter holds one collection, one table, and one action:
+
+```ts first.config.ts
+import { action, collection, db, defineConfig, table, text, timestamp } from "first";
+
+// Collections are WordPress posts: they show up in wp-admin and the REST API.
+export const posts = collection({
+ title: text().notNull(),
+ body: text(),
+}, { title: "title" });
+
+// Tables are plain MySQL tables for app data. Signups hold visitors' emails, so only the
+// signup action writes them and only admins read them.
+export const signups = table({
+ email: text().notNull().unique().max(191),
+ createdAt: timestamp().notNull().defaultNow(),
+}, { access: { read: "admin", create: false, update: false, delete: false } });
+
+export default defineConfig({
+ name: "my-app",
+ collections: { posts },
+ tables: { signups },
+ actions: {
+ signup: action({
+ input: { email: text().notNull() },
+ access: "anyone",
+ async handler({ email }) {
+ const [row] = await db.insert(signups).values({ email: email.trim().toLowerCase() }).returning();
+ return { id: row?.id ?? null };
+ },
+ }),
+ },
+});
+```
+
+Everything comes from the `"first"` module.
+
+## What you get
+
+| Piece | What it is |
+| --- | --- |
+| Collections | Editorial content. Each is a WordPress post type with the block editor, drafts, and revisions |
+| Tables | App data in its own SQL table |
+| REST routes | `/wp-json/first/v1/` for every collection and table, with relations loaded through `with` |
+| Actions | Named server functions at `POST /wp-json/first/v1/actions/` |
+| Hooks | Code that runs before or after a collection write, inside the same transaction |
+| Access rules | Who may read and write each collection, table, field, and action |
+| Sign-in | Accounts, sessions, guests, passkeys, and MFA through the `first-identity` plugin |
+| Jobs and webhooks | Background work that runs after a request commits |
+| Pages | TSX routes and layouts that WordPress renders per request |
+| Typed client | A `fetch` client generated from your schema |
+
+Your data lives in the Space's MySQL database. See [Database](/database).
+
+## Collections and tables
+
+Pick the storage by what the data is. A `collection()` is content people edit: it gets a WordPress post type, an editor in wp-admin, drafts, and revisions. A `table()` is app data: it gets its own SQL table and nothing else.
+
+```ts first.config.ts
+import { collection, defineConfig, defineRelations, integer, richText, table, text } from "first";
+
+const posts = collection({ title: text().notNull(), body: richText() }, { content: "body" });
+const stock = table({
+ postId: integer().references(() => posts.id, { onDelete: "cascade" }),
+ quantity: integer().notNull().default(0),
+});
+
+const collections = { posts };
+const tables = { stock };
+const relations = defineRelations({ ...collections, ...tables }, (r) => ({
+ stock: { post: r.one.posts({ from: r.stock.postId, to: r.posts.id }) },
+ posts: { stock: r.many.stock() },
+}));
+
+export default defineConfig({ collections, tables, relations });
+```
+
+Every row gets an `id`. The registry key is the name everywhere: `shoppingList` is `first.shoppingList` in the client and `/wp-json/first/v1/shopping_list` over REST.
+
+Column builders include `text()`, `integer()`, `real()`, `boolean()`, `timestamp()`, `date()`, `json()`, `email()`, `url()`, `slug()`, `richText()`, `media()`, `file()`, `select()`, `group()`, `repeater()`, and `blocks()`. Chain `.notNull()`, `.default(value)`, `.unique()`, `.min(n)`, `.max(n)`, `.pattern(/re/)`, or `.references(...)` to constrain one. A modifier that makes no sense for its column, like `.max()` on a boolean, fails the build at that line.
+
+## Access rules
+
+Every collection and table takes `access: { read, create, update, delete }`. Each rule is `true`, `false`, `"anyone"`, `"authenticated"`, a role, or a function of `ctx`. Roles rank `"guest" < "subscriber" < "author" < "editor" < "admin"`, and a role admits itself and everything above it.
+
+```ts
+{ access: { read: "anyone", create: "author", update: "author", delete: "editor" } }
+```
+
+The default is read by `"anyone"`, write by `"editor"`. Actions default to `"editor"` too, so an action a visitor calls needs `access: "anyone"`. The rules hold on every path into the data: First's REST routes, WordPress's own `/wp/v2` routes, wp-admin, and Abilities.
+
+## Actions
+
+An action takes typed input and returns JSON. `input` uses the same column builders as your schema, so a bad request is refused before your handler runs.
+
+```bash
+curl -X POST https://my-site.view.fast/wp-json/first/v1/actions/signup \
+ -H 'Content-Type: application/json' \
+ --data '{"email":"sam@example.com"}'
+```
+
+A successful call answers `{ "data": ... }` with whatever the handler returned. Throw `new HttpError(status, code, message)` to choose the error response. Any throw rolls back every write the request made.
+
+## Hooks
+
+Hooks run around collection writes: `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, and `afterDelete`. A `before` hook can change the data or throw to refuse the write.
+
+```ts
+hooks: {
+ posts: {
+ beforeCreate(data) {
+ data.title = data.title.trim();
+ if (data.title === "") throw new HttpError(422, "invalid_input", "A post needs a title");
+ },
+ },
+},
+```
+
+## What compiles
+
+Hooks, actions, and access functions are a checked subset of TypeScript compiled to PHP. Functions, closures, spread, classes, `async`/`await` on First calls, and the usual control flow all work. `eval`, generators, `any`, and dynamic `import()` do not. Anything outside the subset fails the build at `file:line:col` with a fix:
+
+```bash
+sf first check
+sf first check --json
+```
+
+Imports can be `"first"`, your own relative modules, and installed source packages. `sf publish` captures the installed packages it needs. Spacefast never runs `npm install`.
+
+## Call it from a frontend
+
+`sf first build` and `sf first dev` write a typed client to `first/client/`. It is plain `fetch`, so it works from any framework.
+
+```ts
+import { createClient } from "./first/client/index.ts";
+
+const first = createClient({ url: "https://my-site.view.fast/wp-json/first/v1" });
+
+const posts = await first.posts.list({ orderBy: { title: "asc" } });
+await first.actions.signup({ email: "sam@example.com" });
+```
+
+Every argument and result is typed from your schema. A published Space also serves its client at `/__spacefast/client.js`, with types at `/__spacefast/client.d.ts`.
+
+To serve a static frontend from the same Space, put its files in `public/`. `sf publish` uploads them with the app.
+
+## Pages
+
+Pages are TSX files under `pages/`. They compile at build time, and WordPress renders them on every request with the visitor's own data, through the same access rules as the REST routes.
+
+| File | Route |
+| --- | --- |
+| `pages/page.tsx` | `/` |
+| `pages/about/page.tsx` | `/about` |
+| `pages/notes/[slug]/page.tsx` | `/notes/`, one per post in a collection, with `collectionPage("notes")` |
+| `pages/layout.tsx` | Wraps every page below it |
+| `pages/not-found.tsx` | The body of a 404 |
+
+A static route beats an item route at the same path, so `notes/featured` wins over `notes/[slug]`. WordPress's own paths, like `/wp-admin` and `/wp-json`, cannot be claimed. Every page response is `Cache-Control: private, no-store`, because its HTML depends on the visitor.
+
+A page reads data with a `load()` function and renders request values through helpers like `` and `orElse()`. Plain `&&` or `?:` on request data is refused at build time, with the line and the helper to use instead.
+
+## Environment variables
+
+Read a Space variable with `env()`. Set it with `sf env set`, or put it in `.env.server` and publish.
+
+```ts
+import { action, env } from "first";
+
+const checkout = action({
+ access: "anyone",
+ handler() {
+ return { configured: env("STRIPE_SECRET_KEY") !== null };
+ },
+});
+```
+
+See [Environment variables](/environment-variables).
+
+## Jobs and webhooks
+
+A job is background work you enqueue from a hook or an action. It runs after the request commits, never inside it, and a job that throws retries with backoff up to its `retries`.
+
+```ts
+jobs: {
+ notifyEditors: job({
+ input: { postId: integer().notNull() },
+ retries: 3,
+ async handler(input, ctx) {
+ // send the email
+ },
+ }),
+},
+```
+
+Call `jobs.notifyEditors.enqueue({ postId })` to queue one. A `webhooks` entry sends each matching write to a URL as a POST request, signed in the `first-signature` header, and retries the same way. On Spacefast a due job can wait up to five minutes before it starts.
+
+## No realtime
+
+First has no live subscriptions. A client sees a change when it reads again.
+
+## Before you publish a schema change
+
+Every version of a Space shares one database, migrated forward to the newest schema. Rolling back the code does not roll back the schema. See [Database](/database#migrations).
+
+## Run it locally
+
+```bash
+sf first dev
+sf first dev stop
+sf first build
+```
+
+`sf first dev` boots WordPress through WordPress Studio, with no Docker. It needs Node 22 or newer. The first start downloads WordPress and the plugins the app needs. Keep it running and it rebuilds on every save. It prints the local URL and an admin login for local use only.
+
+`sf first build` writes the plugins to `.first/wp-content/`, new migrations to `first/_generated/migrations/`, and the client to `first/client/`. Commit `first/_generated/migrations/`, `first/client/`, and `first.gen.ts`. `.first/` is local state, and publish skips it.
+
+`sf first check` and `sf publish` use the compiler bundled with the CLI. `sf first dev` and `sf first build` need the CLI's native optional package. If it is missing, reinstall the CLI with optional dependencies enabled.
+
+## Bring a schema from another CMS
+
+```bash
+sf first import payload ../my-payload-app
+sf first import emdash ../site --output app/first.config.ts
+```
+
+`sf first import` reads a Payload, EmDash, or Sanity schema and writes a new `first.config.ts`. It never runs code from the other CMS and never overwrites a file. Anything First cannot hold refuses the import, and nothing is written. Every write rule starts as denied, so set `access` before you publish.
+
+Have a Zero app on Spacefast, one with `"runtime": { "kind": "zero" }` in `sf.jsonc`? Zero is retired. See [Zero 0.8](/zero).
+
+
+
+ Where the data lives, migrations, and the SQL console.
+
+
+ Secrets your actions read with `env()`.
+
+
+ Workers, the `functions/` file router, and OpenNext.
+
+
+ Every `sf first` command and its flags.
+
+
diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx
index c316287..64a73a0 100644
--- a/content/(dynamic)/functions.mdx
+++ b/content/(dynamic)/functions.mdx
@@ -5,9 +5,9 @@ sidebar:
order: 1
---
-Functions can detect a supported worker layout when you publish, without an explicit `runtime` block. Custom layouts need a declaration. A [Zero](/zero) app is a project with a `zero.config.ts`.
+Functions can detect a supported worker layout when you publish, without an explicit `runtime` block. Custom layouts need a declaration. A [First](/first) app is a project with a `first.config.ts`.
-Functions runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want collections, tables, and actions compiled to WordPress, with a schema Spacefast migrates for you, use [Zero](/zero) instead. Both can call `fetch()`. One version declares one runtime.
+Functions runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want collections, tables, and actions compiled to WordPress, with a schema Spacefast migrates for you, use [First](/first) instead. Both can call `fetch()`. One version declares one runtime.
Functions is on for every account.
diff --git a/content/(dynamic)/meta.ts b/content/(dynamic)/meta.ts
index b4ca900..acbafeb 100644
--- a/content/(dynamic)/meta.ts
+++ b/content/(dynamic)/meta.ts
@@ -4,5 +4,5 @@ export default defineMeta({
title: "Dynamic",
collapsed: false,
order: 5,
- pages: ["zero", "functions", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress", "zero-migrate"],
+ pages: ["first", "functions", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress", "zero"],
});
diff --git a/content/(dynamic)/object-storage.mdx b/content/(dynamic)/object-storage.mdx
index f691da0..f8b20ae 100644
--- a/content/(dynamic)/object-storage.mdx
+++ b/content/(dynamic)/object-storage.mdx
@@ -28,9 +28,9 @@ An object is addressed by a bare 32-character hexadecimal id. There is no key an
`filename` is presentation only. Path separators, control characters, `.`, and `..` are refused rather than stripped, so the value you send is the value that comes back.
-## Uploads in a Zero app
+## Uploads in a First app
-A [Zero](/zero) app handles its own uploads. Declare a `media()` column for images your content shows, or a `file()` column for private files that download through a signed link valid for five minutes.
+A [First](/first) app handles its own uploads. Declare a `media()` column for images your content shows, or a `file()` column for private files that download through a signed link valid for five minutes.
## Upload from the API
diff --git a/content/(dynamic)/wordpress.mdx b/content/(dynamic)/wordpress.mdx
index ef4e500..3a2503e 100644
--- a/content/(dynamic)/wordpress.mdx
+++ b/content/(dynamic)/wordpress.mdx
@@ -5,7 +5,7 @@ description: Every Space runs a WordPress behind its static files. Run WP-CLI ag
Every Space includes a WordPress install behind its published files. Reach it through `sf wp` or its published Abilities.
-Your published files serve in front of WordPress, so a static Space never touches it and never pays for it. It is there because it is what makes a Space more than a bucket: [Zero](/zero) apps run on it, [Storage](/object-storage) is its media library, and [Database](/database) is its MySQL. This page covers the two ways to reach that WordPress directly.
+Your published files serve in front of WordPress, so a static Space never touches it and never pays for it. It is there because it is what makes a Space more than a bucket: [First](/first) apps run on it, [Storage](/object-storage) is its media library, and [Database](/database) is its MySQL. This page covers the two ways to reach that WordPress directly.
## `sf wp`
diff --git a/content/(dynamic)/zero-migrate.mdx b/content/(dynamic)/zero-migrate.mdx
deleted file mode 100644
index 2a7e21a..0000000
--- a/content/(dynamic)/zero-migrate.mdx
+++ /dev/null
@@ -1,134 +0,0 @@
----
-title: Migrate from Zero 0.8
-description: Publishing a Zero 0.8 app moves it to the current Zero whenever nothing would be lost. When something would, it publishes as 0.8 and tells you exactly what to change.
-sidebar:
- order: 8
----
-
-Your Zero 0.8 app keeps working. Publish it with a current CLI and it moves to the current Zero, the `zero.config.ts` compiler described in [Dynamic sites with Zero](/zero), whenever nothing would be lost. When something would, it publishes as 0.8, keeps serving, and the receipt lists each blocker. New Spaces start on the current Zero.
-
-A Zero 0.8 app is one built on `@spacefast/zero`: a `capsule()` in `server/index.ts`, declared in `sf.jsonc` as `"runtime": { "kind": "zero" }`.
-
-## Keep working on a 0.8 app
-
-A 0.8 app keeps its own local dev server and database commands. The [`sf zero`](/cli/zero) development commands are for `zero.config.ts` apps, not for this one. The exception is `sf zero migrate app`, which [previews the conversion](#preview-the-conversion).
-
-### Run it locally
-
-From the 0.8 project root, `sf dev` starts the 0.8 dev server, because `sf.jsonc` declares `"runtime": { "kind": "zero" }`.
-
-```bash
-sf dev
-sf dev --state-backend sqlite
-sf dev --no-watch
-```
-
-| Flag | Default | What it does |
-| --- | --- | --- |
-| `--port=` | `4173` | Port for the dev server |
-| `--state-backend=memory\|sqlite` | `memory` | Where local state lives. `memory` resets on every start |
-| `--watch` / `--no-watch` | `--watch` | Poll source files and reload after changes |
-| `--watch-interval=` | `1000` | Polling interval in milliseconds |
-| `--dry-run` | off | Print the dev server plan without starting it |
-
-`sf dev` binds `127.0.0.1` unless you pass `--host` with `--allow-network`.
-
-### Read and migrate its database
-
-These commands read the tables the 0.8 app declares. A [Functions](/functions) worker declares none, so they answer it with `db_not_available` or `runtime_kind_mismatch`. `sf db console` works on any Space. See [Database](/database#the-sql-console).
-
-```bash
-sf db
-sf db list
-sf db dump --table todos --limit 100
-sf db export --output ./backup.json
-sf db migrate
-```
-
-| Command | What it does |
-| --- | --- |
-| `sf db [target]` | Tables, columns, and the pending migration plan |
-| `sf db list [target]` | Tables and columns. Aliased as `sf db ls` |
-| `sf db dump [target]` | Rows from every declared table. `--table` picks one, `--limit` caps rows per table (default 25, at most 100), and `--limit 0` prints row counts only |
-| `sf db export [target]` | Every declared table to one versioned JSON backup. `--output` names the file. An existing file is replaced only after the whole export succeeds |
-| `sf db migrate [source]` | Re-apply the live version's schema migrations, and preview what the source tree would change on top. `--drop` and `--rename` preview those operations without applying them |
-
-Pass `--port ` or `--local-url ` to `sf db`, `sf db list`, `sf db dump`, or `sf db export` to read a running `sf dev` server instead of the live version.
-
-## Migrate by publishing
-
-From the 0.8 project root, publish as usual:
-
-```bash
-sf publish --space spc_123 --json
-```
-
-The CLI converts the app on your machine and checks the result. Then one of two things happens:
-
-| Result | What you see |
-| --- | --- |
-| Nothing would be lost | The Space moves to the current Zero, on its existing tables. `version.zero.migration.status` is `migrated` |
-| Something would be lost | The app publishes as 0.8 and keeps serving. `status` is `refused`, and `diagnostics` lists each blocker with its `code`, `file`, `line`, `feature` and `fix` |
-
-Fix what `diagnostics` names and publish again. Nothing about your app changes until a publish migrates it. `waiting_on_runtime` means the app can move but the Space's runtime can't run the current Zero yet. It publishes as 0.8, and the next publish after the runtime update migrates it.
-
-## Preview the conversion
-
-To read the converted source before you publish, run the converter yourself. It writes a new project and never touches the Space.
-
-```bash
-sf zero migrate app --from zero-0.8 ./old-app --out ./candidate --space spc_123 --dry-run
-sf zero migrate app --from zero-0.8 ./old-app --out ./candidate --space spc_123
-sf zero check ./candidate
-```
-
-`./old-app` is the 0.8 project root, the directory with `sf.jsonc` and `server/index.ts`. `--out` must be a new directory, and a refusal writes nothing. Pass the Space's ID with `--space` so the converter binds each table to the exact MySQL table the Space already stores it in, or `--fresh` for an app that has no Space yet. Add `--users` when the Space has Space Users turned on but the capsule has no `auth` declaration.
-
-The command prints a JSON report:
-
-| Field | What it holds |
-| --- | --- |
-| `entries` | Every declaration, `mapped` or `review` |
-| `errors` | Everything the converter refused, each with an RFC 6901 pointer into your source |
-| `adoption_sql` | The SQL that checks the existing tables before first use |
-| `connector_roles` | Connector role mappings, also written to `connector-roles.json` |
-
-The exit code is `0` for a candidate or a clean dry run, `1` for a refusal, and `2` for bad arguments. Fix every `errors` pointer, then read every `review` entry. A candidate with review entries still compiles, but it may not behave the way your 0.8 app did.
-
-## What converts
-
-| Zero 0.8 | Current Zero |
-| --- | --- |
-| `capsule()` in `server/index.ts` | `defineConfig()` in `zero.config.ts` |
-| `schema` tables | `table()` entries under `tables`, bound to the existing MySQL tables |
-| `query()` | An action that reads |
-| `mutation()` | An action that writes, in one transaction |
-| `action()` | An action |
-| `ctx.db` `get`, `insert`, `update`, `delete`, and static `withIndex` chains ending in `collect`, `count`, `first`, or `take` | `db.query` and `db.insert`/`update`/`delete` |
-| `auth` | A `members` user projection with a unique email. `auth.requireSignIn` becomes a signed-in rule on each action |
-| Native `posts`, or a `content/posts` directory | A `posts` collection with title and rich-text body |
-| Connectors | `connector-roles.json`: `owner` becomes `personal`, `team` and `visitor` stay |
-
-Ported tables keep their storage. IDs stay text in action results, numbers stay doubles, and booleans match both the old `true`/`false` text and the new `1`/`0`. The converter copies no rows and renames no columns.
-
-Every write starts denied. Open `zero.config.ts` and set `access` on each collection, table, and action before you rely on it.
-
-## What you port by hand
-
-The converter refuses these, with a pointer to each:
-
-- **Endpoints.** Rewrite each one as a named action at `POST /wp-json/zero/v1/actions/`, then update its callers.
-- **Pages, layouts, and islands.** Rebuild them as [TSX pages](/zero#pages).
-- **Connector calls inside handlers.**
-- **Realtime.** Declarations are dropped with a review hint. The current Zero has no live queries, so a client reads again after a write.
-- **Pagination, dynamic query builders, and range comparisons on optional fields.**
-- **npm imports and unknown `ctx` services.**
-- **Row IDs above JavaScript's safe integer range.**
-
-The converter reads only the server. Your `client/` code uses `@spacefast/zero/client`, which has no counterpart: call the generated client in `zero/client/` from your frontend instead, or move the UI to TSX pages.
-
-## Publishing a hand-ported app
-
-Publish the 0.8 project, not a hand-edited candidate. A Space's data moves with the app: publishing a `zero.config.ts` app to a Space that still runs Zero 0.8 is refused with `zero_migration_required`, because it would leave the posts, users, and sessions behind. Once a publish has migrated the Space, edit and publish the current Zero app as usual.
-
-A project with both `"runtime": { "kind": "zero" }` in `sf.jsonc` and a `zero.config.ts` fails to publish with `zero_mixed_generation`. Delete that block if it has one.
diff --git a/content/(dynamic)/zero.mdx b/content/(dynamic)/zero.mdx
index 329f543..c5ef63c 100644
--- a/content/(dynamic)/zero.mdx
+++ b/content/(dynamic)/zero.mdx
@@ -1,272 +1,136 @@
---
-title: Dynamic sites with Zero
-description: Zero compiles one zero.config.ts (collections, tables, actions, and TSX pages) into WordPress, and Spacefast hosts it on your Space.
+title: Zero 0.8
+description: Zero is retired and replaced by First. Existing Zero Spaces keep working, and publishing a Zero app moves it to First whenever nothing would be lost.
sidebar:
- order: 0
+ order: 8
---
-Zero turns one `zero.config.ts` into a WordPress app: collections, tables, actions, access rules, and pages. `sf publish` sends the source, and Spacefast compiles it and runs it on the Space's own WordPress. Zero is available on every account, with [usage allowances and metered rates](/usage).
+Zero is retired. [First](/first) replaces it, and new apps start on First.
-## What Zero is
+Your Zero app keeps working. Its Space keeps serving, and you can keep publishing it. Publish it with a current CLI and it moves to First whenever nothing would be lost. When something would, it publishes as Zero 0.8, keeps serving, and the receipt lists each blocker. Zero 0.9 is Zero's last release apart from security fixes, and it exists to move apps to First.
-Zero is a compiler. You describe your data and your server logic in TypeScript, and Zero compiles it to WordPress plugins: a post type for each collection, a SQL table for each table, REST routes for both, and plain PHP for your hooks and actions. Nothing runs JavaScript on the server.
+A Zero app is one built on `@spacefast/zero`: a `capsule()` in `server/index.ts`, declared in `sf.jsonc` as `"runtime": { "kind": "zero" }`.
-A project is a Zero app when it has a `zero.config.ts` at its root. There is no runtime block to declare and no switch to turn on.
+## Keep working on a 0.8 app
-The other runtime is [Functions](/functions), a worker for npm-heavy code and framework output like OpenNext.
+A 0.8 app keeps its own local dev server and database commands. The [`sf first`](/cli/first) development commands are for `first.config.ts` apps, not for this one. The exception is `sf first migrate app`, which [previews the conversion](#preview-the-conversion).
-## Start an app
+### Run it locally
-```bash
-sf init my-app --runtime zero
-cd my-app
-sf zero dev
-sf publish
-```
-
-`sf init --runtime zero` writes a starter `zero.config.ts` named after the directory. `sf zero init` writes the same file. `sf zero dev` runs the app on a local WordPress and rebuilds when you save. `sf publish` publishes it to a new Space.
-
-The starter holds one collection, one table, and one action:
-
-```ts zero.config.ts
-import { action, collection, db, defineConfig, table, text, timestamp } from "zero";
-
-// Collections are WordPress posts: they show up in wp-admin and the REST API.
-export const posts = collection({
- title: text().notNull(),
- body: text(),
-}, { title: "title" });
-
-// Tables are plain MySQL tables for app data. Signups hold visitors' emails, so only the
-// signup action writes them and only admins read them.
-export const signups = table({
- email: text().notNull().unique().max(191),
- createdAt: timestamp().notNull().defaultNow(),
-}, { access: { read: "admin", create: false, update: false, delete: false } });
-
-export default defineConfig({
- name: "my-app",
- collections: { posts },
- tables: { signups },
- actions: {
- signup: action({
- input: { email: text().notNull() },
- access: "anyone",
- async handler({ email }) {
- const [row] = await db.insert(signups).values({ email: email.trim().toLowerCase() }).returning();
- return { id: row?.id ?? null };
- },
- }),
- },
-});
-```
-
-Everything comes from the `"zero"` module.
-
-## What you get
-
-| Piece | What it is |
-| --- | --- |
-| Collections | Editorial content. Each is a WordPress post type with the block editor, drafts, and revisions |
-| Tables | App data in its own SQL table |
-| REST routes | `/wp-json/zero/v1/` for every collection and table, with relations loaded through `with` |
-| Actions | Named server functions at `POST /wp-json/zero/v1/actions/` |
-| Hooks | Code that runs before or after a collection write, inside the same transaction |
-| Access rules | Who may read and write each collection, table, field, and action |
-| Sign-in | Accounts, sessions, guests, passkeys, and MFA through the `zero-identity` plugin |
-| Jobs and webhooks | Background work that runs after a request commits |
-| Pages | TSX routes and layouts that WordPress renders per request |
-| Typed client | A `fetch` client generated from your schema |
-
-Your data lives in the Space's MySQL database. See [Database](/database).
-
-## Collections and tables
-
-Pick the storage by what the data is. A `collection()` is content people edit: it gets a WordPress post type, an editor in wp-admin, drafts, and revisions. A `table()` is app data: it gets its own SQL table and nothing else.
-
-```ts zero.config.ts
-import { collection, defineConfig, defineRelations, integer, richText, table, text } from "zero";
-
-const posts = collection({ title: text().notNull(), body: richText() }, { content: "body" });
-const stock = table({
- postId: integer().references(() => posts.id, { onDelete: "cascade" }),
- quantity: integer().notNull().default(0),
-});
-
-const collections = { posts };
-const tables = { stock };
-const relations = defineRelations({ ...collections, ...tables }, (r) => ({
- stock: { post: r.one.posts({ from: r.stock.postId, to: r.posts.id }) },
- posts: { stock: r.many.stock() },
-}));
-
-export default defineConfig({ collections, tables, relations });
-```
-
-Every row gets an `id`. The registry key is the name everywhere: `shoppingList` is `zero.shoppingList` in the client and `/wp-json/zero/v1/shopping_list` over REST.
-
-Column builders include `text()`, `integer()`, `real()`, `boolean()`, `timestamp()`, `date()`, `json()`, `email()`, `url()`, `slug()`, `richText()`, `media()`, `file()`, `select()`, `group()`, `repeater()`, and `blocks()`. Chain `.notNull()`, `.default(value)`, `.unique()`, `.min(n)`, `.max(n)`, `.pattern(/re/)`, or `.references(...)` to constrain one. A modifier that makes no sense for its column, like `.max()` on a boolean, fails the build at that line.
-
-## Access rules
-
-Every collection and table takes `access: { read, create, update, delete }`. Each rule is `true`, `false`, `"anyone"`, `"authenticated"`, a role, or a function of `ctx`. Roles rank `"guest" < "subscriber" < "author" < "editor" < "admin"`, and a role admits itself and everything above it.
-
-```ts
-{ access: { read: "anyone", create: "author", update: "author", delete: "editor" } }
-```
-
-The default is read by `"anyone"`, write by `"editor"`. Actions default to `"editor"` too, so an action a visitor calls needs `access: "anyone"`. The rules hold on every path into the data: Zero's REST routes, WordPress's own `/wp/v2` routes, wp-admin, and Abilities.
-
-## Actions
-
-An action takes typed input and returns JSON. `input` uses the same column builders as your schema, so a bad request is refused before your handler runs.
+From the 0.8 project root, `sf dev` starts the 0.8 dev server, because `sf.jsonc` declares `"runtime": { "kind": "zero" }`.
```bash
-curl -X POST https://my-site.view.fast/wp-json/zero/v1/actions/signup \
- -H 'Content-Type: application/json' \
- --data '{"email":"sam@example.com"}'
+sf dev
+sf dev --state-backend sqlite
+sf dev --no-watch
```
-A successful call answers `{ "data": ... }` with whatever the handler returned. Throw `new HttpError(status, code, message)` to choose the error response. Any throw rolls back every write the request made.
+| Flag | Default | What it does |
+| --- | --- | --- |
+| `--port=` | `4173` | Port for the dev server |
+| `--state-backend=memory\|sqlite` | `memory` | Where local state lives. `memory` resets on every start |
+| `--watch` / `--no-watch` | `--watch` | Poll source files and reload after changes |
+| `--watch-interval=` | `1000` | Polling interval in milliseconds |
+| `--dry-run` | off | Print the dev server plan without starting it |
-## Hooks
+`sf dev` binds `127.0.0.1` unless you pass `--host` with `--allow-network`.
-Hooks run around collection writes: `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, and `afterDelete`. A `before` hook can change the data or throw to refuse the write.
-
-```ts
-hooks: {
- posts: {
- beforeCreate(data) {
- data.title = data.title.trim();
- if (data.title === "") throw new HttpError(422, "invalid_input", "A post needs a title");
- },
- },
-},
-```
+### Read and migrate its database
-## What compiles
-
-Hooks, actions, and access functions are a checked subset of TypeScript compiled to PHP. Functions, closures, spread, classes, `async`/`await` on Zero calls, and the usual control flow all work. `eval`, generators, `any`, and dynamic `import()` do not. Anything outside the subset fails the build at `file:line:col` with a fix:
+These commands read the tables the 0.8 app declares. A [Functions](/functions) worker declares none, so they answer it with `db_not_available` or `runtime_kind_mismatch`. `sf db console` works on any Space. See [Database](/database#the-sql-console).
```bash
-sf zero check
-sf zero check --json
+sf db
+sf db list
+sf db dump --table todos --limit 100
+sf db export --output ./backup.json
+sf db migrate
```
-Imports can be `"zero"`, your own relative modules, and installed source packages. `sf publish` captures the installed packages it needs. Spacefast never runs `npm install`.
-
-## Call it from a frontend
+| Command | What it does |
+| --- | --- |
+| `sf db [target]` | Tables, columns, and the pending migration plan |
+| `sf db list [target]` | Tables and columns. Aliased as `sf db ls` |
+| `sf db dump [target]` | Rows from every declared table. `--table` picks one, `--limit` caps rows per table (default 25, at most 100), and `--limit 0` prints row counts only |
+| `sf db export [target]` | Every declared table to one versioned JSON backup. `--output` names the file. An existing file is replaced only after the whole export succeeds |
+| `sf db migrate [source]` | Re-apply the live version's schema migrations, and preview what the source tree would change on top. `--drop` and `--rename` preview those operations without applying them |
-`sf zero build` and `sf zero dev` write a typed client to `zero/client/`. It is plain `fetch`, so it works from any framework.
+Pass `--port ` or `--local-url ` to `sf db`, `sf db list`, `sf db dump`, or `sf db export` to read a running `sf dev` server instead of the live version.
-```ts
-import { createClient } from "./zero/client/index.ts";
+## Migrate by publishing
-const zero = createClient({ url: "https://my-site.view.fast/wp-json/zero/v1" });
+From the 0.8 project root, publish as usual:
-const posts = await zero.posts.list({ orderBy: { title: "asc" } });
-await zero.actions.signup({ email: "sam@example.com" });
+```bash
+sf publish --space spc_123 --json
```
-Every argument and result is typed from your schema. A published Space also serves its client at `/__spacefast/client.js`, with types at `/__spacefast/client.d.ts`.
-
-To serve a static frontend from the same Space, put its files in `public/`. `sf publish` uploads them with the app.
+The CLI converts the app on your machine and checks the result. Then one of two things happens:
-## Pages
-
-Pages are TSX files under `pages/`. They compile at build time, and WordPress renders them on every request with the visitor's own data, through the same access rules as the REST routes.
-
-| File | Route |
+| Result | What you see |
| --- | --- |
-| `pages/page.tsx` | `/` |
-| `pages/about/page.tsx` | `/about` |
-| `pages/notes/[slug]/page.tsx` | `/notes/`, one per post in a collection, with `collectionPage("notes")` |
-| `pages/layout.tsx` | Wraps every page below it |
-| `pages/not-found.tsx` | The body of a 404 |
-
-A static route beats an item route at the same path, so `notes/featured` wins over `notes/[slug]`. WordPress's own paths, like `/wp-admin` and `/wp-json`, cannot be claimed. Every page response is `Cache-Control: private, no-store`, because its HTML depends on the visitor.
+| Nothing would be lost | The Space moves to First, on its existing tables. `version.zero.migration.status` is `migrated` |
+| Something would be lost | The app publishes as 0.8 and keeps serving. `status` is `refused`, and `diagnostics` lists each blocker with its `code`, `file`, `line`, `feature` and `fix` |
-A page reads data with a `load()` function and renders request values through helpers like `` and `orElse()`. Plain `&&` or `?:` on request data is refused at build time, with the line and the helper to use instead.
+Fix what `diagnostics` names and publish again. Nothing about your app changes until a publish migrates it. `waiting_on_runtime` means the app can move but the Space's runtime can't run First yet. It publishes as 0.8, and the next publish after the runtime update migrates it.
-## Environment variables
+## Preview the conversion
-Read a Space variable with `env()`. Set it with `sf env set`, or put it in `.env.server` and publish.
+To read the converted source before you publish, run the converter yourself. It writes a new project and never touches the Space.
-```ts
-import { action, env } from "zero";
-
-const checkout = action({
- access: "anyone",
- handler() {
- return { configured: env("STRIPE_SECRET_KEY") !== null };
- },
-});
+```bash
+sf first migrate app --from zero-0.8 ./old-app --out ./candidate --space spc_123 --dry-run
+sf first migrate app --from zero-0.8 ./old-app --out ./candidate --space spc_123
+sf first check ./candidate
```
-See [Environment variables](/environment-variables).
+`./old-app` is the 0.8 project root, the directory with `sf.jsonc` and `server/index.ts`. `--out` must be a new directory, and a refusal writes nothing. Pass the Space's ID with `--space` so the converter binds each table to the exact MySQL table the Space already stores it in, or `--fresh` for an app that has no Space yet. Add `--users` when the Space has Space Users turned on but the capsule has no `auth` declaration.
-## Jobs and webhooks
+The command prints a JSON report:
-A job is background work you enqueue from a hook or an action. It runs after the request commits, never inside it, and a job that throws retries with backoff up to its `retries`.
+| Field | What it holds |
+| --- | --- |
+| `entries` | Every declaration, `mapped` or `review` |
+| `errors` | Everything the converter refused, each with an RFC 6901 pointer into your source |
+| `adoption_sql` | The SQL that checks the existing tables before first use |
+| `connector_roles` | Connector role mappings, also written to `connector-roles.json` |
-```ts
-jobs: {
- notifyEditors: job({
- input: { postId: integer().notNull() },
- retries: 3,
- async handler(input, ctx) {
- // send the email
- },
- }),
-},
-```
+The exit code is `0` for a candidate or a clean dry run, `1` for a refusal, and `2` for bad arguments. Fix every `errors` pointer, then read every `review` entry. A candidate with review entries still compiles, but it may not behave the way your 0.8 app did.
-Call `jobs.notifyEditors.enqueue({ postId })` to queue one. A `webhooks` entry sends each matching write to a URL as a POST request, signed in the `zero-signature` header, and retries the same way. On Spacefast a due job can wait up to five minutes before it starts.
+## What converts
-## No realtime
+| Zero 0.8 | First |
+| --- | --- |
+| `capsule()` in `server/index.ts` | `defineConfig()` in `first.config.ts` |
+| `schema` tables | `table()` entries under `tables`, bound to the existing MySQL tables |
+| `query()` | An action that reads |
+| `mutation()` | An action that writes, in one transaction |
+| `action()` | An action |
+| `ctx.db` `get`, `insert`, `update`, `delete`, and static `withIndex` chains ending in `collect`, `count`, `first`, or `take` | `db.query` and `db.insert`/`update`/`delete` |
+| `auth` | A `members` user projection with a unique email. `auth.requireSignIn` becomes a signed-in rule on each action |
+| Native `posts`, or a `content/posts` directory | A `posts` collection with title and rich-text body |
+| Connectors | `connector-roles.json`: `owner` becomes `personal`, `team` and `visitor` stay |
-Zero has no live subscriptions. A client sees a change when it reads again.
+Ported tables keep their storage. IDs stay text in action results, numbers stay doubles, and booleans match both the old `true`/`false` text and the new `1`/`0`. The converter copies no rows and renames no columns.
-## Before you publish a schema change
+Every write starts denied. Open `first.config.ts` and set `access` on each collection, table, and action before you rely on it.
-Every version of a Space shares one database, migrated forward to the newest schema. Rolling back the code does not roll back the schema. See [Database](/database#migrations).
+## What you port by hand
-## Run it locally
+The converter refuses these, with a pointer to each:
-```bash
-sf zero dev
-sf zero dev stop
-sf zero build
-```
+- **Endpoints.** Rewrite each one as a named action at `POST /wp-json/first/v1/actions/`, then update its callers.
+- **Pages, layouts, and islands.** Rebuild them as [TSX pages](/first#pages).
+- **Connector calls inside handlers.**
+- **Realtime.** Declarations are dropped with a review hint. First has no live queries, so a client reads again after a write.
+- **Pagination, dynamic query builders, and range comparisons on optional fields.**
+- **npm imports and unknown `ctx` services.**
+- **Row IDs above JavaScript's safe integer range.**
-`sf zero dev` boots WordPress through WordPress Studio, with no Docker. It needs Node 22 or newer. The first start downloads WordPress and the plugins the app needs. Keep it running and it rebuilds on every save. It prints the local URL and an admin login for local use only.
+The converter reads only the server. Your `client/` code uses `@spacefast/zero/client`, which has no counterpart: call the generated client in `first/client/` from your frontend instead, or move the UI to TSX pages.
-`sf zero build` writes the plugins to `.zero/wp-content/`, new migrations to `zero/migrations/`, and the client to `zero/client/`. Commit `zero/migrations/`, `zero/client/`, and `zero.gen.ts`. `.zero/` is local state, and publish skips it.
+## Publishing a hand-ported app
-`sf zero check` and `sf publish` use the compiler bundled with the CLI. `sf zero dev` and `sf zero build` need the CLI's native optional package. If it is missing, reinstall the CLI with optional dependencies enabled.
-
-## Bring a schema from another CMS
-
-```bash
-sf zero import payload ../my-payload-app
-sf zero import emdash ../site --output app/zero.config.ts
-```
+Publish the 0.8 project, not a hand-edited candidate. A Space's data moves with the app: publishing a `first.config.ts` app to a Space that still runs Zero 0.8 is refused with `zero_migration_required`, because it would leave the posts, users, and sessions behind. Once a publish has migrated the Space, edit and publish the current Zero app as usual.
-`sf zero import` reads a Payload, EmDash, or Sanity schema and writes a new `zero.config.ts`. It never runs code from the other CMS and never overwrites a file. Anything Zero cannot hold refuses the import, and nothing is written. Every write rule starts as denied, so set `access` before you publish.
-
-Have a Zero app on Spacefast with no `zero.config.ts`? See [Migrate an older Zero app](/zero-migrate).
-
-
-
- Where the data lives, migrations, and the SQL console.
-
-
- Secrets your actions read with `env()`.
-
-
- Workers, the `functions/` file router, and OpenNext.
-
-
- Every `sf zero` command and its flags.
-
-
+A project with both `"runtime": { "kind": "zero" }` in `sf.jsonc` and a `first.config.ts` fails to publish with `zero_mixed_generation`. Delete that block if it has one.
diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx
index 3a3d8ee..64991fe 100644
--- a/content/(reference)/config-file.mdx
+++ b/content/(reference)/config-file.mdx
@@ -180,7 +180,7 @@ Data location is chosen once, when the Space is created, and never changes.
Omit the block for a static site.
-A [Zero](/zero) app needs no `runtime` block. A `zero.config.ts` at the project root is the declaration, and a project that has one must not also set `runtime.kind` to `"zero"`: publish refuses it with `zero_mixed_generation`.
+A [First](/first) app needs no `runtime` block. A `first.config.ts` at the project root is the declaration, and a project that has one must not also set `runtime.kind` to `"zero"`: publish refuses it with `zero_mixed_generation`.
### `kind: "functions"`
diff --git a/content/(reference)/glossary.mdx b/content/(reference)/glossary.mdx
index 0ce0f51..35c0308 100644
--- a/content/(reference)/glossary.mdx
+++ b/content/(reference)/glossary.mdx
@@ -23,9 +23,13 @@ Attaching an anonymous Space (one published with no account) to a real account a
The drag-and-drop publish flow at [drop.fast](https://drop.fast): drag a folder, a `.zip`, or a single `index.html` onto the window. No install, no account required. See [Publishing](/publish#publish).
+## First
+
+The compiler that turns one `first.config.ts` (collections, tables, actions, and pages) into a WordPress app, hosted on the Space with its own MySQL database. [Functions](#functions) also provides a database, and [crons](/crons) can call either runtime or static files. See [Dynamic sites with First](/first).
+
## Functions
-The runtime for shipping a worker alongside your site — npm packages, framework server output, outbound HTTP. The other option, alongside [Zero](#zero), for sites that need more than static files. See [Functions](/functions).
+The runtime for shipping a worker alongside your site — npm packages, framework server output, outbound HTTP. The other option, alongside [First](#first), for sites that need more than static files. See [Functions](/functions).
## Grant
@@ -49,4 +53,4 @@ An immutable snapshot created when a publish produces a new version. A ready ver
## Zero
-The compiler that turns one `zero.config.ts` (collections, tables, actions, and pages) into a WordPress app, hosted on the Space with its own MySQL database. [Functions](#functions) also provides a database, and [crons](/crons) can call either runtime or static files. See [Dynamic sites with Zero](/zero).
+The retired runtime that First replaced: a `capsule()` in `server/index.ts`, declared in `sf.jsonc` as `"runtime": { "kind": "zero" }`. Existing Zero Spaces keep serving and publishing. Zero 0.9 is its last release apart from security fixes, and it moves apps to First. See [Zero 0.8](/zero).
diff --git a/content/(serve)/site-pages.mdx b/content/(serve)/site-pages.mdx
index c90bf42..6430125 100644
--- a/content/(serve)/site-pages.mdx
+++ b/content/(serve)/site-pages.mdx
@@ -198,7 +198,7 @@ _pages/*.html takeovers require Plus or Enterprise. Remove the templates or upgr
-Full flag tables for `sf pages pull`, `sf pages validate`, and `sf design generate` are on the [CLI reference](/cli/zero).
+Full flag tables for `sf pages pull`, `sf pages validate`, and `sf design generate` are on the [CLI reference](/cli/first).
## What publish does with a bad page
diff --git a/content/(serve)/stats.mdx b/content/(serve)/stats.mdx
index 7b2ae56..abc359a 100644
--- a/content/(serve)/stats.mdx
+++ b/content/(serve)/stats.mdx
@@ -50,7 +50,7 @@ Every read goes straight to the edge on request. Spacefast keeps no copy, becaus
sf analytics --space docs --window 30d
```
-`--window` takes `48h`, `7d`, or `30d` and defaults to `7d`. The output prints the window, the granularity, the totals, then the per-day visitors, the top paths, and the series. Full flags and the `--json` shape are on [`sf analytics`](/cli/zero#sf-analytics).
+`--window` takes `48h`, `7d`, or `30d` and defaults to `7d`. The output prints the window, the granularity, the totals, then the per-day visitors, the top paths, and the series. Full flags and the `--json` shape are on [`sf analytics`](/cli/first#sf-analytics).
## From the API
diff --git a/content/cli/builds.mdx b/content/cli/builds.mdx
index 88aac8c..6ce720a 100644
--- a/content/cli/builds.mdx
+++ b/content/cli/builds.mdx
@@ -17,7 +17,7 @@ Build and pack project output.
sf build [DIR]
```
-Detects build settings, runs the build, and packs the output into an archive. This command talks to your filesystem, not to a Space. To compile a [Zero](/zero) app locally, use `sf zero build`.
+Detects build settings, runs the build, and packs the output into an archive. This command talks to your filesystem, not to a Space. To compile a [First](/first) app locally, use `sf first build`.
### Arguments
diff --git a/content/cli/db.mdx b/content/cli/db.mdx
index 332d12c..15f1c29 100644
--- a/content/cli/db.mdx
+++ b/content/cli/db.mdx
@@ -5,13 +5,13 @@ sidebar:
order: 22
---
-Open a single-use SQL console on a Space's database, and exercise its scheduled jobs on demand. A [Zero](/zero) app's schema lives in its `zero.config.ts` and migrates on publish. See [Database](/database).
+Open a single-use SQL console on a Space's database, and exercise its scheduled jobs on demand. A [First](/first) app's schema lives in its `first.config.ts` and migrates on publish. See [Database](/database).
Every command here takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`) and the space selection flags (`--space`, `-o`/`--team`, `--claim-token`). See [the CLI overview](/cli).
There is no connection string anywhere in the CLI or the API. `sf db console` is how you reach the database directly.
-An older Zero app that declares `"runtime": { "kind": "zero" }` in `sf.jsonc` also has `sf db`, `sf db list`, `sf db dump`, `sf db export`, and `sf db migrate`. See [Migrate an older Zero app](/zero-migrate#read-and-migrate-its-database).
+A Zero 0.8 app that declares `"runtime": { "kind": "zero" }` in `sf.jsonc` also has `sf db`, `sf db list`, `sf db dump`, `sf db export`, and `sf db migrate`. See [Zero 0.8](/zero#read-and-migrate-its-database).
## sf db console
diff --git a/content/cli/zero.mdx b/content/cli/first.mdx
similarity index 67%
rename from content/cli/zero.mdx
rename to content/cli/first.mdx
index 2a85169..b5563bd 100644
--- a/content/cli/zero.mdx
+++ b/content/cli/first.mdx
@@ -1,63 +1,63 @@
---
-title: zero, wp, runtime, pages, design, analytics
-description: Start, check, run, and build Zero apps, import a CMS schema, run WP-CLI, see what a Space is serving, and read its traffic
+title: first, wp, runtime, pages, design, analytics
+description: Start, check, run, and build First apps, import a CMS schema, run WP-CLI, see what a Space is serving, and read its traffic
sidebar:
order: 25
---
-`sf zero` builds and runs [Zero](/zero) apps: a `zero.config.ts` compiled to WordPress. The rest of this page covers WP-CLI, runtime status, site pages, design tokens, and analytics.
+`sf first` builds and runs [First](/first) apps: a `first.config.ts` compiled to WordPress. The rest of this page covers WP-CLI, runtime status, site pages, design tokens, and analytics.
Every command here takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`). `sf wp`, `sf runtime status`, and `sf analytics` also take the space selection flags (`--space`, `-o`/`--team`, `--claim-token`). See [the CLI overview](/cli).
-To publish a Zero app, run `sf publish` from its root. See [Dynamic sites with Zero](/zero).
+To publish a First app, run `sf publish` from its root. See [Dynamic sites with First](/first).
-## sf zero init
+## sf first init
-Start a Zero app: a starter `zero.config.ts` with one collection, one table, and one action, named after the directory.
+Start a First app: a starter `first.config.ts` with one collection, one table, and one action, named after the directory.
```text
-sf zero init [name] [-f]
+sf first init [name] [-f]
```
| Argument or flag | What it does |
| --- | --- |
| `[name]` | Directory to create. Omit to initialize the current directory |
-| `-f, --force` | Overwrite an existing `zero.config.ts` |
+| `-f, --force` | Overwrite an existing `first.config.ts` |
-It also adds `.zero/` and `node_modules/` to `.gitignore`. `sf init --runtime zero` writes the same starter.
+It also adds `.first/` and `node_modules/` to `.gitignore`. `sf init --runtime first` writes the same starter.
```bash
-sf zero init my-app
-sf zero init
+sf first init my-app
+sf first init
```
-## sf zero check
+## sf first check
-Compile `zero.config.ts` and report diagnostics without writing anything.
+Compile `first.config.ts` and report diagnostics without writing anything.
```text
-sf zero check [project] [--json]
+sf first check [project] [--json]
```
-Each diagnostic names a `file:line:col` and a fix. `--json` prints the diagnostics envelope instead of text. `sf zero check` uses the compiler bundled with the CLI, so it needs no native download.
+Each diagnostic names a `file:line:col` and a fix. `--json` prints the diagnostics envelope instead of text. `sf first check` uses the compiler bundled with the CLI, so it needs no native download.
```bash
-sf zero check
-sf zero check ./app --json
+sf first check
+sf first check ./app --json
```
-## sf zero dev
+## sf first dev
Run the app on a local WordPress and rebuild when its source changes.
```text
-sf zero dev [project] [--port ] [--no-watch] [--allow-destructive]
-sf zero dev stop [project]
-sf zero dev status [project]
-sf zero dev wp [project] --
+sf first dev [project] [--port ] [--no-watch] [--allow-destructive]
+sf first dev stop [project]
+sf first dev status [project]
+sf first dev wp [project] --
```
-`sf zero dev` boots WordPress through WordPress Studio, with no Docker. It needs Node 22 or newer, and the first start downloads WordPress and the plugins the app needs. It watches TypeScript, config, and `public/` files. Ctrl-C stops WordPress and keeps the local database for the next start.
+`sf first dev` boots WordPress through WordPress Studio, with no Docker. It needs Node 22 or newer, and the first start downloads WordPress and the plugins the app needs. It watches TypeScript, config, and `public/` files. Ctrl-C stops WordPress and keeps the local database for the next start.
| Argument or flag | What it does |
| --- | --- |
@@ -71,73 +71,73 @@ sf zero dev wp [project] --
The login it prints is for local development only.
```bash
-sf zero dev
-sf zero dev --no-watch
-sf zero dev wp -- user application-password create admin local --porcelain
-sf zero dev stop
+sf first dev
+sf first dev --no-watch
+sf first dev wp -- user application-password create admin local --porcelain
+sf first dev stop
```
-## sf zero build
+## sf first build
-Compile `zero.config.ts` into WordPress plugins.
+Compile `first.config.ts` into WordPress plugins.
```text
-sf zero build [project] [--out ]
+sf first build [project] [--out ]
```
-It writes the plugins to `.zero/wp-content/`, new migrations to `zero/migrations/`, the typed client to `zero/client/`, and editor types to `zero.gen.ts`. Commit everything except `.zero/`.
+It writes the plugins to `.first/wp-content/`, new migrations to `first/_generated/migrations/`, the typed client to `first/client/`, and editor types to `first.gen.ts`. Commit everything except `.first/`.
```bash
-sf zero build
-sf zero build ./app --out ./dist
+sf first build
+sf first build ./app --out ./dist
```
-`sf zero dev` and `sf zero build` run the native compiler from the CLI's optional platform package. If it's missing, reinstall the CLI with optional dependencies enabled. Set `SPACEFAST_ZERO_BIN` to a `zero` binary to use your own build instead.
+`sf first dev` and `sf first build` run the native compiler from the CLI's optional platform package. If it's missing, reinstall the CLI with optional dependencies enabled. Set `SPACEFAST_FIRST_BIN` to a `first` binary to use your own build instead.
-## sf zero import
+## sf first import
-Translate a Payload, EmDash, or Sanity schema into a new `zero.config.ts`, check that it compiles, and print what mapped and what did not. Fully local, no space flags.
+Translate a Payload, EmDash, or Sanity schema into a new `first.config.ts`, check that it compiles, and print what mapped and what did not. Fully local, no space flags.
```text
-sf zero import [--output ]
+sf first import [--output ]
```
| Argument or flag | What it does |
| --- | --- |
| `` | `payload`, `emdash`, or `sanity`. `payloadcms` is accepted for `payload` |
| `` | The schema file, or the project directory to find it in (`payload.config.ts`, `.emdash/seed.json`, `sanity.config.ts`) |
-| `--output=` | Config file to write. Defaults to `zero.config.ts`, and it must not exist yet |
+| `--output=` | Config file to write. Defaults to `first.config.ts`, and it must not exist yet |
-It never runs code from the other CMS and never overwrites a file. Anything Zero cannot hold refuses the whole import, and nothing is written. Every write rule in the result starts as denied, so set `access` before you publish.
+It never runs code from the other CMS and never overwrites a file. Anything First cannot hold refuses the whole import, and nothing is written. Every write rule in the result starts as denied, so set `access` before you publish.
```bash
-sf zero import payload ../my-payload-app
-sf zero import emdash ../site --output app/zero.config.ts
+sf first import payload ../my-payload-app
+sf first import emdash ../site --output app/first.config.ts
```
### Output
One line per schema entry: its outcome, its JSON pointer, and the reason. With `--json`, `data` carries `source`, `from`, `out`, and `report.entries[]`.
-## sf zero migrate
+## sf first migrate
-Bring a schema, a site's content, or an older Zero app into a `zero.config.ts` project. Everything after `migrate` passes to the Zero compiler unchanged.
+Bring a schema, a site's content, or a Zero 0.8 app into a `first.config.ts` project. Everything after `migrate` passes to the First compiler unchanged.
```text
-sf zero migrate config --from --out
-sf zero migrate site [--dry-run]
-sf zero migrate app ...
+sf first migrate config --from --out
+sf first migrate site [--dry-run]
+sf first migrate app ...
```
| Subcommand | What it does |
| --- | --- |
-| `config` | The same schema import as [`sf zero import`](#sf-zero-import), with its own flags |
-| `site` | Moves an older Zero site's content into the app's collections on a standalone WordPress, through WP-CLI. It does not touch a Space. `--dry-run` previews it. Needs the native compiler, like `sf zero build` |
-| `app` | Converts an older Zero app's source. See [Migrate an older Zero app](/zero-migrate) |
+| `config` | The same schema import as [`sf first import`](#sf-first-import), with its own flags |
+| `site` | Moves a Zero 0.8 site's content into the app's collections on a standalone WordPress, through WP-CLI. It does not touch a Space. `--dry-run` previews it. Needs the native compiler, like `sf first build` |
+| `app` | Converts a Zero 0.8 app's source. See [Zero 0.8](/zero) |
```bash
-sf zero migrate config --from payload payload.config.ts --out app/zero.config.ts
-sf zero migrate site content-model.php --dry-run
+sf first migrate config --from payload payload.config.ts --out app/first.config.ts
+sf first migrate site content-model.php --dry-run
```
## sf wp
diff --git a/content/cli/meta.ts b/content/cli/meta.ts
index 8d9eb55..c6dea7b 100644
--- a/content/cli/meta.ts
+++ b/content/cli/meta.ts
@@ -16,7 +16,7 @@ export default defineMeta({
"storage",
"git",
"source",
- "zero",
+ "first",
"share",
"teams",
"api-keys",
diff --git a/content/cli/project.mdx b/content/cli/project.mdx
index d6fb435..fab4a70 100644
--- a/content/cli/project.mdx
+++ b/content/cli/project.mdx
@@ -16,7 +16,7 @@ Every command here also takes the [global flags](/cli#global-flags).
Create a Spacefast project.
```text
-sf init [NAME] [--space ] [-o ] [-f] [--runtime static|zero|functions]
+sf init [NAME] [--space ] [-o ] [-f] [--runtime static|first|functions]
```
### Arguments
@@ -30,7 +30,7 @@ sf init [NAME] [--space ] [-o ] [-f] [--runtime static|zero|functio
| Flag | Default | What it does |
| --- | --- | --- |
| `-f, --force` | `false` | Overwrite existing generated files. |
-| `--runtime=` | `static` | Project to scaffold. `zero` writes a starter [Zero](/zero) `zero.config.ts`, the same one `sf zero init` writes. |
+| `--runtime=` | `static` | Project to scaffold. `first` writes a starter [First](/first) `first.config.ts`, the same one `sf first init` writes. |
| `--space=` | none | Space ID, live URL, domain, or slug to link the directory to. Only with `--runtime static`. Env `SPACEFAST_SPACE`. |
| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
| `--claim-token=` | none | Space key (`sfc_…`) for an unclaimed anonymous Space. Env `SPACEFAST_CLAIM_TOKEN`. |
@@ -41,7 +41,7 @@ sf init [NAME] [--space ] [-o ] [-f] [--runtime static|zero|functio
sf init
sf init my-site
sf init --space docs
-sf init my-app --runtime zero
+sf init my-app --runtime first
```
### What it writes
diff --git a/content/cli/publish.mdx b/content/cli/publish.mdx
index cdd183a..37249ca 100644
--- a/content/cli/publish.mdx
+++ b/content/cli/publish.mdx
@@ -295,7 +295,7 @@ Start the local dev server.
sf dev
```
-In a Pages project, `sf dev` serves a Pages preview with sample data and the publish-time expander at `/_spacefast/pages/`. It is not a local mirror of what publish serves, and there is no static file server for your site on that path. For a [Zero](/zero) app, run `sf zero dev`. An older Zero app that declares `"runtime": { "kind": "zero" }` in `sf.jsonc` gets its own Zero 0.8 dev server from `sf dev` instead of a Pages preview. See [Migrate an older Zero app](/zero-migrate#keep-working-on-a-08-app). For a framework project, use that framework's own dev server (`next dev`, `vite`), then run `sf publish` when you're ready.
+In a Pages project, `sf dev` serves a Pages preview with sample data and the publish-time expander at `/_spacefast/pages/`. It is not a local mirror of what publish serves, and there is no static file server for your site on that path. For a [First](/first) app, run `sf first dev`. A Zero 0.8 app, one that declares `"runtime": { "kind": "zero" }` in `sf.jsonc`, gets its own Zero 0.8 dev server from `sf dev` instead of a Pages preview. See [Zero 0.8](/zero#keep-working-on-a-08-app). For a framework project, use that framework's own dev server (`next dev`, `vite`), then run `sf publish` when you're ready.
| Flag | Default | What it does |
| --- | --- | --- |
diff --git a/content/index.mdx b/content/index.mdx
index 40ccab2..3a00d3f 100644
--- a/content/index.mdx
+++ b/content/index.mdx
@@ -41,7 +41,7 @@ Publish this folder to Spacefast and give me the live URL.
A **Space** holds a site and its **versions**, immutable snapshots with their own URLs. A publish with no changes can reuse the current version. The `live` channel points at the version visitors see, or is empty before the first publish. Promoting or rolling back to a retained, ready version moves that pointer without rebuilding. Preview publishes and manual promotion policies leave `live` alone. (New terms piling up? See the [glossary](/glossary).)
-Static output is the default path. Point `sf publish` at a build directory and it uploads only the files that changed. If you'd rather publish source, Spacefast detects the framework and builds it. Sites that need a database, server functions, or scheduled jobs add a [Zero](/zero) `zero.config.ts` or a [Functions](/functions) worker.
+Static output is the default path. Point `sf publish` at a build directory and it uploads only the files that changed. If you'd rather publish source, Spacefast detects the framework and builds it. Sites that need a database, server functions, or scheduled jobs add a [First](/first) `first.config.ts` or a [Functions](/functions) worker.
In a self-serve account, your team owns the Spaces you claim, even when you are its only member. On [Plus](/billing#seats), you can add members who publish and roll back without sharing your login. Free and Go allow only the owner. Team roles control who can manage domains, credentials, and billing. See [Teams and members](/teams) to invite people and choose how new Spaces are shared.
diff --git a/content/infrastructure/server-specs-settings.mdx b/content/infrastructure/server-specs-settings.mdx
index 9184891..7e3c553 100644
--- a/content/infrastructure/server-specs-settings.mdx
+++ b/content/infrastructure/server-specs-settings.mdx
@@ -25,7 +25,7 @@ For application access to WordPress, use the documented [WordPress commands and
| --- | --- |
| Redirects, rewrites, and response headers | Publish `_redirects`, `_headers`, or the supported [routing configuration](/routing). |
| Application environment variables | Use [Environment variables](/environment-variables). |
-| Runtime configuration | Use the documented [Functions runtime settings](/functions) or [Zero app configuration](/zero). |
+| Runtime configuration | Use the documented [Functions runtime settings](/functions) or [First app configuration](/first). |
| Storage and resource entitlements | Check [Limits](/limits). |
Spacefast rejects authored `.htaccess` and `.user.ini` files. You cannot replace the managed server configuration or `php.ini` through a publish. Use the supported configuration for the behavior you need.
diff --git a/content/quickstart.mdx b/content/quickstart.mdx
index a5b4f63..4fe04f5 100644
--- a/content/quickstart.mdx
+++ b/content/quickstart.mdx
@@ -107,8 +107,8 @@ Working with other people? If your Space is still anonymous, [claim it first](/a
Push to a remote or connect GitHub and let Spacefast build.
-
- Add collections, tables, and actions with Zero.
+
+ Add collections, tables, and actions with First.
`CI=1` switches the CLI to JSON output and no prompts.
diff --git a/evals.yaml b/evals.yaml
index f31f735..3108de7 100644
--- a/evals.yaml
+++ b/evals.yaml
@@ -68,10 +68,10 @@ questions:
question: What is the project config file called?
expected: ["sf.jsonc"]
routes: /config-file
- - id: zero-declare
- question: How do I turn on the Zero runtime for a project?
- expected: ["a zero.config.ts at the project root", "no runtime block in sf.jsonc"]
- routes: /zero
+ - id: first-declare
+ question: How do I turn on the First runtime for a project?
+ expected: ["a first.config.ts at the project root", "no runtime block in sf.jsonc"]
+ routes: /first
- id: routing-precedence
question: If both a _redirects file and sf.jsonc rules match a path, which wins?
expected: ["_redirects"]
diff --git a/scripts/docs-experience.test.mjs b/scripts/docs-experience.test.mjs
index dfbecd8..c48f143 100644
--- a/scripts/docs-experience.test.mjs
+++ b/scripts/docs-experience.test.mjs
@@ -60,19 +60,19 @@ test("unfamiliar homepage terms have a linked glossary with definitions", async
const home = await builtPage("/");
const glossary = await builtPage("/glossary");
assert.match(home, /\[glossary\]\(\/docs\/glossary\/\)/iu);
- for (const term of ["Ability", "Grant", "Live", "Space", "Version", "Zero"]) {
+ for (const term of ["Ability", "First", "Grant", "Live", "Space", "Version", "Zero"]) {
assert.match(glossary, new RegExp(`^## ${term}(?:\\b| \\()`, "mu"), `${term} needs a definition`);
}
});
-test("Database gives the Zero prerequisite before query instructions", async () => {
+test("Database gives the First prerequisite before query instructions", async () => {
const database = await builtPage("/database");
const prerequisite = database.indexOf("Don't have a database yet?");
const query = database.indexOf("## Query it from code");
assert.ok(prerequisite >= 0 && query > prerequisite);
- assert.match(database.slice(prerequisite, query), /\[Zero\]\(\/docs\/zero\/\)/u);
- assert.match(database.slice(prerequisite, query), /sf init --runtime zero/u);
- assert.match(database.slice(prerequisite, query), /`zero\.config\.ts`/u);
+ assert.match(database.slice(prerequisite, query), /\[First\]\(\/docs\/first\/\)/u);
+ assert.match(database.slice(prerequisite, query), /sf init --runtime first/u);
+ assert.match(database.slice(prerequisite, query), /`first\.config\.ts`/u);
});
test("Troubleshooting gives a first diagnostic step before the error catalog", async () => {
diff --git a/scripts/rename-to-first.mjs b/scripts/rename-to-first.mjs
new file mode 100644
index 0000000..94bcfbc
--- /dev/null
+++ b/scripts/rename-to-first.mjs
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+// Renames Zero v2 to First across the authored docs. Zero 0.8 keeps the name
+// "Zero". Idempotent: running it again on its own output changes nothing, so
+// an open branch takes the rename by merging main and running it once more.
+//
+// Generated snapshots (generated/**) are producer-owned and change only when
+// the exporter re-runs against a renamed CLI and API.
+import { readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+const root = process.cwd();
+
+// 1. File moves, in order. The v2 page and its CLI page become /first and
+// /cli/first; then the Zero 0.8 page takes over /zero, which documents Zero.
+// A move runs only while its source exists and its target does not.
+const moves = [
+ ["content/(dynamic)/zero.mdx", "content/(dynamic)/first.mdx"],
+ ["content/cli/zero.mdx", "content/cli/first.mdx"],
+ ["content/(dynamic)/zero-migrate.mdx", "content/(dynamic)/zero.mdx"],
+];
+const zero08Page = path.join("content", "(dynamic)", "zero.mdx");
+
+// 2. Names that must survive the text pass untouched, because Zero 0.8 or the
+// production API and engine already ship them. Each is swapped for a token
+// before the replacements and restored after.
+const kept = [
+ // Zero 0.8: its runtime declaration, packages, converter flag and page.
+ '"runtime": { "kind": "zero" }',
+ 'runtime: { kind: "zero"',
+ '`runtime.kind` to `"zero"`',
+ "@spacefast/zero/client",
+ "@spacefast/zero",
+ "--from zero-0.8",
+ // Links to the Zero 0.8 page at /zero; longer entries before shorter ones.
+ "Zero 0.8](/zero",
+ "Zero 0.8",
+ "Zero 0.9",
+ "older Zero app",
+ "older Zero site",
+ // Copy that names Zero as the retired runtime, written by hand after the
+ // first run; protected so a re-run leaves it alone.
+ "formerly Zero v2",
+ "Have a Zero app",
+ "Zero is retired",
+ "## Zero\n",
+ "Existing Zero Spaces",
+ "Zero's last release",
+ "Your Zero app",
+ "A Zero app is one",
+ "publishing a Zero app",
+ // Shipped in production: API operations and paths, Abilities, storage.
+ "createSpaceZeroToken",
+ "listSpaceZeroAbilities",
+ "listspacezeroabilities",
+ "/zero/token",
+ "zero/storage-",
+ "zero-storage",
+ "sfz_",
+ "`ZERO_`",
+ "version.zero.migration",
+ // Error codes. They keep their names; the reference pages are generated.
+ "zero_mixed_generation",
+ "zero_migration_required",
+ // Ordinary English and unrelated terms.
+ "nonzero",
+ "non-zero",
+ "zero-dependency",
+ "zeros",
+ "zero or more",
+ "zero or",
+ "at zero",
+ "position zero",
+ "exited zero",
+ "hit zero",
+ "a zero ",
+ "zero operations",
+ "Zero alerts",
+ "zero 404",
+];
+
+// 3. Replacements for v2 names, most specific first. Every left side is gone
+// after one run, which keeps the script idempotent.
+const replacements = [
+ // Routes.
+ ["(/zero)", "(/first)"],
+ ["(/zero#", "(/first#"],
+ ['href="/zero"', 'href="/first"'],
+ ["(/cli/zero)", "(/cli/first)"],
+ ["(/cli/zero#", "(/cli/first#"],
+ ['href="/cli/zero"', 'href="/cli/first"'],
+ ["#sf-zero-", "#sf-first-"],
+ // Navigation entries in meta.ts.
+ ['["zero", "functions"', '["first", "functions"'],
+ ['"wordpress", "zero-migrate"]', '"wordpress", "zero"]'],
+ [' "zero",\n', ' "first",\n'],
+ // Files, folders and wire names of a First app.
+ ["zero.config.ts", "first.config.ts"],
+ ["zero.gen.ts", "first.gen.ts"],
+ [".zero/", ".first/"],
+ ["zero/_generated/", "first/_generated/"],
+ ["zero/migrations/", "first/_generated/migrations/"],
+ ["zero/client/", "first/client/"],
+ ["/wp-json/zero/v1", "/wp-json/first/v1"],
+ ["zero-identity", "first-identity"],
+ ["zero-signature", "first-signature"],
+ ["SPACEFAST_ZERO_BIN", "SPACEFAST_FIRST_BIN"],
+ ['from "zero"', 'from "first"'],
+ ['`"zero"` module', '`"first"` module'],
+ ['Imports can be `"zero"`', 'Imports can be `"first"`'],
+ ["const zero = createClient", "const first = createClient"],
+ ["await zero.", "await first."],
+ ["`zero.shoppingList`", "`first.shoppingList`"],
+ ["a `zero` binary", "a `first` binary"],
+ // Commands.
+ ["sf zero ", "sf first "],
+ ["sf zero`", "sf first`"],
+ ["sf zero\n", "sf first\n"],
+ ["--runtime zero", "--runtime first"],
+ ["static|zero|functions", "static|first|functions"],
+ ["static\\|zero\\|functions", "static\\|first\\|functions"],
+ ["`zero` writes a starter", "`first` writes a starter"],
+ ["Zero compiler", "First compiler"],
+ // Product name in prose, after every 0.8 phrase is protected above.
+ ["Zero v2", "First"],
+ ["Zero's", "First's"],
+ ["Zero", "First"],
+];
+
+// Authored files only; never generated/, .blume/ or dist/.
+// The pricing fixture mirrors the API's pricing document; its labels follow the API.
+const roots = ["content", "README.md", "evals.yaml", "redirects.ts"];
+const textFile = /\.(mdx?|ts|mjs|json|ya?ml)$/u;
+
+async function exists(file) {
+ try {
+ await stat(file);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// Build overlays materialized from generated/ (see AGENTS.md); never authored.
+const overlays = [
+ "content/cli/reference.md",
+ "content/(reference)/errors",
+ "content/(reference)/changelog",
+ "content/setup",
+ "content/_pricing",
+];
+
+async function files(entry) {
+ const full = path.join(root, entry);
+ const info = await stat(full);
+ if (info.isFile()) return [full];
+ const out = [];
+ for (const child of await readdir(full, { withFileTypes: true })) {
+ const childPath = path.join(entry, child.name);
+ if (overlays.includes(childPath)) continue;
+ if (child.isDirectory()) out.push(...(await files(childPath)));
+ else if (textFile.test(child.name)) out.push(path.join(root, childPath));
+ }
+ return out;
+}
+
+// Links to the Zero 0.8 page, rewritten before any name is protected: the page
+// moved from /zero-migrate to /zero, and its title is now "Zero 0.8".
+const prepass = [["[Migrate an older Zero app](/zero-migrate", "[Zero 0.8](/zero"]];
+
+function renameText(source, file) {
+ // The Zero 0.8 page names the old runtime throughout; only its links to the
+ // First pages and First's own names change there.
+ const isZero08Page = file === path.join(root, zero08Page);
+ const tokens = [];
+ let text = source;
+ for (const [from, to] of prepass) text = text.split(from).join(to);
+ const protect = (value) => {
+ text = text.split(value).join(`\u0000${tokens.length}\u0000`);
+ tokens.push(value);
+ };
+ for (const value of kept) protect(value);
+ if (isZero08Page) {
+ for (const value of ["Zero app", "Zero apps", "a Zero", "Zero's", "Zero 0.9", "Zero."]) protect(value);
+ }
+ for (const [from, to] of replacements) {
+ if (isZero08Page && (from === "Zero" || from === "Zero's")) continue;
+ text = text.split(from).join(to);
+ }
+ return text.replace(/\u0000(\d+)\u0000/gu, (_, index) => tokens[Number(index)]);
+}
+
+let moved = 0;
+for (const [from, to] of moves) {
+ if ((await exists(path.join(root, from))) && !(await exists(path.join(root, to)))) {
+ await rename(path.join(root, from), path.join(root, to));
+ moved += 1;
+ }
+}
+
+let changed = 0;
+for (const entry of roots) {
+ if (!(await exists(path.join(root, entry)))) continue;
+ for (const file of await files(entry)) {
+ const before = await readFile(file, "utf8");
+ const after = renameText(before, file);
+ if (after !== before) {
+ await writeFile(file, after);
+ changed += 1;
+ }
+ }
+}
+
+console.log(`rename-to-first: moved ${moved} file(s), rewrote ${changed} file(s).`);
diff --git a/scripts/verify-routes.mjs b/scripts/verify-routes.mjs
index 526130d..fb2071f 100644
--- a/scripts/verify-routes.mjs
+++ b/scripts/verify-routes.mjs
@@ -149,7 +149,7 @@ const representativePages = [
["errors/index.html", `${docsRoot}/errors`, "Error reference"],
["errors/rate_limited/index.html", `${docsRoot}/errors/rate_limited`, "rate_limited"],
["recipes/vite/index.html", `${docsRoot}/recipes/vite`, "Vite"],
- ["zero/index.html", `${docsRoot}/zero`, "What Zero is"],
+ ["first/index.html", `${docsRoot}/first`, "What First is"],
];
for (const page of representativePages) {
await requirePage(...page);