# Store app data (/docs/build/storage)



When Domino hosts your app, the app can keep data of its own: giveaway entries, a list of event check-ins, a cache of rendered pages. Your developer names the stores the app needs, and Domino creates them in test and in live. Your team can browse what's in them from Console.

* **Test and live are separate.** Test data never reaches live, and live gets its own empty stores when you go live.
* **Your team can look, and clear test data.** Console shows the data read-only. In test, your developer can empty tables or delete keys to start a test run over. Live data changes only through your app's code.

> [!DEV]
> A hosted Worker app can keep its own data in Cloudflare KV namespaces and D1 SQL databases. You declare stores by name in `relay.json`; Domino creates them in each environment that runs the app and binds them to every build there. Staged builds get test's stores, and the live app gets live's. Your server code uses Cloudflare's native binding APIs, so there is no Domino wrapper to learn.

```mermaid
flowchart LR
    Stage["domino stage"] --> Provision["Create or reuse stores"]
    Provision --> Migrate["Apply new SQL migrations"]
    Migrate --> Upload["Upload the Worker with bindings"]
    Upload --> Active["Staging serves the new build"]
```

> [!NOTE] Separate data per environment, Worker apps only
> Test and live each have their own stores. Live's are created when a build that declares them is promoted to live, and data never copies between environments. Static apps cannot declare stores. Storage needs `@domino-sdk/relay` and `@domino-sdk/relay-cli` 1.2.0 or later: older versions reject `app.storage`.

## Add a store [#add-a-store]

### 1. Declare it in relay.json [#1-declare-it-in-relayjson]

Add a `storage` property to the `app` object of a Worker app. `kv` declares KV namespaces and `sql` declares D1 databases, each by its binding name.

```json
{
  "app": {
    "kind": "worker",
    "storage": {
      "kv": { "CACHE": { "description": "Rendered page cache" } },
      "sql": {
        "DB": { "description": "Campaign data", "migrations": "migrations" }
      }
    }
  }
}
```

* **Names, not IDs.** Domino creates and tracks the Cloudflare resources. Never put a Cloudflare resource ID in `relay.json`.
* **Migrations are optional.** `migrations` names a directory of `.sql` files, relative to the repository root. Only SQL stores have one.
* **Descriptions show in Console** and in `domino storage list`.

### 2. Type the bindings [#2-type-the-bindings]

Your Worker receives `env.CACHE` as a `KVNamespace` and `env.DB` as a `D1Database`. Add `@cloudflare/workers-types` as a dev dependency and import the types into `server/env.ts`:

```ts
import type { D1Database, KVNamespace } from "@cloudflare/workers-types/index.ts"

export interface Env {
  CACHE: KVNamespace
  DB: D1Database
}
```

Import from `index.ts` instead of adding the package to `types` in `tsconfig.json`. The starters include the DOM library, which conflicts with the package's global declarations, and the build runs your check script before it stages anything.

If your tests build `Env` objects, as the API starter's do, they stop type-checking once these bindings are required. Give the tests stub bindings, or declare the bindings optional (`CACHE?: KVNamespace`) and answer 503 from routes that need them when they are missing.

### 3. Mirror the bindings for local development [#3-mirror-the-bindings-for-local-development]

`domino dev` runs your app on your device, where the Cloudflare Vite plugin reads `wrangler.jsonc`, not `relay.json`. Declare the same binding names there:

```jsonc
{
  "kv_namespaces": [{ "binding": "CACHE", "id": "local-cache" }],
  "d1_databases": [{
    "binding": "DB",
    "database_name": "local-db",
    "database_id": "local-db",
    "migrations_dir": "migrations"
  }]
}
```

The IDs are local placeholders, and local data stays on your device, separate from hosted test data. `domino dev` does not apply migrations, so run them yourself after adding a file:

```sh
pnpm exec wrangler d1 migrations apply DB --local
```

`domino dev` and `domino stage` warn when `wrangler.json` or `wrangler.jsonc` and `relay.json` disagree on binding names or migration directories, or when Wrangler uses `migrations_pattern`. The warnings never stop the command.

### 4. Stage [#4-stage]

Commit, push, and [stage](/docs/build/staging) the app. The build creates any new store, applies new migrations, and binds every declared store before staging serves the new build. The store appears in `domino storage list` and Console once that build is active.

