Skip to main content
A Site can keep its own data: RSVPs, guestbook entries, a team’s goals. You declare its tables in db/schema.json, and your code reads and writes them through fluid.db. The browser never sends SQL and never holds a database credential. Each request goes to your site’s host, which checks it against your schema and your table rules before it touches a row. This page covers db/schema.json and fluid.db. The older fluid member-site database commands belong to a separate backend approach. Use db/schema.json and fluid sites publish for the database described here.
Site databases are available to companies Fluid has turned Sites on for. For any other company, publishing answers 404. To ask for them, contact support.

Create a database for your site

You don’t turn a database on. The first publish of a site whose folder has a db/schema.json creates the site’s database and applies the schema, before the new version goes live. A new site published without db/schema.json has no database, and fluid.db answers no_database. A newly created database is empty. Removing the file from a later build does not turn an existing database off. Follow these steps in order.
1

Go to the site's folder

Open a terminal in the folder that holds package.json, config/ and .member-site.json. If the folder has no .member-site.json, bind it first. See Before you start.
2

Create db/schema.json

Make a db folder in the site’s folder, next to config/ and src/, and put the schema in it as db/schema.json. The file name and the folder name must be exactly these. Don’t put it in dist/ or src/. A publish reads it from db/, and it is never served as a file.This schema is complete. It keeps the events a site lists and the RSVPs people leave for them. Copy it as it is, then change the names to fit your site:
db/schema.json
  • events can be read by anyone, including a guest. Only the site’s owner can add, change or delete an event. On a company site, that means a company admin.
  • rsvps can be read and added by any signed-in visitor. A visitor changes or deletes only the rows they wrote.
  • Each visitor can leave one RSVP for each event: unique is ["event_id", "created_by"].
  • indexes and unique are optional.
3

Check the schema

Build the app before running lint, because lint also checks your build output. The command makes no request and needs no login. It checks db/schema.json against the same rules a publish checks. A clean folder prints nothing and exits 0. Each problem prints as an error that names its file and exits 1. Fix each one and run it again.
4

Build and publish

Add --as member for a member’s site, or --as admin for a company site. A successful publish prints lines like these, with your own numbers:
The CLI prints no separate line about the database. The command exits 0 and prints Published member site <handle>. (or company site) only after the schema was applied and the new version went live. With --json it prints one object, {"ok":true,"site":"diamond-leaders","bundle":"...","manifestSha256":"..."}. If the command fails, check the error before retrying. A timeout can leave the outcome unknown, and a schema change can succeed before the new build becomes current. See What the answers mean.
5

Use it from the site's code

Import fluid from @fluid-app/sites and read and write the tables. See Query a table.
6

Check that it works

Sign in on the published site as its owner and add an events row from your code. Then a company admin can see the row on the site’s page. See Review and delete rows. The site’s owner can also download everything. See Download the data.
Later publishes compare db/schema.json with what the database holds. See Change the schema.

Declare the tables

db/schema.json is one JSON object. This section lists everything it can hold. A key that isn’t listed here is refused, at every level. A table is an object with these keys: A column is an object with these keys:

Names

A table name and a column name must match ^[a-z][a-z0-9_]{0,62}$: a lowercase letter first, then lowercase letters, digits and underscores, 63 characters at most. A name can’t start with pg_. A column can’t take a system column’s name. The names in indexes and unique are columns you declared, or created_by, created_at, updated_at or id. The whole schema is at most 1 MiB as compact UTF-8 JSON. A JSON value in a column default or migration fill can nest at most 1,000 levels.

Column types

One request’s body is at most 1 MiB (see Limits), so a row you write through fluid.db is smaller than that. A json column compares as a whole value: filter it with eq, neq, in or is only, and don’t order by it.

What the schema check refuses

  • A version that isn’t 1, or a key that isn’t listed above.
  • A column type that isn’t one of the eight, such as float, string or datetime.
  • A table or column name that has a capital letter, starts with _, a digit or pg_, or is longer than 63 characters.
  • A column named id, created_by, created_at or updated_at.
  • A rule that isn’t public, signed_in, creator, owner or nobody, and creator as the insert rule.
  • A default that isn’t a value of the column’s type, such as 5 for a text column, a date of "2026-02-30", or null on a required column.
  • An index or unique set that names a column the table doesn’t have, has no column, or has more than 3.
  • More than 32 tables, 64 columns in a table, 8 indexes or 4 unique sets in a table.

