Skip to content
Talk to our solutions team

Datastores and database pools

Three things are kept separate, and a request touches all three:

ThingWhat it isWhere you declare it
BackendA database driver — pool, SQL driver, transaction factory, query compiler, DDL dialectNot declared; selected by type
Database poolA physical connection endpoint for one tenant: host, port, database, credentials, sizinghives.tenant[].dbpools.<name>
DatastoreA named logical binding that points at exactly one poolhives.tenant[].datastores.<name>

Your schema declares entities against a datastore name. At request time data.svc resolves tenant → datastore → pool, builds a connection pool, and injects it into an engine:

X-Customer / X-Product / X-Env / X-Tenant (the CPET request headers)
└── tenant config
├── datastores.main.dbpool: main
└── dbpools.main → host:port/database, schema, isolation
→ backend type → dialect + compiler

Nothing here is built at boot. The pool, the schema, the hook chain, and the engine are constructed on the first request that names a given (tenant, datastore) pair, cached per CPET, and torn down as one unit on config change or shutdown.

Both blocks are maps keyed by name, under the tenant’s config node. The map key is the identity — an inline name: field is overwritten by the key after binding.

hives:
tenant:
- path: ""
name: dev
active: true
domain:
- localhost
- 127.0.0.1
datastores:
main:
dbpool: main
dbpools:
main:
type: postgres
dbserver: localhost:5432
dbuser: erpadmin
database: erpdb
password: erpadmin
schema: erptest # this tenant's physical PostgreSQL schema
maxconnections: 10

The datastore key main and the pool key main are independent names that happen to match here. The binding is the dbpool: main line, and dbpool is a child of the datastore, never a sibling of it.

KeyTypeDefaultNotes
typestringpostgresEmpty and postgresql both canonicalise to postgres. Only postgres and clickhouse pass validation
dbserverstringlocalhost:5432host:port. A bare host gets port 5432. A non-numeric port fails the build
dbuserstringRequired unless dsn is set
databasestringRequired unless dsn is set
passwordstring""A literal, or a key resolved through Vault
schemastringthe tenant’s own nameThe physical PostgreSQL schema this tenant’s objects live in
isolationstringdedicateddedicated or shared
tlsconfigstringdriver defaultdisable, require, verify-ca, verify-full. Passed through as the driver’s sslmode
maxconnectionsint10Maximum open connections. Applied only when greater than zero
connection_life_timedurationunlimitede.g. 30m. Maximum lifetime of a connection
connect_timeoutduration or int5sA bare integer is read as seconds
dsnstring""Explicit connection-string escape hatch

password is resolved through the registered vault client before the pool is built. When no vault client is registered — the local dev path — the value is used as a literal password. The config shape is the same either way.

Several knobs exist on the runtime pool object but have no configuration key: idle connections, idle time, health-check period, pool-level statement timeout, and arbitrary driver options beyond sslmode. They stay at their zero value and the driver default applies. Do not expect to tune them from YAML.

KeyTypeRequiredNotes
dbpoolstringyesName of the entry under dbpools. The sole binding to a physical pool
remoteconnectionsmapnoNon-database (HTTP-style) backends attached to this datastore
remoteconnections.<name>.urlstringnoRemote endpoint URL
remoteconnections.<name>.protocolstringnoRemote endpoint protocol
remoteconnections.<name>.authorizationstringnoAuthorization header value sent to the remote endpoint

A schema file declares the datastores its entities target as a list, and names the one used when a request selects none:

default-datastore: main
datastores:
- name: main
version: "18"
extensions:
- pg_trgm
- pgvector
init:
scripts:
- path: sql/extensions.sql
description: extensions and shared functions
kind: ddl
transactional: false
KeyTypeNotes
namestringMust match a key in the tenant’s datastores: map
typestringOptional dialect hint. Omit it — see below
versionstringBackend version. Not enforced — see Versions
extensionslistInstalled database extensions; registers their types and operators with the compiler
init.scriptslistRaw SQL run once per (tenant, datastore)
databasestringParses, never read
poolstringParses, never read
schemaslistParses, never read
pathstringParses, never read
settingsmapParses, never read
loggingmapParses, never read

default-datastore is the fallback when a caller passes no datastore selector. Requests select another with the datastore query parameter — see the Data API HTTP reference. When a schema declares exactly one datastore and no default-datastore, that one is used. If the requested name is absent from the loaded schema the build fails with schema has no datastore <name>, and the message enumerates the default and every available name.

init.scripts[] is raw SQL run once per (tenant, datastore) pair on the first engine build — extensions, base functions, default triggers, type definitions. Each entry takes path (required), description, kind (ddl or dml), and transactional (default true; set it false for statements PostgreSQL refuses to run inside a transaction).

