Your first datastore
Two YAML files, one boot config, one migration command, one row. Every command and every response body below is from a run of this exact project against PostgreSQL 18.
Before you start
Section titled “Before you start”| You need | Note |
|---|---|
| A reachable PostgreSQL server | Every command below assumes localhost:5432. |
| An existing, empty database | The engine creates the schema, never the database. Provisioning the database is external. |
A database user that may CREATE SCHEMA | data bootstrap issues CREATE SCHEMA IF NOT EXISTS on the tenant’s namespace. |
The data.svc binary | It is both the server and the migration CLI. |
Lay out the project
Section titled “Lay out the project”quickstart/ bootstrap.yaml # boot config: listener, tenant, database data/ datastore.yaml # the datastore manifest currency.yaml # one entityEverything under data/ is walked recursively for *.yaml and *.yml and merged into one schema.
The directory is the unit, not any one file.
Declare the datastore
Section titled “Declare the datastore”default-datastore: maindatastores: - name: mainA datastore declaration is a logical name. This one pins nothing else: no backend, no host, no
credentials. Those come from the tenant’s connection pool, and with type: left off the declaration the
backend type is inferred from that pool. An entity that does not name a datastore: binds to
default-datastore.
Declare one entity
Section titled “Declare one entity”entities: - name: currency description: "Currency master data" fields: - name: code type: string modifiers: [required, unique] - name: name type: string modifiers: [required] - name: symbol type: string modifiers: [required] - name: fraction_units type: integer defaultvalue: 100 access: actions: read: { language: expr, expression: "true" } create: { language: expr, expression: "true" } update: { language: expr, expression: "true" } delete: { language: expr, expression: "true" }| Key | Meaning |
|---|---|
entities[].name | Entity name. Also the table name and the :entity segment of every REST path. |
fields[].name | Column name. |
fields[].type | Logical type, mapped to a physical type by the backend dialect. integer is an accepted spelling of int. |
fields[].modifiers | required sets NOT NULL; unique adds a unique constraint. Both may also be written under validations:. |
fields[].defaultvalue | Applied on create. A literal is also emitted as a SQL DEFAULT. |
access.actions.<action> | Per-action guard, evaluated on every request. The eight actions are create, read, update, delete, purge, restore, unmask, decrypt. |
The guards here evaluate to true so the walkthrough can use the anonymous mirror without minting a
token. See Access rules for what a real guard looks
like.
What you did not declare
Section titled “What you did not declare”You wrote four fields; the table will have eleven. Three things are added for you:
| Added | Source | Effect |
|---|---|---|
id | The built-in id trait, injected because primary-key: is absent and no id field exists | ULID primary key, char(26) on PostgreSQL |
createdby, createdon, updatedby, updatedon | The built-in common trait (applytoall: true) | Maintained by the engine on every write |
deletedby, deletedon | The built-in softdelete trait (applytoall: true) | The presence of deletedon is what switches the entity to soft delete |
Declaring primary-key: code instead suppresses the surrogate-id injection entirely. The three
built-in traits are ordinary traits — declaring your own
under the same bare name replaces them.
Point it at a database
Section titled “Point it at a database”name: datahost: 127.0.0.1port: 8099zerotrust: false
source: type: fsstore
log: level: info
schemasource: type: localdir path: ./data
hives: tenant: - path: "" name: dev active: true datastores: main: dbpool: main dbpools: main: type: postgres dbserver: localhost:5432 dbuser: appuser database: appdb password: appsecret schema: quickstart maxconnections: 10| Key | Meaning |
|---|---|
zerotrust | Mutual-TLS between services. Must be false here — see the caution below. |
schemasource.type | localdir reads entity YAML from disk instead of the metadata service. |
schemasource.path | Definition directory, relative to the process working directory. |
hives.tenant[].path | Config-plane path prefix. "" serves a single tenant for any tenant key the process sees. |
hives.tenant[].datastores.<name>.dbpool | Binds the logical datastore main to the pool named main. |
hives.tenant[].dbpools.<name>.type | Backend. PostgreSQL is what the deployable service wires for tenant datastores. |
hives.tenant[].dbpools.<name>.schema | The tenant’s PostgreSQL namespace. Every table and every ledger row lands here. |
Migrate
Section titled “Migrate”Nothing schema-shaped is ever applied implicitly. Create the tables explicitly:
data.svc data bootstrap --tenant acme:shop:dev:main -f bootstrap.yaml--tenant is the full four-part key customer:product:env:tenant. The hive at path: "" serves it
regardless of the value.
Plan U18C5E87BB7360D400000000001 (source=yaml, max_risk=low)
Steps (21): 1. [ddl ] add_table currency. CREATE TABLE IF NOT EXISTS "quickstart"."currency" ( "cod... 2. [ddl ] add_table data_applied_changes. CREATE TABLE IF NOT EXISTS "quickstart"."data_applied_cha... 3. [ddl ] add_index data_applied_changes.data_applied_changes_plan_step_idx CREATE UNIQUE INDEX IF NOT EXISTS ... … 21. [ddl ] add_index data_plans.data_plans_status_idx CREATE INDEX IF NOT EXISTS "data_plans_status_idx" ON "qu...plan_id: U18C5E87BB7360D400000000001status: appliedapplied: 21 failed: 0 skipped: 0One step created currency. The other twenty created the engine’s own bookkeeping tables in the same
namespace: data_plans, data_applied_changes, data_db_version, data_locks,
data_archive_handoff, data_materialized_refresh_tasks, and their indexes. bootstrap exists as a
separate verb because the plan that creates the plan ledger cannot itself be recorded in that ledger.
bootstrap is idempotent — every statement is IF NOT EXISTS, so re-running it is a no-op sweep.
Add --dry-run to print the plan without applying a step. --dry-run still creates the namespace: the
CREATE SCHEMA runs before the plan is generated, not as part of it.
data.svc -f bootstrap.yamlTen HTTP surfaces mount at unprefixed paths: schema, rest, graphql, bulk, queryhash,
actions, script, stateflows, admin, superadmin. Behind a gateway the public path is
/data/<service path>.
Create a row
Section titled “Create a row”Every tenanted route needs the four tenancy headers. /anon/rest is the mirror that drops the bearer
token but not the headers.
curl -i -X POST http://127.0.0.1:8099/anon/rest/currency \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main' \ -d '{"code":"EUR","name":"Euro","symbol":"€","fraction_units":100}'HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8X-Ratelimit-Limit: 60X-Ratelimit-Remaining: 59X-Ratelimit-Reset: 1785088486{ "data": [ { "code": "EUR", "createdby": "", "createdon": "2026-07-26T17:54:45.970265Z", "fraction_units": 100, "id": "01KYFS1AJJN5B9JSFBYNK88ZNF", "name": "Euro", "symbol": "€", "updatedby": "", "updatedon": "2026-07-26T17:54:45.970265Z" } ], "count": 1}You supplied four fields; the engine minted the ULID and stamped the audit columns.
X-Tenant carries the tenant segment (main), not the four-part key. Drop any of the four
headers and the chassis rejects the request before the handler runs — and this one failure is plain
text, not the JSON envelope:
HTTP/1.1 400 Bad RequestContent-Type: application/text
invalid tenantRead it back
Section titled “Read it back”curl 'http://127.0.0.1:8099/anon/rest/currency?limit=10' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'{"data":[{"code":"EUR","createdby":"","createdon":"2026-07-26T17:54:45.970265Z","fraction_units":100,"id":"01KYFS1AJJN5B9JSFBYNK88ZNF","name":"Euro","symbol":"€","updatedby":"","updatedon":"2026-07-26T17:54:45.970265Z"}],"count":1}A read by id returns the same envelope with a one-element array, never a bare object:
curl 'http://127.0.0.1:8099/anon/rest/currency/id/01KYFS1AJJN5B9JSFBYNK88ZNF' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'Filtering and projection
Section titled “Filtering and projection”Any query-string key that is not reserved becomes an equality predicate, and multiple keys are AND-ed:
curl 'http://127.0.0.1:8099/anon/rest/currency?code=EUR&select=id,code,name' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'{"data":[{"code":"EUR","id":"01KYFS1AJJN5B9JSFBYNK88ZNF","name":"Euro"}],"count":1}The query-AST keys are select, fields, orderby, order_by, groupby, having, include,
depth, filter, limit, pagesize, offset, page, after, before. ?orderby= takes a
leading - for descending (?orderby=-createdon); ?page= is 1-based and needs a limit.
Everything else that is reserved is a request flag —
see Request flags for the complete set.
Seeing the SQL
Section titled “Seeing the SQL”curl 'http://127.0.0.1:8099/anon/rest/currency?limit=10&stats=true' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'{ "data": [ … ], "count": 1, "stats": { "sql": "SELECT \"code\", \"name\", \"symbol\", \"fraction_units\", \"createdby\", \"createdon\", \"updatedby\", \"updatedon\", \"deletedby\", \"deletedon\", \"id\" FROM \"quickstart\".\"currency\" WHERE \"deletedon\" IS NULL ORDER BY \"id\" ASC LIMIT 10", "duration_ms": 9223372036854, "rows_affected": 1 }}Three things the compiler did without being asked: it qualified the table with the tenant’s
namespace, it injected WHERE "deletedon" IS NULL because the entity carries a delete marker, and it
injected ORDER BY "id" ASC because you supplied no ordering. It also emits an explicit column list
rather than SELECT *.
Update and delete
Section titled “Update and delete”PUT and PATCH have the same semantics here — only the fields you supply are written.
curl -X PATCH 'http://127.0.0.1:8099/anon/rest/currency/id/01KYFS1AJJN5B9JSFBYNK88ZNF' \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main' \ -d '{"name":"Euro (updated)"}'Delete is soft by default. The row stays, deletedon is stamped, and the API stops returning it:
curl -X DELETE 'http://127.0.0.1:8099/anon/rest/currency/id/01KYFS1AJJN5B9JSFBYNK88ZNF' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'{"data":[],"count":0,"total":1}The row is no longer addressable:
{"error":"currency 01KYFS1AJJN5B9JSFBYNK88ZNF not found","code":"v2.row_not_found"}?purge=true on the delete removes the row physically instead. POST /rest/:entity/id/:id/restore
undoes a soft delete, and ?deleted=true on a read bypasses the soft-delete filter.
Change the shape
Section titled “Change the shape”Add a field:
- name: is_active type: boolean defaultvalue: trueA definition change on disk does nothing to the database on its own. Two separate steps are required, in this order.
1. Diff, review, apply
Section titled “1. Diff, review, apply”data.svc data plan generate --tenant acme:shop:dev:main -f bootstrap.yamlPlan U18C5E88C08A905F00000000001 (source=yaml, max_risk=low)
Steps (1): 1. [ddl ] add_column currency.is_active ALTER TABLE "quickstart"."currency" ADD COLUMN "is_active...The plan is persisted. Read the full SQL of any step with --output json:
data.svc data plan show U18C5E88C08A905F00000000001 --tenant acme:shop:dev:main -f bootstrap.yaml --output json{ "PlanID": "U18C5E88C08A905F00000000001", "Source": "yaml", "MaxRisk": 0, "Warnings": null, "Refusals": null, "Steps": [ { "Order": 1, "Kind": 0, "SchemaOp": "add_column", "Entity": "currency", "Identifier": "is_active", "SQL": "ALTER TABLE \"quickstart\".\"currency\" ADD COLUMN \"is_active\" BOOLEAN NOT NULL DEFAULT true", "Expected": "", "Risk": 0, "Notes": "", "Transactional": false, "Checksum": "" } ]}Kind and Risk serialise as integers, not names: 0 is ddl and low respectively.
data.svc data plan apply U18C5E88C08A905F00000000001 --tenant acme:shop:dev:main -f bootstrap.yaml --confirm-risk lowplan_id: U18C5E88C08A905F00000000001status: appliedapplied: 1 failed: 0 skipped: 0Every step is recorded:
data.svc data applied list --tenant acme:shop:dev:main -f bootstrap.yaml --plan-id U18C5E88C08A905F00000000001plan_id: U18C5E88C08A905F00000000001
STEP KIND STATUS ENTITY DURATION ERROR----------------------------------------------------------------------------------------------------1 ddl applied currency 2ms--confirm-risk gates against the plan’s computed maximum risk: low (the default) applies low-risk
plans only, medium also applies type widening and nullability tightening, high also applies column
drops. A plan whose risk exceeds the confirmed level is refused outright and no step runs.
2. Rebuild the engine
Section titled “2. Rebuild the engine”The running process holds a compiled schema per (tenant, datastore). Until it rebuilds, writes still
use the old shape — a create will succeed and silently omit the new column. Either restart the
process, or clear the cache in place:
curl -X DELETE http://127.0.0.1:8099/cache \ -H 'Authorization: Bearer <token>' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: dev' -H 'X-Tenant: main'{"cleared":true,"tenant":"acme:shop:dev:main"}This drops every cached schema, pool, engine and action registry for the tenant; the next request rebuilds from source. It rebuilds the engine — it does not touch the database.
Refusals
Section titled “Refusals”Remove a field that still exists in the database and the diff stops rather than guessing:
Plan U18C5E8941EE6C2280000000001 (source=yaml, max_risk=low)
Refusals (apply blocked): - refused data_loss on currency.is_active: column currency.is_active exists in DB but not in YAML — declare an explicit migration and pass --allow-drops to applyThree refusal kinds exist: data_loss (a live column has no definition), needs_explicit_migration
(an unsafe type change, or a NOT NULL column added with no default), and ambiguous_change (rename
detection matched more than one candidate).
What the engine built
Section titled “What the engine built”Straight after bootstrap, before the is_active column above:
\d quickstart.currency Table "quickstart.currency" Column | Type | Collation | Nullable | Default----------------+--------------------------+-----------+----------+------------------- code | character varying(255) | | not null | name | character varying(255) | | not null | symbol | character varying(255) | | not null | fraction_units | integer | | not null | 100 createdby | character varying(255) | | not null | createdon | timestamp with time zone | | not null | CURRENT_TIMESTAMP updatedby | character varying(255) | | | updatedon | timestamp with time zone | | | deletedby | character varying(255) | | | deletedon | timestamp with time zone | | | id | character(26) | | not null |Indexes: "currency_pkey" PRIMARY KEY, btree (id) "currency_code_key" UNIQUE CONSTRAINT, btree (code)Where to go next
Section titled “Where to go next”| Concern | Page |
|---|---|
| The full block model, layering, request path | Data API |
| Entities, indexes, history strategies | Entities |
| Every field-level key | Fields |
| The logical type set and custom types | Types |
| Datastores, pools, backends | Datastores and database pools |
| Relations and eager loading | Relations |
| Real access guards and row-level security | Access rules |
| Every query-string flag | Request flags |
| Every route, parameter and status code | Data API reference |
| Seeds, init scripts, locks, retention, failure modes | Operations |
| Error codes | Errors |