Store app data

Give your hosted app its own storage for things like giveaway entries or a cache, and browse it in Console.

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.

Add a store

Declare it in relay.json

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.

{
  "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.

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:

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.

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:

{
  "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:

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.

Stage

Commit, push, and stage 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.

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.

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:

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.

MessageCauseFix
… failed: <D1 message>The file's SQL is invalid or conflicts with the databaseFix the file and stage again
… ran but did not record itselfThe file ends inside an unterminated /* … comment, which swallowed the tracking rowClose the comment and stage again
Migrations for DB must be .sql files directly in …The directory holds folders instead of .sql filesFlatten the directory
Migrations directory … does not existmigrations names a missing pathFix the path in relay.json
This project can own at most 10 SQL stores …The project already owns 10 stores of that kindReuse a name, or delete an unused store

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.

ReadCLIManagement API
Storesdomino storage listGET /management/v1/storage (read)
SQL tables and viewsdomino storage tables DBGET /management/v1/storage/sql/DB/tables
50 rowsdomino storage rows DB entries --cursor <nextCursor>GET /management/v1/storage/sql/DB/rows?table=entries&cursor=…
One SQL value in fulldomino storage cell DB entries 51 body --allGET /management/v1/storage/sql/DB/cell?table=entries&row=51&column=body&cursor=…
100 KV keysdomino storage keys CACHE --prefix page:GET /management/v1/storage/kv/CACHE/keys?prefix=page:&cursor=…
A KV valuedomino storage get CACHE page:/GET /management/v1/storage/kv/CACHE/value?key=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, Domino has no write or export: change data with migrations or your own server code.

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.
ClearCLI
Named SQL tablesdomino storage clear sql DB --table tickets --table progress
Every SQL table except somedomino storage clear sql DB --all-tables --keep settings
KV keys under a prefixdomino storage clear kv PHOTOS --prefix photo:
Every KV keydomino 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.

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

RuleLimit or behavior
Stores per buildUp to 5 KV namespaces and 5 SQL databases
Stores per projectUp to 10 of each kind, counting stores kept after their declaration was removed
Names1–64 uppercase letters, digits, or underscores, not starting with a digit
Reserved namesASSETS and every name beginning with DOMINO_
UniquenessA name appears once across vars, declared secrets, KV, and SQL; a stored secret with the same name also blocks the build
Migration directoryRelative to the repository root; no hidden segments, parent traversal, or node_modules
Migration filesUp to 100 per store, 90,000 UTF-8 bytes each, 1,000,000 bytes across the build
Statement durationD1 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

On this page