The runner records applied checksums in a ledger and skips any script whose content checksum is already there, so a new process boot applies only newly-added scripts. Script bodies must be idempotent on their own — CREATE EXTENSION IF NOT EXISTS, CREATE OR REPLACE FUNCTION.

The datastore’s backend type selects the SQL dialect and compiler. It may be declared on the schema’s datastores: entry, but that is redundant with the pool’s type:. When the schema omits type, the service infers it from the resolved pool and logs that it did so. The inference is copy-on-write; the cached schema is never mutated.

Spelling is not uniform across the surfaces. The canonical pool type and the shared-endpoint key use postgres; the backend driver reports itself as postgresql; schema-side dialect lookup accepts both, as does engine assembly. Match the spelling to the field rather than assuming one canonical string.

symbols: and functions: are top-level schema keys — siblings of entities:, not children of a datastores: entry. Both describe SQL vocabulary that belongs to a specific database backend, and both are attached to the dialect built for the resolved datastore.

KeyShapeDeclares
symbolsmap keyed by backend nameDatabase function and keyword names exposed for that backend
functionslist of named entriesUser-defined functions the schema refers to by name

A symbols: block is keyed by backend. Each backend bucket takes one key, systemfunctions, a list of names:

symbols:
postgres:
systemfunctions:
- min
- max
- count
clickhouse:
systemfunctions:
- multiMatchAllIndices
- multiFuzzyMatchAllIndices

Each name is flattened into one symbol entry with category function and one mapping per backend that lists it. The mapping value is the name itself, so a symbol is never rewritten into different SQL — listing it exposes the name for that backend, and nothing more. A name listed under two backends produces a single entry carrying both mappings.

Backend keys are matched by alias when a symbol is resolved: postgresql, postgres and pg all match PostgreSQL; clickhouse and ch match ClickHouse; sqlite and sqlite3 match SQLite; duckdb matches only itself. A key outside those sets matches no dialect and the block has no effect.

Schema-declared symbols are consulted before the dialect’s own built-in symbol table, so listing a name the dialect already provides overrides the built-in.

Across multiple schema files the merge is last-write-wins per backend key, on the whole bucket. A second file declaring symbols: with a postgres: key replaces the first file’s systemfunctions list rather than appending to it. Keep one backend’s symbols in one file. In a layered schema the key is dropped entirely — the base’s symbols are carried and a layer’s are ignored, see Schema sources.

functions: declares user-defined functions by name so the schema can refer to them. Entries are a list:

functions:
- name: tax
kind: computed
runtime: sql
entity: invoice
source: tax(amount, rate)
KeyValuesNotes
namestringThe lookup key. Names are unique per schema; a later file with the same name replaces the earlier entry
kindtrigger, validation, transformAnything else, including an omitted value, is read as computed
runtimesql, expr, cel, js, lua, starlarksql marks the function inlinable into compiled SQL; every other runtime is executed outside the query
entitystringThe entity the function applies to
sourcestringThe expression or file path
trigger.eventstringe.g. before_insert, after_update
trigger.entitystringThe entity the trigger fires on

The PostgreSQL schema a tenant’s objects live in comes from that tenant’s pool schema: property, never from a process constant. Resolution is: the explicit schema: when set, otherwise the tenant’s own name.

That value is written into the composed schema’s name, so the query compiler qualifies all DML as "<schema>"."<table>", and it is also sent as the pool’s search_path startup parameter. The same namespace drives the migration pipeline — the dialect’s schema name so DDL is emitted qualified, the discovery inputs so introspection inspects the right namespace, and the ledger so plan and step rows land in the right place.

Qualification applies in both isolation modes. An earlier design had shared pools clear the schema name and rely on the per-checkout search_path flip; that was reversed, because the engine’s non-transactional read path uses the raw database handle, which never passes through connection checkout. With unqualified SQL, two tenants sharing an endpoint wrote into one schema. The checkout flip and app.tenant_id remain as defence in depth.

An omitted schema: is convenient — schema-per-tenant isolation in a shared database for free — but it ties the physical namespace to the tenant name, so a tenant rename moves the namespace. The identifier used in the search_path SET is quoted; a value containing a double quote or semicolon is discarded and replaced with a fixed placeholder identifier, so a schema name can never inject into the statement.

Schema qualification is PostgreSQL-only. ClickHouse, DuckDB and SQLite suppress the schema prefix in compiled SQL, so the whole physical-namespace mechanism does not apply to them.

dedicated (default)shared
Pool scopeOne private pool per (CPET, datastore)One reference-counted base pool per physical endpoint
Endpoint keyn/a<backend>://<host>:<port>/<database>
schema:OptionalRequired — build fails without it
Connectivity checkPinged once after creation; a failed ping fails the buildNo probe ping; the first query validates
Failure surfaceCredential and network errors at pool buildCredential and network errors at first query
Engine, schema, hooksPer tenantPer tenant — only the physical pool is shared