System columns

Every table has four columns you don’t declare. Your code can read them but never write them.

Choose who reads and writes

A rule applies to one table and one operation: read, insert, update or delete. An operation with no rule gets its default: So with no rules, signed-in visitors can add rows and change their own, and only the owner reads them. Set read to signed_in or public when visitors should see each other’s rows.
Your site’s code runs with each visitor’s own access. Whatever it writes to a table, whoever its read rule admits can read, created_by included. Don’t copy a visitor’s private data, such as their orders, into a table other people can read.

Query a table

Import the SDK

Importing @fluid-app/sites also puts fluid on window.fluid. fluid.db has one method, from(table: string), which starts a query on one of your tables. Await the query to send one request. It resolves { data, error }, and count when you ask for it, whatever the answer. HTTP errors resolve in error, and a request that can’t reach the site’s host resolves with the unavailable error. Invalid query construction can still throw, as described below. These types come from the same package: DbError, DbResponseV1, QueryBuilder, Row and Value.

The query methods

Each method returns the query, so you chain them. These are all of them: Row is an object of column names and values. Value is a string, a number, a boolean, null or any JSON value. Pass uuid, date and timestamp values as strings. The builder throws a TypeError, before it sends anything, when the calls can’t make a request: update or delete with no filter, insert with a filter, an order, limit or range on a write, or a select() that follows a select(). fluid.db leaves out the Supabase methods this table doesn’t list, such as upsert, rpc, textSearch and joins in select. They throw a NotSupportedError that names the method.

Read rows

  • select() or select("*") reads every declared column, then the system columns.
  • select("name,guests", { count: "exact" }) also returns count, the number of rows that match, whatever the limit.
  • limit(n) takes at most 1,000 rows. With no limit, a select returns 100.
  • range(0, 49) reads the first 50 rows, and range(50, 99) the next 50.
  • A row of a select holds the columns you asked for. A uuid or date is a string, and an integer past 2^53 is a string.
To read one row:

Write rows

  • insert takes one row or an array of up to 500. Leave out id, created_by, created_at and updated_at: the site’s host sets them.
  • update sets at most 100 values together with the filter’s values.
  • update and delete need a filter that picks rows, to limit the rows they change. A broad filter can still match every row, so use a row id when you mean to change just one.
  • Add .select() after a write to get the written rows back in data.
  • A creator rule adds “rows this visitor wrote” to the filter for you, so an update on another person’s row changes nothing.

Filter

Handle errors

error is null on success. Otherwise it has a code, a message, and sometimes details. Check error after every call:

Limits

What happens at a limit:
  • A request limit refuses the request with quota_exceeded. The count restarts each minute, so try again when the minute ends.
  • The storage and row limits refuse an insert or an update with quota_exceeded. Reads and deletes still work, so you can free space by deleting rows.
  • The size figures lag a little. A site’s rows and storage are measured on a schedule, so a size limit can take effect shortly after the database reaches it.
  • The per-visitor limit is fixed. The site’s request, row and storage limits can be raised for your company. A company admin with the application_themes.update permission raises them with Replace the company’s member site policy, in databaseLimits. Read the company’s member site policy shows what is set. A limit left out, or null, goes back to its default.

Change the schema

Edit db/schema.json and publish again. Each publish compares your new file with the schema the site’s database holds, and applies the difference before the new version goes live.

Add to the schema

An additive change needs no flag. This file is the first example with a location column added to events and a new announcements table. Every table of the schema stays in the file:
db/schema.json
Run the same command as before:

Make a destructive change

