Concepts
Databases, tables, fields and rows, plus update and recovery rules.
What Database is for
Database is a headless content store: people, agents and scripts read and write structured tables and rows directly through the public API, as with a headless CMS. It holds content such as itineraries and catalogs, independently of the applications that use it. It is neither an application's private runtime database nor a data warehouse.
| Role | Who uses it | Lifecycle | Where to store it |
|---|---|---|---|
| Content store | People, agents and scripts; applications can read it through public APIs | Content is edited and reused independently of application releases | Database for structured content; File for files; Knowledge for searchable knowledge |
| Application runtime store | Application code managing orders, sessions and business rules | State changes as the application runs, under its own business constraints | The application's own d1, kv or r2 components |
| Data warehouse | Analysts and reporting pipelines | Historical data accumulated for analysis | A dedicated analytics warehouse, outside Database |
A typical pattern is content store → build → publish to Application: read itinerary content from Database, build site files, then use praxis.application.publish to upload, version and deploy them. Editing the source content does not update those published files; build and publish again to include the edits.
A database is an isolation unit. Tables define record structure, fields define value types, and rows hold the records.
Databases, tables, fields and rows
Your API Key determines which databases you can access. Tables and fields accept IDs or names. Links use table IDs, so renaming a table does not change the link target. Database labels support categorization and filtering.
Default rows contain only the primary key id and user fields, including select=*, return=representation and RPC rows. Only id is a reserved field name; new fields cannot start with _. created_at, updated_at and version are ordinary field names. Field names start with a lowercase letter, followed by lowercase letters, digits or underscores, up to 63 characters; table names also allow a leading underscore. Existing underscore user fields remain readable and writable and take precedence over same-named metadata, making that metadata inaccessible in the table.
System metadata is read-only and opt-in: use select=*,_version,_created_at,_updated_at. Star never expands it. Use _version=eq.3 in filters or order=_created_at.desc for sorting. Without order, rows still sort by internal creation time, then id. Old metadata names in filters/order without a same-named user field return 400: details says use _created_at (or the corresponding name), and hint says system metadata moved to underscore columns.
A datetime field can use {"name":"created_at","type":"datetime","default":"now()"}. Omitting it on insertion stores current server UTC time; the field definition continues to show "now()". Explicit null and omitted PATCH fields do not use defaults. String/json defaults of "now()" remain literal strings. Adding this field to an existing table evaluates the default for the backfill too.
Seven field types
| Type | Value |
|---|---|
string | A string |
integer | An integer |
number | A number |
boolean | true or false |
datetime | An RFC 3339 date-time such as 2026-10-11T08:30:00+08:00; impossible dates (such as February 30) return 400. Values are stored and read back in UTC with three fractional digits, such as 2026-10-11T00:30:00.000Z; precision is milliseconds and extra fractional digits are rounded half up |
json | A JSON value satisfying the supplied JSON Schema |
link | A row ID, or multiple row IDs, in another table in the same database |
required requires a non-null value. A new field with a default fills every existing row with it; a new field without one is null on existing rows. Adding a required field to a table that already has rows needs a default; making an existing field required returns 400 VALIDATION_FAILED with the number of empty rows while any row is null in that field, so fill those rows first. unique requires unique values. The first five types are indexed by default; set indexed: false where unnecessary. json and link cannot be indexed. A field's type cannot change after creation.
Batch writes and transactions
One POST inserts at most 500 rows. Each request is atomic: a failure in any row rolls back the entire batch. There are no transactions across requests. Put rows that must succeed together in one batch POST. For links, query the target first, then insert the referencing rows. Querying first does not prevent concurrent changes; still handle a missing-link error at insert time.
Rows in a batch are filled to the union of their keys with null (PostgREST behavior), so omitting a required field in one row can reject the whole batch. Send Prefer: missing=default to use field defaults for missing keys. Explicit null remains null, and required fields without defaults must still be provided.
The default return=minimal returns 201 with an empty body. Send Prefer: return=representation to read back inserted rows, or chain .select() after supabase-js .insert(...).
JSON Schema
A json field requires JSON Schema in options.schema. For example, this field only accepts objects containing a string summary.
{
"name": "detail",
"type": "json",
"options": {
"schema": {
"type": "object",
"properties": { "summary": { "type": "string" } },
"required": ["summary"],
"additionalProperties": false
}
}
}Schema updates constrain subsequent writes without rewriting existing rows. Use detail->>summary to extract its string value in queries.
Links and deletion protection
A link's options.linked_table_id points to a target table in the same database. multiple selects a single row ID or an array of row IDs. The target cannot change after creation.
{
"name": "project",
"type": "link",
"options": { "linked_table_id": "tbl_example", "multiple": false }
}Replace tbl_example with an existing table ID. References to nonexistent rows are rejected. Deleting a row that other rows still reference is also rejected; rows that reference each other can be deleted together in one delete. Remove references before deleting the target; deletion does not cascade automatically.
Use _version to avoid overwriting another update
A new row starts at _version: 1, increasing on every update. Filter by both the row ID and the _version you read. An empty result means the condition no longer matches; read again before deciding what to do next.
const { data: row, error: readError } = await supabase.from("tasks")
.select("*,_version").eq("id", rowId).single();
if (readError) throw readError;
const { data, error } = await supabase.from("tasks")
.update({ done: true })
.eq("id", row.id)
.eq("_version", row._version)
.select();
if (error) throw error;
if (data.length === 0) throw new Error("Read the row again before updating");Restore a deleted database within 7 days
After deleting a database, its rows cannot be read or written. Restore the database within 7 days to use it and its retained tables and rows again. After that window, recovery is unavailable. Recovery applies to the whole database, not individually deleted tables, fields or rows.