The endpoint key deliberately excludes schema and user, because the schema flips per checkout and including it would partition the pool. Two consequences:

  • All tenants on a shared endpoint connect as the same database role. Varying dbuser across tenants on one endpoint does not partition the pool — they all connect as whichever tenant built it first.
  • The base pool is sized by the first tenant’s maxconnections. Size a shared endpoint for the whole fleet, not for one tenant.

Acquire on an endpoint builds the base pool on first miss and increments a refcount; each tenant’s router releases it on invalidation, and the base closes when the last tenant releases. Shutdown force-closes every base pool regardless of refcount, because every engine is being torn down anyway.

Tenant identity is not stored on the pool object. It flows through the request context and is applied per connection checkout. On PostgreSQL the hook emits, in order:

SET search_path TO "<schema>"; -- shared isolation only
SET app.tenant_id = '<cpet key>';
SET app.user_id = '<user>'; -- when non-empty
SET statement_timeout = 30000;

app.tenant_id and app.user_id are session variables for database-native policies you create yourself, through an init script or a migration. The Data API’s own row-level security does not read them: it compiles a predicate from the request principal and ANDs it into the SQL, and no CREATE POLICY DDL is emitted for an entity’s RLS rules.

A failure anywhere in this setup closes the connection instead of handing it to the caller, so no partial session state leaks.

The raw handle bypasses the tenant session deliberately, for migrations and introspection. Every raw-SQL subsystem — migration ledger, lock manager, retention eraser, archive handoff queue, materialised-refresh task table, init scripts — is therefore explicitly namespace-qualified. This is why qualification, not the checkout flip, is the isolation guarantee on a shared pool.

Every connection hand-off on PostgreSQL runs DISCARD ALL, wired through both the pool’s after-release hook and the driver’s session-reset option. It wipes search_path, timeouts, prepared statements, temp tables, cursors, LISTEN channels and SET variables. A failed reset drops the connection rather than returning it to the pool — under a persistent failure such as revoked privileges this shows up as constant connection churn and pool exhaustion, not an obvious error.

Because DISCARD ALL implies DEALLOCATE ALL, prepared-statement caching is disabled and the driver runs in simple-protocol mode. That is a deliberate per-query cost traded for correctness: a cached statement name the server has dropped fails with prepared statement does not exist.

data.svc serves PostgreSQL only for tenant datastores. Three independent chokepoints enforce it, and they do not agree with each other:

StageAcceptsRejects with
Pool config validationpostgres, postgresql, clickhouseunknown db pool type "<type>" for pool "<name>"
Pool factory lookuppostgresno v2 pool factory for DBPool type "<type>" (datastore=<name>)
Engine component assemblypostgresql, postgres, sqlite, sqlite3unsupported backend type "<type>" (supported: postgresql, sqlite)

So type: duckdb and type: sqlite are rejected at config validation; type: clickhouse passes validation and then fails at pool-factory lookup. ClickHouse, DuckDB and SQLite are implemented as engine backends — pool, driver, transactions, compiler, dialect — but they are not reachable from tenant configuration. Treat PostgreSQL as the deployment target.

The tables below are what each backend declares about itself. They are the portability reference for the SQL the compiler emits per backend. The schema-refusal gates built on those declarations are a separate matter — see Capability gates for what actually runs in data.svc.

PostgreSQLClickHouseDuckDBSQLite
TransactionsYes24.8+ onlyYesYes
SavepointsYesNoYesYes
Transactional DDLYesNoYesYes
Isolation levelsRead committed, repeatable read, serializableSnapshot (24.8+)Read committed, repeatable read, serializableSerializable
PostgreSQLClickHouseDuckDBSQLite
Joins, subqueries, set opsYesYesYesYes
Window functionsYesYesYesYes
Lateral joinsYesNoYesNo
Writable CTEsYesNoYesYes
Upsert (ON CONFLICT)YesNoYes3.35+
RETURNINGYesNoYes3.35+
Regex operatorsYesYesYesNo
Information schemaYesNoYesNo
Placeholder style$1?$1?
PostgreSQLClickHouseDuckDBSQLite
OrientationRowColumnHybridRow
Default engineheapMergeTree
Row-level securityYes (incl. per-role)NoNoNo
Triggers, sequences, identity, functionsYesNoNoNo
Online add columnYesYesYesYes
Online schema changeYesNoNoNo
Concurrent index buildYesNoNoNo
TablespacesYesNoNoNo
Schema qualificationYesSuppressedSuppressedSuppressed

ClickHouse also offers ReplacingMergeTree, SummingMergeTree and AggregatingMergeTree; all of them require an ORDER BY.