A destructive change needs a migration in migrations, with an id unique in the site, 1 to 100 characters. Keep every other key of every table as it was: a rule you leave out goes back to its default, a table you leave out is refused with validation unless a confirmed drop_table migration names it, and changing a rule is applied with no confirmation. This file keeps the additions from Add to the schema. It renames guests to party_size and drops note:
db/schema.json
A migration is a list of steps, run in order. Each step has an op and these fields, spelled exactly: A step can’t name a system column. Confirm each migration by its id when you publish:
Repeat --confirm for each migration. Without it, the publish fails with destructive_change_unconfirmed, lists each unconfirmed migration and its steps, and changes nothing. A destructive change that no migration names fails with validation, and the error names the table and column. A refused schema plan or a failed schema transaction leaves the schema unchanged. A successful schema change can remain even if a later step of publishing fails; the old build may still be current. See Finish an interrupted publish. How migrations run:
  • A migration runs once. A later publish with the same migration doesn’t run it again, so you can leave it in db/schema.json.
  • The first publish skips them. A new database is created in its final form, so there is nothing to change and nothing to confirm.
  • A drop or a rename takes its indexes with it. A migration that drops or renames a column also drops the indexes and unique sets that list it. Declare the ones you still want in the new schema, under the column’s new name.
  • The data goes with a drop. Dropping a table or a column deletes its rows or values, and a rename keeps them. Check what a step removes before you confirm it.

Finish an interrupted publish

A publish that runs out of time while it applies a schema change may have applied it. Until that publish is finished, the next publish is refused with 409 and schema_change_pending, and nothing changes. The answer names the pending bundle in details.pendingBundle. fluid sites publish handles it for you:
  • In a terminal, it asks whether to publish the pending bundle again to finish it. A bare Enter says yes.
  • With --finish-pending, it finishes the pending bundle without asking.
  • With --json, or with no terminal, and no flag, it prints the command to run and sends nothing.
Finishing the pending bundle makes that bundle the current version. Your own build isn’t published by that run. Run fluid sites publish again to publish it. Finishing the pending bundle uses its existing upload. Publishing your own build afterwards may upload files it still needs. If the pending bundle holds a destructive migration, finishing it is refused with destructive_change_unconfirmed, and the command lists the pending bundle’s own migration ids. Confirm them with --confirm-pending:
--confirm never goes to the pending bundle. A migration id is unique only within one schema, so an id you confirmed for this build could match a different migration in the pending one. Check the pending bundle’s schema before you confirm.

Go back to an earlier version

You can’t make an earlier version current when it has a db/schema.json. Make an earlier bundle current answers no_database and changes nothing. To go back, change your folder and publish again. The rules above still apply to the schema you publish.

Develop locally

fluid sites dev gives the site a private database on your machine, .fluid/dev.sqlite in the site’s folder, and answers every fluid.db call from it. The same schema check and table rules apply as on the live site. See Develop a Site locally for how to run it.
  • Local and live rows are separate. Rows you write locally never reach the live site, and the live site’s rows never appear locally. Publishing doesn’t send local rows, and no command uploads them.
  • Additive changes apply on save. Add a table or a column, and the local database has it as soon as you save db/schema.json.
  • A destructive change asks first, locally too. A migration that drops, renames or retypes data waits until you confirm it. At a terminal, the command lists the migration’s steps and asks. Otherwise it prints the command to restart with. Until you confirm, the local database keeps its schema and its rows. In Mist, the preview shows the change with an Apply and restart button, and confirming it changes only your local copy. Publishing asks again before the live database changes. See Make a destructive change.
  • What publish refuses is refused locally. A dropped table or column with no migration step that names it is refused, and so is a file that isn’t a valid schema. The local database keeps the schema it has until you fix the file.
  • Rows carry a stand-in owner. Locally, rows written by the default viewer carry created_by local-owner, which isn’t your real user id. Run with --viewer guest to check what a signed-out visitor may read and write.
  • Reset when you need a clean start. fluid sites dev --reset-db deletes the local rows and creates the tables again from db/schema.json.
In Mist, the Databases view and chat read the local database as the site’s local side. They only read it.

Branches

