Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/(dynamic)/crons.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-runtime) endpoint, 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 [Zero](/zero) 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.

Expand Down
199 changes: 75 additions & 124 deletions content/(dynamic)/database.mdx
Original file line number Diff line number Diff line change
@@ -1,69 +1,90 @@
---
title: Database
description: Every Zero Space gets its own MySQL. Declare tables in your capsule, migrate on publish, read rows with sf db, and open a single-use SQL console when you need one.
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.
sidebar:
order: 2
---

There's no connection string for this database — your code reaches it through `ctx.db` or `env.DB`, or through a single-use SQL console when you need raw SQL.
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.

:::note[Don't have a database yet?]
For [Zero](/zero-runtime), start with `sf init --runtime zero`, or add the full [runtime block](/zero-runtime#declare-it) to `sf.jsonc`, including `kind: "zero"`, `server`, and `client` entry paths. The server file is required; Zero can generate a client shell. Publish the app before querying its live database. A [Functions](/functions#what-is-on-env) worker also gets a database through `env.DB`, but the Zero table commands do not apply to it.
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`.
:::

## What it is

Every Space that runs [Zero](/zero-runtime) gets one database, the MySQL that lives on the machine serving the site. A [Functions](/functions) worker gets one too, as `env.DB`.
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`.

You do not create it, size it, or connect to it. Your capsule declares the tables. The platform applies the migration when a version finalizes.
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, and none is coming. Your code reaches the database through `ctx.db` (Zero) or `env.DB` (Functions). For everything else there is the SQL console below.
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).
:::

## Query it from code

In a Zero capsule the schema you declared is the API.
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.

```ts server/index.ts
import { capsule, mutation, query, string, table } from "@spacefast/zero/server";
```ts zero.config.ts
import { action, db, defineConfig, HttpError, integer, table, text } from "zero";

export default capsule({
name: "notes",
schema: {
notes: table({ body: string(), ownerId: string() }).index("by_owner", ["ownerId"]),
// Each signed-in account reads and writes only its own rows, through every route.
const notes = table(
{
body: text().notNull(),
ownerId: integer().notNull(),
},
queries: {
recent: query(async (ctx) =>
ctx.db.notes
.withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
.order("desc")
.take(20),
),
{
access: {
read: ({ user }) => (user ? { ownerId: user.id } : false),
create: false,
update: false,
delete: false,
},
},
mutations: {
add: mutation(async (ctx, body: string) => {
await ctx.db.notes.insert({ body, ownerId: ctx.auth.userId });
ctx.invalidate("recent");
);

export default defineConfig({
tables: { notes },
actions: {
recent: action({
input: {},
access: "authenticated",
async handler(_input, ctx) {
if (!ctx.user) throw new HttpError(401, "unauthenticated", "Sign in first");
return await db.query.notes.findMany({
where: { ownerId: ctx.user.id },
orderBy: { id: "desc" },
limit: 20,
});
},
}),
add: action({
input: { body: text().notNull() },
access: "authenticated",
async handler({ body }, ctx) {
if (!ctx.user) throw new HttpError(401, "unauthenticated", "Sign in first");
const [row] = await db.insert(notes).values({ body, ownerId: ctx.user.id }).returning();
return row;
},
}),
},
});
```

| Call | Available in | What it does |
| --- | --- | --- |
| `get(id)` | queries and mutations | One row by id, or `null` |
| `withIndex(index, range?)` | queries and mutations | A query builder over a declared index |
| `.order("asc" \| "desc")` | builder | Direction |
| `.collect()` | builder | Every matching row |
| `.take(n)` | builder | The first `n` |
| `.first()` | builder | One row or `null` |
| `.paginate({ cursor, numItems })` | builder | `{ page, continueCursor, isDone }` |
| `insert(value)` | mutations and write endpoints | Returns the new row |
| `update(id, patch)` | mutations and write endpoints | Returns the row or `null` |
| `delete(id)` | mutations and write endpoints | Returns a boolean |

Index ranges take `eq`, `gt`, `gte`, `lt`, and `lte`. Every call is async, so await all of them.
The owner comes from `ctx.user`, the signed-in caller, never from `input`. Anything in `input` is whatever the client sent, so an `ownerId` there would let any signed-in account read or write another user's notes. The table's `access` rule enforces the same thing on the generated REST routes, which skip your actions. Without it a table is readable by anyone. Writes go only through `add`, so the table refuses direct writes.

| Call | What it does |
| --- | --- |
| `db.query.<name>.findMany({ where, orderBy, limit, offset, columns, with })` | Matching rows |
| `db.query.<name>.findFirst({ where, ... })` | One row, or `null` |
| `db.insert(table).values(row \| rows).returning()` | Inserts, and returns the new rows |
| `db.update(table).set(patch).where(filter).returning()` | Updates, and returns the changed rows |
| `db.delete(table).where(filter).returning()` | Deletes, and returns the removed rows |

`where` is an object filter: `{ col: value }`, or `{ col: { eq, ne, gt, gte, lt, lte, in, notIn, like, isNull, isNotNull } }`, with top-level `OR`, `AND`, and `NOT`. A relation key filters through the relation: `{ author: { name: "Alice" } }`. `orderBy` is `{ col: "asc" | "desc" }`. Await every call.

An action's writes run in one transaction. A throw anywhere rolls all of them back.

A Functions worker gets a D1-shaped binding instead.

Expand All @@ -76,124 +97,54 @@ export default {
};
```

## Inspect the schema

```bash
sf db
```

`sf db` reads the live version's declared tables, its migration plan, and whether that plan has been applied. It takes an optional target, so `sf db docs` reads the space `docs` without `--space`. A space id, slug, live URL, or custom domain all work.

```text
Backend mysql
Schema hash sha256:1f9c…
Tables notes, sessions
Migrations 0 pending
```

`plan.applied` can come back `null`. That means the control plane has no application record and refuses to guess, not that something failed.

To read a locally running capsule instead of the live version, point at `sf dev`.

```bash
sf db --port 4173
sf db --local-url http://127.0.0.1:4173
```

`--local-url` accepts `localhost`, `127.0.0.1`, or `::1` only. A refused port exits `40`.

## Read rows

```bash
sf db dump
sf db dump --table notes --limit 100
```
## Migrations

`--limit` is 1 to 100 and defaults to 25. `--table` must be a declared table name matching `/^[A-Za-z_][A-Za-z0-9_]*$/`. Omit it to dump every declared table. The dump reads through the space's live runtime, so it needs the app to be reachable.
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.

## Back up everything
To see the SQL a change produces, build locally and read the newest file in `zero/migrations/`:

```bash
sf db export
sf db export docs --out ./docs-backup.json
sf zero build
```

`sf db export` walks every declared table in stable keyset pages of 500 rows and writes one JSON file. Without `--out` the file is `spacefast-backup-<space>-<timestamp>.json`.

The write is atomic and mode `0600`. If the export fails part-way, an existing destination is left exactly as it was.

## Migrations
:::warning[Rollback does not roll back the schema]
Every version of a Space shares one database, migrated forward to the newest schema. Rolling back to older code keeps the newer tables and columns, so make schema changes the older code can live with.
:::

Your capsule's tables are the schema. Publishing a version that changes them plans a migration, and the platform applies it at finalize. `sf db migrate` applies the live plan again, or previews what your source tree would change on top of it.
To rename a collection or table without moving its data, keep the old physical name with `storageName`:

```bash
sf db migrate
sf db migrate --drop
sf db migrate --rename
```ts
const stockItems = table(fields, { storageName: "stock_items" });
```

<Steps>
<Step title="Change the schema">
Edit `table(...)` and `.index(...)` in your server entry.
</Step>
<Step title="See the plan">
Run `sf db migrate` from the project directory. It computes the plan locally, prints the operations, and asks before applying.
</Step>
<Step title="Opt in to anything destructive">
Drops and renames are refused unless you pass `--drop` or `--rename`. The two are mutually exclusive.
</Step>
</Steps>

| Operation | Needs a flag |
| --- | --- |
| `create_table`, `add_column`, `add_index` | no |
| `drop_table`, `drop_column`, `drop_index` | `--drop` |
| `rename_table`, `rename_column` | `--rename` |

`sf db migrate` decides Zero-ness from your local `sf.jsonc` before it contacts the API. A static or Functions project fails with `runtime_kind_mismatch` and exit code `30`, having sent no request. `--previous-artifact` diffs against a named artifact instead of the live schema.

`sf db`, `sf db dump`, and `sf db migrate` are Zero-only. A worker declares no table set, so they answer `runtime_kind_mismatch` for one.
A build that drops one collection or table while adding another is refused as an ambiguous replacement. Rename with `storageName` instead, and remove storage in its own change.

## The SQL console

When you need raw SQL, the dashboard mints a single-use phpMyAdmin sign-in URL.

Open **Database** for the space and click **Open db console**. The page shows **Backend**, **Migrations**, a **Database tables** list, and per-table **Columns** and **Indexes**.

From the CLI:
When you need raw SQL, the CLI mints a single-use phpMyAdmin sign-in URL.

```bash
sf db console
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.

The console is also how you reach a Functions worker's tables, since the schema routes do not apply to one.
`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.

Until the space runs a Zero app or a worker, the Database page shows **Database isn't enabled**.
A Zero app's tables share a prefix that starts with `sfz_`.

## Limits

| Limit | Value |
| --- | --- |
| Databases per Space | 1 |
| `sf db dump --limit` | 1 to 100, default 25 |
| Export page size | 500 rows, fixed |
| Field types you can declare | `string()`, `boolean()`, `id("table")` |
| Reserved column names | `id`, `createdAt`, `updatedAt` |
| Reserved index name | `by_creation` |
| Console URL | Single-use |

## API

| Operation | Route |
| --- | --- |
| [`getSpaceDatabase`](/api/reference/database/getspacedatabase) | `GET /v1/spaces/{spaceId}/db` |
| [`getSpaceDatabaseDump`](/api/reference/database/getspacedatabasedump) | `GET /v1/spaces/{spaceId}/db/dump` |
| [`getSpaceDatabaseExport`](/api/reference/database/getspacedatabaseexport) | `GET /v1/spaces/{spaceId}/db/export` |
| [`migrateSpaceDatabase`](/api/reference/database/migratespacedatabase) | `POST /v1/spaces/{spaceId}/db/migrate` |
| `openSpaceDatabaseConsole` | `POST /v1/spaces/{spaceId}/db/console` |

## Next steps

Use [CLI database commands](/cli/db) to inspect tables or export data.
See [CLI database commands](/cli/db) for `sf db console` and crons.
28 changes: 15 additions & 13 deletions content/(dynamic)/environment-variables.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Environment variables
description: Set variables on a Space or a team, keep secrets write-only, substitute them into files at build, and read them from a handler as ctx.env
description: Set variables on a Space or a team, keep secrets write-only, substitute them into files at build, and read them from server code at runtime
sidebar:
order: 3
---
Expand Down Expand Up @@ -81,7 +81,7 @@ sf env set API_URL https://api.example.com --branch-value beta=https://beta.exam

`--branch-value` takes `branch=value` and repeats. An empty branch or an empty value is a validation error.

### Runtime, `ctx.env`
### Runtime, `env()`

Server-only values belong in `.env.server` next to your project. `sf publish` reads that file, syncs every key into the Space's variables **as secrets**, and never uploads the file itself. The shared publish denylist covers `.env.*`, so the bytes stay on your machine.

Expand All @@ -92,24 +92,26 @@ EOF
sf publish
```

A Zero handler reads it from `ctx.env`.
A [Zero](/zero) action or hook reads it with `env()`, which returns `null` for a name that isn't set.

```ts server/index.ts
import { capsule, endpoint, json } from "@spacefast/zero/server";
```ts zero.config.ts
import { action, defineConfig, env } from "zero";

export default capsule({
name: "billing",
endpoints: {
charge: endpoint({ mode: "write", method: "POST", path: "/api/charge" }, (ctx) =>
json({ configured: Boolean(ctx.env.STRIPE_SECRET_KEY) }),
),
export default defineConfig({
actions: {
billingStatus: action({
access: "editor",
handler() {
return { configured: env("STRIPE_SECRET_KEY") !== null };
},
}),
},
});
```

A [Functions](/functions) worker reads the same values from `env`, its second handler argument.
A Zero app sees **Space** variables only. Team variables do not reach it, so set what the app needs on the Space.

`.env.lakebed.server` is accepted as an alias for capsule portability.
A [Functions](/functions) worker reads the same values from `env`, its second handler argument.

An anonymous first publish, before you have an account, syncs nothing and reports `skipped: "anonymous"`.

Expand Down
4 changes: 2 additions & 2 deletions content/(dynamic)/functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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, and [Zero](/zero-runtime#declare-it) is always declared explicitly.
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 runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want live queries in the browser and a schema the platform migrates for you, use [Zero](/zero-runtime) 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 [Zero](/zero) instead. Both can call `fetch()`. One version declares one runtime.

Functions is on for every account.

Expand Down
6 changes: 3 additions & 3 deletions content/(dynamic)/logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The first positional is a target, which can be a Space id, slug, live URL, or cu
| `--follow`, `-f` | off | Polls every 2 seconds until Ctrl-C |
| `--cursor` | none | Continues from the cursor the previous page printed |
| `--request-id` | none | Runtime only. Everything logged while serving one request |
| `--handler` | none | Runtime only. Lines from one Zero mutation or Functions handler |
| `--handler` | none | Runtime only. Lines from one Functions handler |

`--request-id` and `--handler` on the access log are refused rather than ignored, with `Flags --request-id and --handler only apply to \`sf logs runtime\`.`

Expand All @@ -59,12 +59,12 @@ Runtime entries carry more.
| `timestamp` | When the line was written |
| `level` | `debug`, `info`, `warn`, or `error` |
| `requestId` | Nullable. Pass it to `--request-id` |
| `handlerName` | Nullable. Zero names the mutation, Functions names the handler |
| `handlerName` | Nullable. The Functions handler that wrote the line |
| `versionId` | Nullable |
| `message` | What you logged |
| `metadata` | The data bag you passed |

In a Zero capsule, `ctx.log.info(message, data)` writes here, as do `warn` and `error`. A static Space returns an empty runtime stream, because it runs no code.
A [Functions](/functions) worker's console output lands here. A static Space returns an empty runtime stream, because it runs no code.

## Two things that surprise people

Expand Down
2 changes: 1 addition & 1 deletion content/(dynamic)/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ export default defineMeta({
title: "Dynamic",
collapsed: false,
order: 5,
pages: ["zero-runtime", "functions", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
pages: ["zero", "functions", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress", "zero-migrate"],
});
Loading
Loading