PostgreSQLClickHouseDuckDBSQLite
Index typesBTree, Hash, GIN, GiST, BRIN, full-text, partial, covering, expression, concurrent, multicolumnminmax data-skipping (reported as BTree), multicolumn, Bloom at 24.1+BTree, Hash, partial, expression, multicolumnBTree, partial, expression, multicolumn
Max index columns32
PartitioningRange, list, hash, nested, detach/attachRange, auto-create, detach/attach, compressionNoneNone
Materialized viewsYes, concurrent refreshYes, incrementalNoNo
Regular viewsYesYesYesYes
Recursive viewsYesNoYesYes
PostgreSQLClickHouseDuckDBSQLite
History strategiesAudit, Type 3, Type 4, Type 2, Type 6NoneAudit, Type 3, Type 4, Type 2, Type 6Audit, Type 3, Type 4, Type 2, Type 6
Point-in-time (asof) queriesYesNoYesYes
Soft deleteYesNoYesYes

ClickHouse declares no support for history or soft delete because its updates and deletes are asynchronous background mutations: the “delete returned, next read hides the row” guarantee cannot hold, and with no transactional rollback an audit side-table write can succeed while the main mutation fails. Model temporal semantics there explicitly with a deduplicating table engine instead.

Declared under datastores[].extensions. On PostgreSQL the list reaches the compiler dialect, which registers the extension’s types and operators and unlocks the features gated on them:

ExtensionWhat declaring it adds
pgvectorvector, halfvec, sparsevec types; distance operators; vector-operation feature gate
postgisgeometry, geography, box2d, box3d types; spatial operators; geospatial feature gate
pg_trgmtrigram operators and similarity functions
citextcase-insensitive text type
uuid-osspUUID generation functions

Names outside that set are accepted and ignored. A second, separate list — pgvector, pg_trgm, timescaledb, postgis, pg_cron on PostgreSQL, vss (or duckdb-vss) and fts on DuckDB, fts5 and rtree on SQLite, none on ClickHouse — feeds the capability matrix, which data.svc does not resolve. Declaring timescaledb or pg_cron therefore changes nothing in the deployed service.

Each backend declares support ranges. Comparison is numeric on major and minor only, so 24.3.1 compares as 24.3.

BackendSupportedDeprecatedEnd of life
PostgreSQL13.0+12.0 – 13.0below 12.0
ClickHouse23.8+ (LTS)23.3 – 23.8below 23.3
DuckDB0.10+0.9 – 0.10below 0.9
SQLite3.37+3.35 – 3.37below 3.35

Four validators reject a schema that asks for something the resolved backend does not declare.

CheckTriggerMessage
Soft deleteAn entity carries a deletedon field on a backend without synchronous row-level UPDATEunsupported soft-delete: entity <name> uses kisai.softdelete but backend <b> does not support the contract
HistoryAn entity’s history strategy is not in the backend’s listunsupported history strategy: entity <name> declares History.Strategy=<s> but backend <b> supports only [<set>]
PartitioningA partition key on a backend with no partitioningunsupported partitioning: entity <name> declares Storage.PartitionBy=<k> but backend <b> has no partitioning support
PolicyA datastore policy enables RLS, triggers, concurrent indexes, exclusion constraints, transactional DDL, or a default engine the backend lackspolicy enables RLS but <b> does not support it (and siblings)

Soft-delete detection is purely the presence of a deletedon field.

Code or messageMeaning
datastore-not-foundThe requested datastore name is not in the tenant’s datastores map
dbpool-not-foundA datastore’s dbpool: names no key under dbpools:
invalid-db-pool-configThe pool exists but failed validation
tenant-config-unavailableThe tenant’s config could not be bound to the pools/datastores shape at all
unknown db pool type "<t>" for pool "<n>"type is not postgres/postgresql/clickhouse
invalid postgres db pool "<n>": dbuser, dbserver and database are requiredA required field is missing and no dsn was supplied
no v2 pool factory for DBPool type "<t>" (datastore=<n>)Type is valid but no factory is registered for it — anything other than postgres today
unsupported backend type "<t>" (supported: postgresql, sqlite)Engine components could not be assembled for the resolved type
no dialect implementation for datastore type "<t>"The migration path could not map the type to a dialect
DBPool declares isolation: shared but no schema: propertyShared isolation without a per-tenant namespace
no DStore config for "<tenant>":"<ds>" (pass ?datastore=<name>)No datastore selected and none resolvable for the tenant
schema has no datastore "<n>" (default=…, available=…)Name mismatch between tenant config and the loaded schema
pool: parse db port "<p>"The port in dbserver is not an integer
postgres: parse config / postgres: connectMalformed DSN, unreachable host, or bad credentials
pool/shared: tenantizePer-checkout session setup failed; the connection is closed rather than returned

Pool and endpoint diagnostics — refcounts, endpoint snapshots, tenantize error counts — are collected internally but not exposed on any route.