Skip to content
Talk to our solutions team

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.

You needNote
A reachable PostgreSQL serverEvery command below assumes localhost:5432.
An existing, empty databaseThe engine creates the schema, never the database. Provisioning the database is external.
A database user that may CREATE SCHEMAdata bootstrap issues CREATE SCHEMA IF NOT EXISTS on the tenant’s namespace.
The data.svc binaryIt is both the server and the migration CLI.
quickstart/
bootstrap.yaml # boot config: listener, tenant, database
data/
datastore.yaml # the datastore manifest
currency.yaml # one entity

Everything under data/ is walked recursively for *.yaml and *.yml and merged into one schema. The directory is the unit, not any one file.

data/datastore.yaml
default-datastore: main
datastores:
- name: main

A 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.

data/currency.yaml
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" }
KeyMeaning
entities[].nameEntity name. Also the table name and the :entity segment of every REST path.
fields[].nameColumn name.
fields[].typeLogical type, mapped to a physical type by the backend dialect. integer is an accepted spelling of int.
fields[].modifiersrequired sets NOT NULL; unique adds a unique constraint. Both may also be written under validations:.
fields[].defaultvalueApplied 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.

You wrote four fields; the table will have eleven. Three things are added for you:

AddedSourceEffect
idThe built-in id trait, injected because primary-key: is absent and no id field existsULID primary key, char(26) on PostgreSQL
createdby, createdon, updatedby, updatedonThe built-in common trait (applytoall: true)Maintained by the engine on every write
deletedby, deletedonThe 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.

bootstrap.yaml
name: data
host: 127.0.0.1
port: 8099
zerotrust: 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
KeyMeaning
zerotrustMutual-TLS between services. Must be false here — see the caution below.
schemasource.typelocaldir reads entity YAML from disk instead of the metadata service.
schemasource.pathDefinition directory, relative to the process working directory.
hives.tenant[].pathConfig-plane path prefix. "" serves a single tenant for any tenant key the process sees.
hives.tenant[].datastores.<name>.dbpoolBinds the logical datastore main to the pool named main.
hives.tenant[].dbpools.<name>.typeBackend. PostgreSQL is what the deployable service wires for tenant datastores.
hives.tenant[].dbpools.<name>.schemaThe tenant’s PostgreSQL namespace. Every table and every ledger row lands here.

Nothing schema-shaped is ever applied implicitly. Create the tables explicitly:

Terminal window
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: U18C5E87BB7360D400000000001
status: applied
applied: 21 failed: 0 skipped: 0

One 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.

Terminal window
data.svc -f bootstrap.yaml

Ten 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>.

Every tenanted route needs the four tenancy headers. /anon/rest is the mirror that drops the bearer token but not the headers.

Terminal window
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 Created
Content-Type: application/json; charset=utf-8
X-Ratelimit-Limit: 60
X-Ratelimit-Remaining: 59
X-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 Request
Content-Type: application/text
invalid tenant
Terminal window
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:

Terminal window
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'

Any query-string key that is not reserved becomes an equality predicate, and multiple keys are AND-ed:

Terminal window
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.

Terminal window
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 *.

PUT and PATCH have the same semantics here — only the fields you supply are written.

Terminal window
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:

Terminal window
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.

Add a field:

- name: is_active
type: boolean
defaultvalue: true

A definition change on disk does nothing to the database on its own. Two separate steps are required, in this order.

Terminal window
data.svc data plan generate --tenant acme:shop:dev:main -f bootstrap.yaml
Plan 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:

Terminal window
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.

Terminal window
data.svc data plan apply U18C5E88C08A905F00000000001 --tenant acme:shop:dev:main -f bootstrap.yaml --confirm-risk low
plan_id: U18C5E88C08A905F00000000001
status: applied
applied: 1 failed: 0 skipped: 0

Every step is recorded:

Terminal window
data.svc data applied list --tenant acme:shop:dev:main -f bootstrap.yaml --plan-id U18C5E88C08A905F00000000001
plan_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.

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:

Terminal window
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.

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 apply

Three 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).

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)
ConcernPage
The full block model, layering, request pathData API
Entities, indexes, history strategiesEntities
Every field-level keyFields
The logical type set and custom typesTypes
Datastores, pools, backendsDatastores and database pools
Relations and eager loadingRelations
Real access guards and row-level securityAccess rules
Every query-string flagRequest flags
Every route, parameter and status codeData API reference
Seeds, init scripts, locks, retention, failure modesOperations
Error codesErrors