A branch of a site that has a database gets a database of its own, with a copy of the parent’s tables and rows. You make the branch with Branch a site, or for a company site Branch a company site.
  • The copy is separate. What the branch writes never reaches the parent, and what the parent writes after the copy never reaches the branch.
  • The copy isn’t made at once. The copy runs in the background when scheduled database processing is enabled for your environment. Wait for it before publishing a schema on the branch. A failed copy can need support to repair it; retrying the publish does not repair the copy.
  • Until the copy lands, the branch has no database. fluid.db on the branch answers no_database, and so does a publish of the branch that has a db/schema.json. Nothing changes. Try again after the copy lands.
  • The branch starts with the parent’s schema. Its next publish is compared with that schema, so the rules in Change the schema apply to it.
  • A parent with no database gives the branch none. The branch’s first publish with a db/schema.json creates an empty one.
  • A failed setup removes the branch. If the branch’s database can’t be made when you create the branch, the call answers 409 and the branch isn’t kept. Try again.

Billing

A site’s database uses the company’s hosting credits. Usage is metered by UTC day for three things:
  • The rows the site read that day.
  • The rows the site wrote that day.
  • The storage the database holds.
There is no free allowance. Requests aren’t charged on their own. The days a branch spends waiting for its copy aren’t charged. Billing depends on the company’s credit terms and billing being enabled; ask your company admin about charges for your environment.

Inspect your site’s data

As the owner of a member site, you can inspect its published database from an external app or integration. These read-only management APIs let you see every row in your own site, including rows its browser read rules would hide. They do not let you inspect another member’s site or a company-owned site.
1

Use the owner's login

Authenticate with the site owner’s member Bearer credential. Make these calls outside the site’s browser code. The site’s cookie session and a member session credential from an email link cannot use these APIs. Never put the credential in your site’s build.
2

Choose your site

Use List the member’s sites or Show a site to choose the site you own.
3

List its tables

Use List the tables of a site’s database. The list comes from the schema in the site’s current published bundle.
4

Read a table's rows

Use Read the rows of a table of a site’s database with the same owner’s credential. Follow the reference for paging and error handling. Inspecting rows does not add, change or delete them.
The site needs an available V1 database and a published schema, and member sites must be enabled for your company. If you cannot inspect a site, check its owner and published database before checking the errors in the endpoint reference. These management reads are separate from fluid.db inside your app, where the visiting member and your table’s access rules decide what can be read or written. See Query a table. For a file containing every table and row, see Download the data. Company admins use the separate company APIs and permissions described in Review and delete rows. The owner inspection APIs do not provide the admin’s row-deletion operation.

Download the data

You can download every table and row of a site’s database as one file. The file is NDJSON, a JSON value on each line. This shortened example shows its structure:
  • The first line is the header. It holds the format, its version and the database’s schema.
  • Each other line is one row. It names the table and holds every column, the system columns included.
  • Tables come in name order. Each table’s rows come by id.
  • Values are written as the schema says. An integer past 2^53 is a string, and a timestamp carries its milliseconds.
The file streams as the database is read. It is not a snapshot: rows can change during a download. Avoid writes while exporting when you need a consistent copy. Check that the download finishes successfully and every line ends with a newline; a newline alone does not prove that a download is complete. If the download fails after it started, the last line is {"error":"export_interrupted"}. That line isn’t a row. Download again. A site’s owner downloads with the member API, using the member’s own bearer token and the site_id in the folder’s .member-site.json:
  • Only the owner can download. A site’s own code can’t, and neither can a member session link from an email. Both are refused with 403.
  • A site with no database answers 404. So does a company that Sites aren’t on for.
  • A failure before the file starts answers 502 or 504. Ask again.
See Download a site’s database for the full contract. A company admin with the member_site_data.view permission downloads any site of the company with Download a site’s database on the company API.

Review and delete rows

Company admins can read and delete the rows of any site in the company, a member’s site included. An admin never adds or changes a row. A table’s rules don’t apply to an admin, so a table whose read rule is nobody is still readable here.
  • Reading and deleting need their own permissions. member_site_data.view reads rows and member_site_data.delete deletes them. Both also need application_themes.view, and a signed-in admin.
  • Every call is logged. The log holds the admin, the site and the table, and for a delete the row ids.
  • The admin screen does it for you. See Sites in the admin.
With the API, List the tables of a site’s database shows the tables the site’s current version declares. Read the rows of a table of a site’s database returns a page of rows, newest first. Delete rows of a table of a site’s database removes the selected row ids. A row already gone is ignored, so sending the same delete again is safe.

Next steps