When a promotion carrying the build is approved, the live deployment does the same against live: it creates live's stores, applies migrations to live's databases, and only then switches the live app to the build. See [How test and live hosting relate](/docs/concepts/hosting#how-test-and-live-hosting-relate).

## Write migrations [#write-migrations]

Hosted builds track migrations the way Wrangler does: in a `d1_migrations` table, ordered by numeric filename prefix and then filename. The same files therefore work with `wrangler d1 migrations apply --local`.

* **One file per change.** Never edit or rename a file that has been applied; add a new one.
* **Flat directory.** Put `.sql` files directly in the migrations directory. Nested layouts such as Drizzle's `0000_name/migration.sql` fail the build; point Drizzle at a flat output directory.
* **Each file is atomic.** A file runs as one D1 request together with its tracking row, so a failing file rolls back on its own and files before it stay applied. There is no whole-build rollback.

> [!WARNING] Migrations run while the previous build still serves traffic
> The builder applies migrations before the new Worker activates. Every migration must work with the build that is serving now. Dropping a column or changing what it means can break the serving staging or live app before the new build replaces it.

### When a migration fails [#when-a-migration-fails]

A failed migration fails the build without retrying, and the previous build keeps serving. In live, it fails the live deployment and the previous live build keeps serving; fix the file, then stage and promote again. The error names the store, the file, and D1's own message:

```text
SQL store DB migration 0003_add_score.sql failed: near "CREAT": syntax error at offset 0: SQLITE_ERROR
```

Fix the file and stage again. If D1 itself was unavailable, the build retries on its own, and a file whose response was lost after it committed is never applied twice.

| Message                                              | Cause                                                                                 | Fix                                                                  |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `… failed: <D1 message>`                             | The file's SQL is invalid or conflicts with the database                              | Fix the file and stage again                                         |
| `… ran but did not record itself`                    | The file ends inside an unterminated `/* …` comment, which swallowed the tracking row | Close the comment and stage again                                    |
| `Migrations for DB must be .sql files directly in …` | The directory holds folders instead of `.sql` files                                   | Flatten the directory                                                |
| `Migrations directory … does not exist`              | `migrations` names a missing path                                                     | Fix the path in `relay.json`                                         |
| `This project can own at most 10 SQL stores …`       | The project already owns 10 stores of that kind                                       | Reuse a name, or [delete an unused store](#remove-or-delete-a-store) |

## Browse your data [#browse-your-data]

Open **Settings → Storage** in Console with **Test** or **Live** selected. The list shows every store in that environment, its description, and whether the serving build still declares it.

* **SQL stores.** **Browse** opens the first app table 50 rows at a time. Pick another table or view from the picker, and select a row to see every column. Under a cut value, **Show full value** reads it in full, 100,000 characters at a time.
* **KV stores.** **Browse** lists keys 100 at a time. Search by key prefix, and select a key to see its value.

Browsing is read-only and needs `publish`, because an app's data can hold what its members told it. People with only `read` see the list of stores. Rows and KV values cut text longer than 2,000 characters; read a cut SQL value in full with a cell read. BLOBs and binary values show only their size.

The CLI and management API offer the same reads. `domino storage` uses test unless you pass `--environment live`; it ignores `RELAY_ENVIRONMENT` and the environment in `relay.json`, and warns when either names live. Its `--json` result says which environment it used in `environment`.

| Read                  | CLI                                                    | Management API                                                                     |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Stores                | `domino storage list`                                  | `GET /management/v1/storage` (`read`)                                              |
| SQL tables and views  | `domino storage tables DB`                             | `GET /management/v1/storage/sql/DB/tables`                                         |
| 50 rows               | `domino storage rows DB entries --cursor <nextCursor>` | `GET /management/v1/storage/sql/DB/rows?table=entries&cursor=…`                    |
| One SQL value in full | `domino storage cell DB entries 51 body --all`         | `GET /management/v1/storage/sql/DB/cell?table=entries&row=51&column=body&cursor=…` |
| 100 KV keys           | `domino storage keys CACHE --prefix page:`             | `GET /management/v1/storage/kv/CACHE/keys?prefix=page:&cursor=…`                   |
| A KV value            | `domino storage get CACHE page:/`                      | `GET /management/v1/storage/kv/CACHE/value?key=page:/`                             |

> [!DEV] Reading from code
> `createManagementClient` from `@domino-sdk/relay/server` has the same reads under `storage`: `list()`, `tables(name)`, `rows(name, table, { cursor })`, `cell(name, table, { row, column, cursor })`, `keys(name, { prefix, cursor })`, and `value(name, key)`. Each page of rows, keys or a value's characters has a `nextCursor`: pass it back as `cursor` (`--cursor` in the CLI) for the next page, until it is `null`. Leave `cursor` out for the first page. A cursor is opaque: pass back the one Domino returned rather than building your own.

> [!DEV] Reading one value in full
> A cell read names the row by its position in the table's storage order: the rows page's `offset` plus the row's index on that page, so row 51 is the second row of the page at offset 50. Rows written in between can shift that order, as they shift pages. Each page holds up to 100,000 characters from `offset`, with `type` (SQLite's `text`, `integer`, `real`, `blob` or `null`) and `length` in characters. A BLOB answers with its `length` in bytes and `text: null`. `--all` follows `nextCursor` and prints the whole value as one page.

Every row a read touches counts toward the database's D1 usage, including the row count of the open table. Apart from [clearing test data](#clear-test-data), Domino has no write or export: change data with migrations or your own server code.

## Clear test data [#clear-test-data]

Resetting or erasing test members removes only Domino's records of them. What your app stored about them stays: a ticket mapped to a member, an uploaded photo, a check-in. When a test run leaves such data behind, clear it in the test environment so the next run starts clean. Clearing never changes the schema, and live data can't be cleared.

1. **Count first.** `domino storage clear sql DB --table tickets --dry-run` answers how many rows each table holds, and changes nothing.
2. **Clear.** Run the same command without `--dry-run`. In a terminal it shows the counts and asks first. Unattended or with `--json`, it needs `--yes`.

| Clear                       | CLI                                                            |
| --------------------------- | -------------------------------------------------------------- |
| Named SQL tables            | `domino storage clear sql DB --table tickets --table progress` |
| Every SQL table except some | `domino storage clear sql DB --all-tables --keep settings`     |
| KV keys under a prefix      | `domino storage clear kv PHOTOS --prefix photo:`               |
| Every KV key                | `domino storage clear kv PHOTOS --all-keys`                    |

* **Rows, not tables.** A SQL clear deletes the rows of each table and keeps its columns, indexes, triggers and views. `d1_migrations`, which records your applied migrations, and SQLite's and D1's own tables (`sqlite_*`, `_cf_*`) are never cleared, even with `--all-tables`. A virtual table, such as an FTS5 full-text index, is emptied through itself, which keeps its internal shadow tables consistent; the shadow tables can't be named. Name a table exactly as it is spelled; an unknown table, including one given to `--keep`, answers 404 and nothing is cleared.
* **All or nothing.** The tables of one SQL clear empty in one transaction. If a table you keep still references rows you clear, the whole clear fails and nothing changes; a foreign key with `ON DELETE CASCADE` deletes the referencing rows instead, as any delete would.
* **Large KV stores.** The CLI deletes keys in batches of 10,000 until none remain under the prefix. A dry run counts up to 100,000 keys and says "at least" beyond that.
* **Test only.** In live, every clear, dry runs included, is refused with `test-environment-required`. It needs `publish`, like deleting a store, and works on a store the serving build still declares. Each clear is recorded in the project's audit history with the tables and row counts, or the prefix and key count; a dry run is not.

> [!DEV]
> The management API is `POST /management/v1/storage/sql/DB/clear` with `{ "tables": ["tickets"] }`, or `{ "allTables": true, "keepTables": ["settings"] }`, and `POST /management/v1/storage/kv/PHOTOS/clear` with `{ "prefix": "photo:" }` or `{ "all": true }`. Add `"dryRun": true` to count only. A SQL clear answers `{ environment, kind, store, dryRun, tables: [{ name, rows }] }`, the row counts before the clear. A KV clear answers `{ environment, kind, store, dryRun, prefix, keys, complete }`: one call deletes at most 10,000 keys, so call again until `complete` is `true`. On a dry run, `complete: false` means `keys` stopped counting at 100,000. In code, `createManagementClient` has the same operation as `storage.clear("sql", "DB", { tables: ["tickets"] })` and `storage.clear("kv", "PHOTOS", { prefix: "photo:" })`. A refused name or flag combination answers 400, live 422 `test-environment-required`, and a missing store or table 404. Every row a clear counts or deletes counts toward D1 usage.

## Remove or delete a store [#remove-or-delete-a-store]

* **Remove a declaration** from `relay.json` and stage. The next build no longer binds the store, but its data stays, and declaring the same kind and name again reuses it. Live keeps binding it until a build without it is promoted.
* **Rename** by declaring a new name. That creates an empty store; the old one stays until you delete it.
* **Delete** a store the serving build no longer declares, with all of its data, from Console's **Delete** action, `domino storage delete sql OLD_DB`, or `DELETE /management/v1/storage/sql/OLD_DB`. It needs `publish`. Add `--environment live` to delete a live store; a CLI sign-in session cannot delete in live, so use Console or an API key there. A declared store, or one in a project with a build still running, answers 409. Declaring a deleted name again creates an empty store.

Organization transfers keep stores. Purging a deleted project deletes them; deleting a project only starts its restore window.

## Limits [#limits]

| Rule                | Limit or behavior                                                                                                          |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Stores per build    | Up to 5 KV namespaces and 5 SQL databases                                                                                  |
| Stores per project  | Up to 10 of each kind, counting stores kept after their declaration was removed                                            |
| Names               | 1–64 uppercase letters, digits, or underscores, not starting with a digit                                                  |
| Reserved names      | `ASSETS` and every name beginning with `DOMINO_`                                                                           |
| Uniqueness          | A name appears once across `vars`, declared secrets, KV, and SQL; a stored secret with the same name also blocks the build |
| Migration directory | Relative to the repository root; no hidden segments, parent traversal, or `node_modules`                                   |
| Migration files     | Up to 100 per store, 90,000 UTF-8 bytes each, 1,000,000 bytes across the build                                             |
| Statement duration  | D1 stops any single statement after 30 seconds, including a migration's                                                    |

Every build ships every migration file, so the file and size limits cover a store's whole history. When a long-lived store nears them, squash applied migrations into one file. Every hosted and local database must already have applied the files you squash.

## Where next [#where-next]

- [Configure a hosted app](/docs/build/hosted-app) — routing, variables, secrets, and the management key your server code receives.
- [Stage and share](/docs/build/staging) — build a pushed commit into the staging app.
- [Hosted apps](/docs/concepts/hosting) — how previews, staging, and the live app fit together.
