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 adb/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
eventscan 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.rsvpscan 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:
uniqueis["event_id", "created_by"]. indexesanduniqueare optional.
3
Check the schema
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
--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: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.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
versionthat isn’t1, or a key that isn’t listed above. - A column type that isn’t one of the eight, such as
float,stringordatetime. - A table or column name that has a capital letter, starts with
_, a digit orpg_, or is longer than 63 characters. - A column named
id,created_by,created_atorupdated_at. - A rule that isn’t
public,signed_in,creator,ownerornobody, andcreatoras theinsertrule. - A
defaultthat isn’t a value of the column’s type, such as5for atextcolumn, adateof"2026-02-30", ornullon arequiredcolumn. - 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.
Query a table
Import the SDK
@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()orselect("*")reads every declared column, then the system columns.select("name,guests", { count: "exact" })also returnscount, 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, andrange(50, 99)the next 50.- A row of a select holds the columns you asked for. A
uuidordateis a string, and anintegerpast 2^53 is a string.
Write rows
inserttakes one row or an array of up to 500. Leave outid,created_by,created_atandupdated_at: the site’s host sets them.updatesets at most 100 values together with the filter’s values.updateanddeleteneed 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 indata. - A
creatorrule 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
insertor anupdatewithquota_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.updatepermission raises them with Replace the company’s member site policy, indatabaseLimits. Read the company’s member site policy shows what is set. A limit left out, ornull, goes back to its default.
Change the schema
Editdb/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 alocation column added to events and a new announcements table. Every table of the schema stays in the file:
db/schema.json
Make a destructive change
A destructive change needs a migration inmigrations, 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
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:
--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 with409 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.
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 adb/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_bylocal-owner, which isn’t your real user id. Run with--viewer guestto check what a signed-out visitor may read and write. - Reset when you need a clean start.
fluid sites dev --reset-dbdeletes the local rows and creates the tables again fromdb/schema.json.
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.dbon the branch answersno_database, and so does a publish of the branch that has adb/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.jsoncreates 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
409and 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.
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.
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.
{"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
502or504. Ask again.
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 whoseread rule is nobody is still readable here.
- Reading and deleting need their own permissions.
member_site_data.viewreads rows andmember_site_data.deletedeletes them. Both also needapplication_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.
Next steps
- Publish a Site covers the login, the checks and what each answer means.
- The Sites SDK covers the rest of
fluid. - Site declarations covers
config/access.json.