This document describes
main, which is ahead of what you can run. Anything here the published image does not serve yet carries a Since x.y.z marker naming the release that will bring it. Everything else is in the published version, 0.11.0 — the one the README pins. To read the reference for a release rather than formain, open this file at its tag:docs/API.mdatv0.11.0.The marker is one-directional by nature: it can say that a documented behaviour is unreleased, and no check can find a behaviour someone forgot to mark. What is held is that a marker names a version the published one has not reached, and that the version named above is the one the README pins — so a release cannot quietly leave either of them behind.
Every schema and data endpoint applies to one project. Say which:
X-Project-Id: <project id>A request that omits it is answered with 400 and the code MISSING_PROJECT.
A project is a schema namespace, not a trust boundary, and the header is not a credential. The project exists so MorphDB can operate physical schemas on its own judgement — it is an internal operating unit, not a multi-tenancy feature.
Whether a request needs a credential is a separate question, and the answer depends on one thing:
- No master secret injected — the default. No endpoint requires authentication; the service has
none. A request carrying only
X-Project-Idis served. - A master secret injected. Every endpoint except the health and metrics probes requires
Authorization: Bearer <secret>. See Connection secrets.
Either way, never forward a project id supplied by a browser or an end user — whoever picks that value picks which schemas they read. And in the default shape, run MorphDB where only your application can reach it and decide there who may see what.
Creating a project answers with the id it generated, and a caller that can read that answer needs nothing else. A deployment often cannot: the manifest that creates the project and the manifest that scopes requests to it are written before either runs, so an id that only exists after startup has nowhere to be written down.
Say which id instead:
POST /api/projects
{ "projectId": "0197c0de-0000-4000-8000-000000000001", "name": "orders" }The id is then a constant of the deployment rather than something discovered at runtime, and
re-running the same request answers 409 DUPLICATE_PROJECT_ID — enough for a start-up step to
treat "already created" as success. Two start-up steps racing for the same id get the same answer:
one is created and every other is a conflict, so the reply does not depend on which of them arrived
first. Omit the field and MorphDB generates one, which is what an application creating projects on
the fly should keep doing.
# Tables
POST /api/schema/tables # Create table
GET /api/schema/tables # List tables
GET /api/schema/tables/{name} # Get table details
PATCH /api/schema/tables/{name} # Update table
DELETE /api/schema/tables/{name} # Delete table
# Columns — a column is addressed by its id once created, not by table and name
POST /api/schema/tables/{name}/columns # Add column
PATCH /api/schema/columns/{columnId} # Update column
DELETE /api/schema/columns/{columnId} # Delete column
# Relations & Indexes
POST /api/schema/relations # Create relation
DELETE /api/schema/relations/{id} # Delete relation
POST /api/schema/tables/{name}/indexes # Create index
POST /api/schema/batch # Batch DDL operationsA relation is a virtual foreign key: it is metadata this layer checks, not (by default) a
constraint the database holds. Two flags decide how far that goes, and both default to true:
{
"name": "fk_orders_customer",
"sourceTable": "orders", "sourceColumn": "customer_id",
"targetTable": "customers", "targetColumn": "_id",
"type": "one-to-many",
"enforceOnWrite": false
}enforceOnWrite— whether writes are checked against the relation. Setfalseto declare the link without gating writes on it: joins and navigation still see it, but a row referencing a missing parent is accepted. This is what a caller that rebuilds tables wholesale needs — when tables are dropped and reloaded independently, a child can be written before its parent has been reloaded, and enforcing would reject data that is consistent at its source. A non-enforcing relation also gets no physical constraint, so nothing rejects the write underneath either.virtualCascade— whether cascade behaviour is handled at the application layer.
Both are echoed back on the response, so you can see what you got rather than what you asked for.
Omit enforceOnWrite and the project answers. A deployment that rebuilds its tables wholesale
would otherwise have to repeat "enforceOnWrite": false on every relation, and the first one that
forgets enforces:
PATCH /api/projects/{id}
{ "settings": { "defaultEnforceOnWrite": false } }The default is true, so a project that says nothing enforces. A relation that states its own value
overrides it in either direction. The answer is resolved when the relation is created and stored
on it, so changing the project default later leaves existing relations as they are — the physical
constraint was decided at the same moment, and a relation cannot start claiming enforcement that
nothing behind it holds.
settingsreplaces the whole object rather than merging, so send every setting the project should end up with — see Audit retention.
# Schema Changelog
GET /api/schema/tables/{name}/history # Table change history
GET /api/schema/changelog # Global schema changelogThe type of a column declaration takes one of the names below (case-insensitive; the aliases in
the second column are accepted and normalized to the name). The storage column is the PostgreSQL
type the value is kept in; how a value is validated or rendered on the way in and out belongs to
the type, not to the storage, so two types that share a storage column are still different types.
type |
Also accepted | Stored as | Notes |
|---|---|---|---|
text |
string |
text |
|
longtext |
text |
||
integer |
int |
integer |
Widens to biginteger and decimal — see Update Column |
biginteger |
bigint, long |
bigint |
|
decimal |
number, float, double |
numeric |
|
boolean |
bool |
boolean |
|
date |
date |
||
datetime |
timestamp |
timestamptz |
|
time |
time |
||
uuid |
guid |
uuid |
|
json |
jsonb |
jsonb |
|
array |
jsonb |
||
email |
text |
||
url |
text |
||
phone |
text |
||
singleselect |
text |
||
multiselect |
jsonb |
||
relation |
uuid |
Configured through the relation fields of the declaration | |
rollup |
— (virtual) | Declared with a rollup object; no storage column, computed on read |
|
formula |
— (virtual) | Declared with a formula object — see Formula columns; no storage column |
|
attachment |
jsonb |
See Attachment Type | |
createdtime |
timestamptz |
Defaults to now() |
|
modifiedtime |
timestamptz |
Defaults to now() |
|
createdby |
uuid |
||
modifiedby |
uuid |
Two further names exist in the vocabulary and are refused at column creation with
400 INVALID_ARGUMENT, because nothing implements them: lookup and computed. A lookup column
is declared with a lookup object beside its result type, not by naming a type — and, like rollup
and formula columns, is then virtual. The error message of an unknown or refused type lists the
accepted names, derived from the same table the server uses.
# CRUD - Auto-generated per table
GET /api/data/{table} # List records
GET /api/data/{table}/{id} # Get single record
POST /api/data/{table} # Create record
PATCH /api/data/{table}/{id} # Update record
DELETE /api/data/{table}/{id} # Delete record
# Advanced
POST /api/data/{table}/query # Complex queryBatch writes live under
/api/batch, not under/api/data— see Batch Operations.
GET /api/data/customers?filter=grade:eq:VIP&orderBy=_created_at:desc&page=1&pageSize=20| Parameter | Description | Example |
|---|---|---|
filter |
Field filtering (column:operator:value) |
grade:eq:VIP, status:neq:inactive |
orderBy |
Sort order (column:asc or column:desc) |
name:asc, _created_at:desc |
search |
Full-text search across text columns | john |
select |
Comma-separated column names. _id is always fetched, so each row's envelope id is its own |
name,email,grade |
state |
Row state filter (if enabled) | valid, draft, error, all |
page |
Page number | 1 |
pageSize |
Records per page (max 1000) | 20 |
| Operator | Description | Example |
|---|---|---|
eq |
Equals | status:eq:active |
neq |
Not equals | status:neq:deleted |
gt |
Greater than | price:gt:100 |
gte |
Greater than or equal | age:gte:18 |
lt |
Less than | stock:lt:10 |
lte |
Less than or equal | score:lte:50 |
like |
Pattern match (case-sensitive, % wildcards) |
name:like:Jo% |
ilike |
Pattern match (case-insensitive) | name:ilike:jo% |
contains |
String contains | name:contains:john |
startswith |
String starts with | email:startswith:admin |
endswith |
String ends with | file:endswith:.pdf |
An operator outside this list is answered with 400 listing the supported set — it is never
silently coerced. (in/isnull were documented here once but no server ever accepted them — on
this parameter or anywhere else; the operator vocabulary above is the whole set, on every surface.)
For predicates the flat filter parameter cannot express — AND/OR trees — post a filter tree.
A node is either a condition or a group (discriminated by $type); conditions use the same
operator vocabulary as the filter parameter above:
POST /api/data/customers/query
Content-Type: application/json
X-Project-Id: <project id>
{
"filter": {
"$type": "group",
"logic": "and",
"filters": [
{ "$type": "condition", "column": "grade", "operator": "eq", "value": "vip" },
{ "$type": "condition", "column": "amount", "operator": "gte", "value": 50 }
]
},
"select": ["name", "amount"],
"orderBy": ["amount:desc"],
"page": 1,
"pageSize": 10
}filter— optional; acondition(column,operator,value) or agroup(logic:"and"|"or",filters: child nodes).select— optional column list; omitted selects all._idis always fetched even when left out, so the envelopeidof every row is that row's own.orderBy— optionalcolumnorcolumn:descentries.page/pageSize— 1-based;pageSizeis clamped to the server maximum.
The response is the same paged envelope as GET /api/data/{table}:
{ "data": [...], "pagination": { "page", "pageSize", "totalCount", "totalPages", "hasNext", "hasPrevious" } }.
These examples run verbatim in the contract suite (ComplexQueryApiTests) — if the wire shape
drifts, the suite fails before the docs lie.
POST /api/batch/data # Mixed operations, in order, across tables
POST /api/batch/data/{table}/insert # Insert many into one table
PATCH /api/batch/data/{table} # Update many, selected by filter
DELETE /api/batch/data/{table}?filter=... # Delete many, selected by filter
PUT /api/batch/data/{table} # Upsert many, matched on key columns
POST /api/batch/data/{table}/seed # Seed rows (upsert, ignoring conflicts)
POST /api/batch/transaction # Atomic cross-entity operationsAn operation names a table and a data method — it is not an embedded HTTP request:
POST /api/batch/data
Content-Type: application/json
X-Project-Id: <project id>
{
"operations": [
{ "method": "INSERT", "table": "customers", "data": { "name": "Acme" } },
{ "method": "UPDATE", "table": "customers", "id": "…", "data": { "grade": "VIP" } },
{ "method": "DELETE", "table": "orders", "id": "…" },
{ "method": "UPSERT", "table": "customers", "data": { "email": "a@example.com", "name": "Acme" }, "keyColumns": ["email"] }
]
}Operations run in order and are reported individually. A batch containing failed operations still
returns 200 — read results rather than the status code:
{
"results": [
{ "index": 0, "success": true, "data": { "_id": "…" }, "affectedRows": 1 },
{ "index": 1, "success": false, "error": "null value in column 'name'" }
],
"successCount": 1,
"failureCount": 1
}Inserting many rows into one table has a shorter form that takes a bare array and returns the same response shape:
POST /api/batch/data/customers/insert
[ { "name": "Acme" }, { "name": "Globex" } ]Endpoint: /graphql
The schema is fixed and does not grow a type per table. A table is named in an argument —
records(table: "customers") — and a row travels as the Any scalar under data, keyed by the
logical column names the table was declared with. Creating, altering or dropping a table changes
no part of this schema, which is why a client written against the operations below keeps working
as the tables beneath it change. There is no customers field, no generated Customer type, and
no per-table mutation or subscription.
Introspection is refused outside the Development environment. The published image runs with
ASPNETCORE_ENVIRONMENT=Production, so a __schema query there answers with HC0046
(Introspection is not allowed for the current request) rather than the schema. For those
deployments this section is the schema reference: the operations below are the whole root
surface, and a contract test holds them to what the server actually serves in both directions.
query {
health
tables {
name
version
columns { name type nullable unique indexed }
}
}query {
table(name: "customers") {
name
version
columns { name type nullable unique indexed }
indexes { name type unique columns }
relations { name type sourceColumn targetTableId targetColumnId }
createdAt
updatedAt
}
}table answers null for a name the current project does not have. Physical names are never part
of either answer — see SYSTEM_COLUMNS.md.
query {
records(table: "customers", first: 10, filter: "grade:eq:VIP", orderBy: "name:asc") {
edges {
node { id data createdAt updatedAt }
cursor
}
pageInfo { hasNextPage hasPreviousPage startCursor endCursor totalCount }
totalCount
}
}filter and orderBy are compact strings here, not input objects:
| Argument | Grammar | Notes |
|---|---|---|
filter |
column:operator:value, comma-separated |
The operators are the same set as on REST; ne, ge and le are accepted here as spellings of neq, gte and lte. The value is read as null, boolean, number, UUID, timestamp or string, in that order |
orderBy |
column or column:asc/column:desc, comma-separated |
Defaults to newest first when omitted |
first |
page size | Defaults to 20, capped at 100 |
after |
a cursor from a previous page |
Take it from pageInfo.endCursor — the value is opaque |
A single row is fetched by id:
query {
record(table: "customers", id: "00000000-0000-0000-0000-000000000000") {
id
data
createdAt
updatedAt
}
}query {
aggregate(
table: "orders"
aggregations: [
{ function: COUNT, alias: "orders" }
{ function: SUM, column: "amount", alias: "revenue" }
]
groupBy: ["status"]
filter: [{ column: "shipped", operator: "eq", value: true }]
having: [{ alias: "revenue", operator: "gt", value: 1000 }]
orderBy: [{ column: "revenue", direction: "desc" }]
limit: 10
offset: 0
) {
data
totalGroups
metadata { rowsScanned executionTimeMs }
}
}Note that aggregate takes structured filter and orderBy inputs while records takes
strings. The two are not interchangeable. Functions are COUNT, SUM, AVG, MIN, MAX and
COUNT_DISTINCT; column is omitted for a plain count.
Every mutation answers with the same envelope — success, data, error, errorCode — so a
failure is read from the payload rather than from a transport status.
mutation {
ping
createRecord(table: "customers", data: { name: "Acme", grade: "VIP" }) {
success
data { id data createdAt updatedAt }
error
errorCode
}
}mutation {
updateRecord(
table: "customers"
id: "00000000-0000-0000-0000-000000000000"
data: { grade: "GOLD" }
) {
success
data { id data }
error
errorCode
}
}mutation {
upsertRecord(
table: "customers"
data: { email: "a@example.com", grade: "VIP" }
keyColumns: ["email"]
) {
success
data { id data }
error
errorCode
}
}mutation {
createRecords(
table: "customers"
records: [{ name: "Acme" }, { name: "Globex" }]
) {
success
data { id data }
error
errorCode
}
}mutation {
deleteRecord(table: "customers", id: "00000000-0000-0000-0000-000000000000") {
success
data
error
errorCode
}
}Subscriptions are per table and per event kind, and the table is again an argument.
subscription {
onRecordCreated(table: "customers") {
table
changeType
record { id data createdAt updatedAt }
timestamp
}
}subscription {
onRecordUpdated(table: "customers") {
table
changeType
record { id data }
timestamp
}
}subscription {
onRecordChanged(table: "customers") {
table
changeType
record { id data }
timestamp
}
}Deletions carry the id of the row that is gone rather than its contents:
subscription {
onRecordDeleted(table: "customers") {
table
recordId
timestamp
}
}changeType is CREATED, UPDATED or DELETED. The SignalR hub described under
WebSocket (Real-time) is a separate surface with its own payload shape;
the two are not the same wire.
Endpoint: /odata
Standard OData v4 protocol for enterprise tool integration (Excel, Power BI).
# Metadata
GET /odata/$metadata
# Queries
GET /odata/Customers?$filter=grade eq 'VIP'&$orderby=name desc&$top=10
GET /odata/Customers?$select=name,email&$skip=20
GET /odata/Customers?$count=true
# CRUD — the key is the row's _id, and the metadata document types it as Edm.Guid,
# so it is written bare rather than quoted
GET /odata/Customers(00000000-0000-0000-0000-000000000000)
POST /odata/Customers
PATCH /odata/Customers(00000000-0000-0000-0000-000000000000)
DELETE /odata/Customers(00000000-0000-0000-0000-000000000000)
# Batch
POST /odata/$batchSupported query options: $filter, $orderby, $top, $skip, $select, $count
$count is a query option, not a path segment: ?$count=true puts @odata.count on the
collection response, and /odata/Customers/$count is not a route. Relations are not navigable
from this surface — the metadata document declares no navigation properties, so there is nothing
for $expand to reach. Read a related table as its own entity set instead.
Connecting from a BI tool. These requests are scoped like every other one — by the
X-Project-Id header, or by the secret the caller authenticates with. Both travel as headers, and
a tool that offers only a URL and a choice of built-in credential has nowhere to put either. In
Power Query that means the point-and-click OData dialog cannot reach this surface; its query
language can, because OData.Feed takes a record of headers as its second argument:
OData.Feed("https://<host>/odata", [#"X-Project-Id" = "<project id>"])
The entity set names in the metadata document are the PascalCase forms of the table names —
order_items is served as OrderItems — while the columns keep the names they were declared
with. Rows carry _id, _created_at and _updated_at alongside them.
Endpoint: /hubs/morph (SignalR)
The connection is scoped the same way every other request is, by X-Project-Id — and it is scoped
at connect time, not per subscription. A connection that names no project is refused rather than
served a stream that would stay empty forever.
That header has to ride the HTTP request that establishes the connection, which a browser cannot do on the WebSocket transport. Ask for a transport that carries headers:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/morph", {
headers: { "X-Project-Id": "<project id>" },
transport: signalR.HttpTransportType.LongPolling
})
.build();
await connection.start();
await connection.invoke("Subscribe", "customers");
connection.on("RecordCreated", (message) => { /* message.table, message.data, … */ });
connection.on("RecordUpdated", (message) => { /* … */ });
connection.on("RecordDeleted", (message) => { /* … */ });| Method | Arguments |
|---|---|
Subscribe |
table name |
Unsubscribe |
table name |
SubscribeMany |
table names |
UnsubscribeMany |
table names |
GetSubscriptions |
none — answers the table names this connection is subscribed to |
A subscription is per table and nothing narrower. Subscribe takes the table name and nothing
else, and every subscriber to a table receives every change to it. Filter on the receiving side.
| Event | Payload |
|---|---|
RecordCreated |
table recordId operation data timestamp |
RecordUpdated |
table recordId operation data timestamp |
RecordDeleted |
table recordId timestamp |
Subscribed |
tableName |
Unsubscribed |
tableName |
OnError |
code message |
operation is INSERT, UPDATE or DELETE — upper case, and not the same vocabulary as the
GraphQL subscription's changeType. A deletion carries the id of the row that is gone and no
data. There is no before-image on any event. data is keyed by the same logical column names
REST and GraphQL use — not the physical (col_…) names the trigger payload carries internally.
Register webhooks for external system integration:
POST /api/webhooks
Content-Type: application/json
{
"name": "Order notification",
"table": "orders",
"events": ["insert", "update"],
"url": "https://external.system/callback",
"headers": { "Authorization": "Bearer xxx" },
"filter": { "status": "completed" }
}The signing secret is generated by the server, not supplied by the caller — it is not a request field. The create response carries it once:
{ "id": "…", "secret": "1f6b3fb9e8a0814c…", "isActive": true, "…": "…" }Read it back later and it is gone — only the id and role are recorded, the same as Connection secrets.
filter narrows delivery to rows matching every key — flat, AND-combined scalar-literal equality
({"status": "completed", "priority": "high"} fires only when both hold). There is no operator
syntax and no nesting; registering a filter whose value is an object or array is rejected with
400 INVALID_WEBHOOK_FILTER. A filter never matches a delete event — a delete carries no row
data to compare against, so a webhook that needs to see deletions must subscribe without a filter
on that event.
events accepts insert, update, and delete; a webhook subscribes to one or more of them.
Webhook payload. A delivery's fields are camelCase, the same as every other surface in this document — the payload is signed and posted to a third-party endpoint rather than returned to a client of this API, but it is still this API naming its own fields:
{
"event": "insert",
"table": "orders",
"recordId": "0f3c1e2a-…",
"data": { "id": "123", "status": "completed" },
"timestamp": "2025-01-01T00:00:00Z"
}Every write door — data CRUD, batch, seed, upsert, bulk import rows, GraphQL mutations — goes
through the same write pipeline: constraint validation (required / unique / FK / CHECK) is
validated and system columns (_id, timestamps, _version, audit fields) are applied uniformly.
The request body of a data write is the record itself — there is no { "data": ..., "options": ... }
envelope:
POST /api/data/{table}
Content-Type: application/json
{ "name": "John", "email": "john@example.com" }Behaviour is selected per request with query parameters:
| Parameter | Effect |
|---|---|
?mode=draft |
Skips validation and stores the row with _row_state = 'draft' (requires row state enabled on the table) |
?ignoreUnknown=true |
Fields naming no declared column are dropped instead of failing the write. Without it, an unknown field is a 400 UNKNOWN_COLUMN naming the field — a typo must not become silent data loss |
The validation and auto-apply behaviours below are pipeline policy (what the server enforces), not request-body switches.
| Option | Default | Description |
|---|---|---|
validateRequired |
true |
Validate required fields (NOT NULL) |
validateForeignKeys |
true |
Validate foreign key references exist |
validateUnique |
true |
Validate unique constraints |
validateCheck |
true |
Validate CHECK constraints (supports AND/OR expressions) |
| Option | Default | Description |
|---|---|---|
applyDefaults |
true |
Apply default values for missing fields |
applyTimestamps |
true |
Auto-manage _created_at and _updated_at |
applyVersion |
true |
Auto-manage _version for optimistic locking |
applyAuditFields |
true |
Auto-manage _created_by and _updated_by |
applyOwnership |
true |
Auto-manage _owner_id for ownership tables |
applySortOrder |
true |
Auto-manage _sort_order for hierarchy tables |
| Option | Default | Description |
|---|---|---|
deferValidation |
false |
Defer validation until after bulk insert |
expectedVersion |
null |
Expected version for optimistic locking |
Default (all enabled):
{ "validateRequired": true, "validateForeignKeys": true, "validateUnique": true, "validateCheck": true, "applyDefaults": true, "applyTimestamps": true, "applyVersion": true }Bulk Import (deferred validation):
{ "validateRequired": true, "validateForeignKeys": false, "validateUnique": false, "validateCheck": false, "applyDefaults": true, "applyTimestamps": true, "applyVersion": false, "deferValidation": true }No Validation (use with caution):
{ "validateRequired": false, "validateForeignKeys": false, "validateUnique": false, "validateCheck": false, "applyDefaults": false, "applyTimestamps": false, "applyVersion": false }Bulk import and export are asynchronous jobs. The request returns 202 Accepted with a job
id; progress and results are read from the job endpoints. The format is part of the path, not a
query parameter, because each format takes its own options.
# Import — one endpoint per format
POST /api/bulk/{table}/import/csv
POST /api/bulk/{table}/import/json
POST /api/bulk/{table}/import/ndjson
Content-Type: text/csv
name,email,grade
John Doe,john@example.com,VIP
# Export — options travel in the body
POST /api/bulk/{table}/export/csv # { "columns": ["name", "email"], "delimiter": ",", "includeHeader": true }
POST /api/bulk/{table}/export/json # { "columns": [...], "pretty": false }
POST /api/bulk/{table}/export/xlsx # { "columns": [...] }
# Following a job
GET /api/bulk/jobs/{jobId}/progress # Progress while it runs
POST /api/bulk/jobs/{jobId}/cancel # Stop it
GET /api/bulk/import # List import jobs
GET /api/bulk/export # List export jobs
GET /api/bulk/export/{jobId}/download # Fetch a finished exportcolumns selects which of the table's columns the file carries, in that order; omitted, the file
carries every column the schema surface lists. A name that is not one of those — a typo, a physical
name, a system-internal column — is refused with 400 COLUMN_NOT_FOUND when the job is requested,
before anything runs. An export is the whole table in storage order — there is no filter or
orderBy on an export request (a body naming either is refused as an unknown member, like any
other). Filter first through the query API if you need a subset.
An import job that finishes with errorCount > 0 carries errorDetails — up to the first 100
per-row failures, each { "rowNumber": <1-based>, "error": "<message>" } — so a row-level failure
is diagnosable without re-sending the file. errorDetailsTruncated is true once errorCount
exceeds 100. errorMessage (singular) is unrelated: it is set only when the whole job died from an
unhandled exception, not on a per-row basis.
Update a column's metadata and/or physical constraints:
PATCH /api/schema/columns/{columnId}
Content-Type: application/json
{
"name": "new_column_name",
"type": "biginteger",
"nullable": true,
"unique": false,
"check": "value > 0",
"default": "0",
"version": 3
}All fields except version are optional. Only provided fields are changed.
| Field | Description |
|---|---|
name |
New logical column name |
type |
New data type (safe type widening only: integer→biginteger→decimal, *→text) |
nullable |
Whether the column allows null |
unique |
Whether the column has a unique constraint (physical DDL) |
check |
Check expression (virtual constraint) — see Expression fields |
default |
Default value — see Expression fields |
version |
Expected schema version for optimistic concurrency |
default and an index where are written into DDL, so what they may contain is bounded. check
never reaches DDL at all — it is a virtual constraint, enforced by the app-layer evaluator, and
a declaration is accepted only when that evaluator can enforce it (a stored-but-unenforceable CHECK
would constrain nothing, silently). A value outside these bounds is answered with 400 and an
error code, not applied.
| Field | Accepted | Rejected (400) |
|---|---|---|
default |
A literal (0, pending, O'Brien — quoted for you), or one of gen_random_uuid(), now(), transaction_timestamp(), statement_timestamp(), clock_timestamp() |
Any other value containing parentheses → INVALID_DEFAULT. Notably uuid_generate_v4(): it needs the uuid-ossp extension, which managed PostgreSQL does not grant. Use gen_random_uuid(). |
check |
The CHECK grammar: <field> <op> <value> or <field> <op> <field> (op: > >= < <= = == != <>; value: a 'quoted string', number, true/false/null), <field> MATCHES '<regex>', combined with AND/OR and parentheses — age >= 0 AND age <= 150, status = 'a)b', email MATCHES '^[^@]+@[^@]+$' |
Anything else — SQL functions, IN, BETWEEN, the ~ operator (use MATCHES) → INVALID_ARGUMENT listing the supported forms |
index where |
Any predicate that stays within itself — age >= 0, status = 'a)b' |
Unbalanced parentheses or quotes, a statement separator, or a comment → INVALID_EXPRESSION |
MorphDB requires no PostgreSQL extension. This is what lets it run on Azure Database for PostgreSQL,
Cloud SQL and RDS, where CREATE EXTENSION is gated behind a server-parameter allow-list.
A formula column is declared like any other column — in POST /api/schema/tables or
POST /api/schema/tables/{name}/columns — with a formula object beside its type:
{
"name": "email_domain",
"type": "text",
"nullable": true,
"formula": {
"formula": "SUBSTRING({email}, '@', 999)",
"returnType": "text"
}
}| Field | Description |
|---|---|
formula |
The expression. Required. |
returnType |
The type the expression evaluates to, one of the column types. Defaults to text. |
outputFormat |
Optional presentation hint stored with the column (a format string); the API does not apply it. |
A formula column is virtual: no column is created in storage, and the value is computed when a
row is read, by translating the expression to SQL over the table's physical columns. The same is
true of a lookup or rollup column — a declaration carrying any of the three configuration
objects creates a virtual column. A formula column therefore cannot be written to, indexed, or
given a default.
Expression syntax. Reference the table's columns by logical name in braces ({email});
combine with the arithmetic operators + - * /, the comparison operators = != <> < <= > >=, the
keywords AND OR NOT, string and numeric literals, and the functions below. A function outside
this list is refused when the column is declared (400), naming it.
| Group | Functions |
|---|---|
| String | CONCAT UPPER LOWER TRIM LTRIM RTRIM LEFT RIGHT SUBSTRING REPLACE LENGTH CHAR_LENGTH |
| Numeric | ABS ROUND FLOOR CEIL CEILING MOD POWER SQRT LOG LOG10 EXP SIGN |
| Date | NOW TODAY DATE YEAR MONTH DAY HOUR MINUTE SECOND DATEADD DATEDIFF DATE_TRUNC CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP |
| Conditional | IF IFS SWITCH COALESCE NULLIF |
| Boolean | AND OR NOT |
| Aggregation (over a lookup) | SUM AVG MIN MAX COUNT |
| Conversion | CAST TO_TEXT TO_NUMBER TO_DATE TO_BOOLEAN |
The attachment data type stores file metadata as JSONB. MorphDB does not manage file storage directly — files should be stored in external services (S3, Azure Blob, etc.) and referenced by URL.
{
"url": "https://s3.example.com/bucket/file.pdf",
"filename": "report.pdf",
"size": 1048576,
"mimeType": "application/pdf",
"uploadedAt": "2026-01-01T00:00:00Z"
}POST /api/schema/tables/{name}/columns
{
"name": "document",
"type": "attachment",
"nullable": true
}POST /api/data/{table}
{
"document": {
"url": "https://storage.example.com/file.pdf",
"filename": "file.pdf",
"size": 2048,
"mimeType": "application/pdf"
}
}| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | URL to the file in external storage |
filename |
string | Yes | Original file name |
size |
number | No | File size in bytes |
mimeType |
string | No | MIME type |
uploadedAt |
string | No | ISO 8601 timestamp |
A relational database identifies whoever opens a connection with a user and a password. There is no connection to open here — every access is an API call — so the same position is held by a secret. A secret is not a person: it has no email, no invitation and no organization. It is issued, it carries a role, and it is revoked.
Authentication is off unless you inject a master secret, and this is stated rather than implied: an installation that advertises a boundary it does not enforce is worse than one that says it enforces none.
environment:
Security__MasterSecret: <a long random string>The master secret arrives the way PostgreSQL is given POSTGRES_PASSWORD — from the deployment,
before anything can ask for it. No API issues it, and it is never written to the database. That
is what keeps the bootstrap acyclic: the authority to issue credentials never originates inside the
API.
With it injected, every endpoint requires a secret except /health, /health/live, /health/ready
and /metrics — machine surfaces that must answer before any credential is distributed.
Authorization: Bearer mdb_<secret>A request with no secret or an unrecognized one is answered 401 UNAUTHENTICATED. A recognized
secret that may not do what was asked is answered 403 FORBIDDEN.
This includes /graphql and /hubs/morph — a boundary that held on REST and not on the other
two would not be a boundary. It has a consequence worth knowing before you turn enforcement on:
⚠️ Browser WebSocket clients cannot authenticate. The browser WebSocket API cannot set anAuthorizationheader, so a SignalR or GraphQL-subscription client running in a page is refused at/hubs/morph/negotiateonce a master secret is injected. Server-side and desktop clients set the header normally. If you need browser real-time today, terminate authentication in a proxy in front and leave MorphDB's own enforcement off.
Once a caller is identified, the audit trail records which secret acted — its id and role, never the secret itself.
These routes require the master secret; an issued secret cannot reach them. Without a master
secret injected they answer 503 SECRETS_NOT_CONFIGURED.
POST /api/security/secrets # Issue a secret — the plaintext is returned once
GET /api/security/secrets # List issued secrets (never hashes or plaintexts)
DELETE /api/security/secrets/{secretId} # Revoke{
"name": "reporting-service",
"role": "analyst",
"projectId": null
}role— free-form. MorphDB does not enumerate the roles a database may have; meaning comes from the policies that reference it through{{role}}(see below). The namesmasterandserviceare reserved and are refused with400 VALIDATION_ERROR.projectId—nullfor every project, or a project id to confine the secret to it. A confined secret addressing another project is answered403 FORBIDDEN.
The response carries the plaintext once. It is stored only as a hash; if it is lost, issue another and revoke the old one. Revocation keeps the row so audit records retain a name for it.
Only the master secret bypasses row-level security. An issued secret is subject to the same policies
an anonymous caller is — with {{role}} now resolving to something.
Values can be encrypted at rest. The API never shows ciphertext: a row is encrypted when it is written and decrypted when it is read, on every surface, so a consumer sees the same values with encryption on or off. What changes is what is stored.
Turning it on. Encryption is active only when a master key is configured — Encryption:MasterKey
(Encryption__MasterKey as an environment variable), a base64-encoded 32-byte key for AES-256-GCM.
With no key, nothing is encrypted and the routes below answer 503. The other settings in the
Encryption section:
| Setting | Default | Meaning |
|---|---|---|
KeyVersion |
1 |
The version new values are encrypted under; raise it and rotate to re-encrypt |
Algorithm |
AES-256-GCM |
Recorded with the data for forward compatibility |
EncryptAllByDefault |
true |
Encrypt every column of an encryptable type unless excluded |
ExcludedColumns |
the system columns | Logical names never encrypted |
Which columns. With EncryptAllByDefault, every column whose type is text, longtext,
email, phone, url, json, integer, biginteger or decimal is encrypted, except the
excluded names; other types (dates, booleans, uuids, selections, relations) are stored in clear.
The column metadata also carries a per-column encrypted flag that the writer honours, but no
request field sets it — the API exposes no way to mark one column encrypted and leave another
in clear, so in practice the choice is the EncryptAllByDefault setting for the whole service.
Key rotation. All under /api/security, scoped by X-Project-Id like the rest of the API, and
503 while encryption is not enabled:
| Route | Does |
|---|---|
GET /api/security/encryption/info |
{ enabled, currentKeyVersion, availableKeyVersions[] } |
GET /api/security/encryption/status/{table} |
Rotation state of one table: state, currentKeyVersion, targetKeyVersion, progressPercent, rowsProcessed, totalRows, estimatedTimeRemainingMs, startedAt, lastRotatedAt |
GET /api/security/encryption/validate/{table} |
Whether every encrypted value of the table is under the current key: isValid, expectedKeyVersion, totalEncryptedValues, currentVersionCount, oldVersionCount, unencryptedCount, versionBreakdown |
POST /api/security/encryption/rotate/{table} |
Re-encrypt one table under the current key version; answers { success, tableName, previousKeyVersion, newKeyVersion, rowsProcessed, columnsRotated, durationMs, startedAt, completedAt, errorMessage } |
POST /api/security/encryption/rotate |
The same for every table of the project |
A policy narrows what a table's rows answer, per operation. Applicable policies are combined with
AND, so adding one can only ever restrict a read further.
GET /api/security/policies/{tableName} # Policies applying to a table
POST /api/security/policies # Create a policy
PATCH /api/security/policies/{policyId} # Update name, expression, description or is_active
DELETE /api/security/policies/{policyId} # Delete a policy{
"name": "owner_reads_only",
"tableName": "orders",
"policyType": "Select",
"expression": "owner_id = {{user_id}}"
}policyType is Select, Insert, Update, Delete or All. The expression is a SQL predicate
over the table's own columns, with {{user_id}}, {{email}}, {{role}}, {{project_id}},
{{is_authenticated}} and {{claims.<name>}} substituted from the request's security context
before the query runs. Substituted values are emitted as quoted literals — a caller's identity
cannot become part of the predicate.
Because the service is unauthenticated, an HTTP request's context is the project's anonymous one:
{{project_id}} carries the header's value, {{is_authenticated}} is false, and the
user-bearing placeholders substitute NULL — a policy written against {{user_id}} therefore
matches no rows over HTTP. That is fail-closed on purpose; there is currently no way for a caller
to assert an end-user identity to this service.
The expression is a predicate, not a statement. It is checked before it is stored and again
before it is used: a statement separator, a comment opener, an unbalanced parenthesis or an
unterminated quote is refused with INVALID_EXPRESSION. A stored policy that fails that check
fails the read rather than being quietly dropped from it — a security rule that silently stops
applying is worse than an error.
A project decides how much of its own audit history it keeps, through its settings:
POST /api/projects
{ "name": "orders", "settings": { "auditLogRetentionDays": 30 } }Entries older than the window are removed by the server on a recurring sweep — the audit table is
not reachable from the API, so keeping it within its declared size is the server's obligation
rather than something to ask for. The sweep interval is a deployment setting
(AuditRetention:SweepInterval, one hour by default); the window is per project.
Omit the field, or set it to null, and nothing is removed. A project that has not asked for a
window keeps everything, so introducing the setting does not start deleting history anywhere.
Zero or a negative number is refused with INVALID_ARGUMENT rather than stored — a window that
cannot be applied would read back as configured while governing nothing.
The window can be turned on after the fact and governs history already written:
PATCH /api/projects/{id}
{ "settings": { "auditLogRetentionDays": 30 } }
settingsreplaces the whole object, it does not merge. Fields you omit go back to their defaults rather than keeping their stored values, so send the settings you want the project to end up with — read the project first if you are changing one field of several.
GET /health # Overall health
GET /health/live # Liveness probe
GET /health/ready # Readiness probe/health runs every registered check (the database always; Redis only when a Redis connection
string is configured), /health/ready runs the checks tagged ready (the database), and
/health/live runs none — it answers as long as the process is serving requests. A healthy report
is 200, an unhealthy one 503; either way the body is:
{
"status": "Healthy",
"totalDuration": "00:00:00.0123456",
"entries": {
"postgresql": { "data": {}, "duration": "00:00:00.0098765", "status": "Healthy", "tags": ["db", "ready"] }
}
}status is Healthy, Degraded, or Unhealthy. A failing entry adds description and
exception (the failure's message); members that would be null are omitted.
Every error is a JSON envelope — no path answers a 5xx with an empty body:
{ "error": "ValidationError", "message": "what went wrong, and what is possible", "code": "VALIDATION_ERROR" }The envelope belongs to routes this API serves. A URL that matches no route is answered by the
framework, with 404 and no body at all — there is no code to branch on because nothing here
handled it. A client that parses every non-2xx as an envelope should treat an empty body as "this
address is not part of the API", which is a different mistake from any error listed below: the
others say a request was understood and refused.
code is the machine-readable contract; branch on it, not on message text. Request envelopes are
strict: a JSON member a request body does not declare (a typo'd filters for filter, a colums
for columns) answers 400 INVALID_ARGUMENT naming the member and listing the supported ones —
never a silent drop. (Row-data bodies are dictionaries — arbitrary members are the point; their
unknown-field policy is the write pipeline's UNKNOWN_COLUMN.) A 4xx means the
request must change before retrying; a 500 INTERNAL_ERROR is a service defect (its message is a
fixed string — internal exception text never reaches the wire) and retrying may succeed.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
A value failed validation — a required / unique / FK / CHECK constraint, a type mismatch, or any mix of write-validation causes; physical NOT NULL / UNIQUE / foreign-key violations translate to the same code |
| 400 | UNKNOWN_COLUMN |
A write named a column the table does not declare (see ?ignoreUnknown=true) — answered whenever undeclared fields are the only thing wrong with the write |
| 400 | COLUMN_NOT_FOUND |
A query referenced a column the table does not have |
| 400 | INVALID_FILTER |
A malformed filter expression, or an unknown filter operator |
| 400 | INVALID_WEBHOOK_FILTER |
A webhook filter value that is not a scalar literal — object and array values are not supported (see Webhook) |
| 400 | INVALID_ARGUMENT |
A malformed value elsewhere in the request (e.g. an unknown column type — the message lists the supported set) |
| 400 | MISSING_PROJECT |
The request did not say which project it applies to — send X-Project-Id |
| 400 | INVALID_EXPRESSION |
A CHECK predicate, index predicate, policy expression, or a view's join condition or column expression that could escape the clause it is written into |
| 400 | TABLE_HAS_DEPENDENTS |
Deleting a table another table still references — delete those relations first |
| 400 | EMPTY_BATCH |
A batch request with no operations |
| 400 | EMPTY_DATA |
A batch write with no rows |
| 400 | EMPTY_TRANSACTION |
A transaction with no operations |
| 400 | EMPTY_RECORD_IDS |
A bulk-delete with no record ids |
| 400 | MISSING_KEY_COLUMNS |
A batch upsert without the key columns to match on |
| 400 | FILTER_REQUIRED |
A batch update-by-filter without a filter (a full-table write must be said out loud) |
| 400 | AGGREGATION_REQUIRED |
An aggregate query with no aggregation |
| 400 | ROW_STATE_NOT_ENABLED |
A row-state operation on a table whose systemColumns.rowState is off |
| 400 | JOB_NOT_COMPLETED |
Reading the result of a bulk job that has not finished |
| 400 | NOT_MATERIALIZED |
Refreshing or reading a view that is not materialized |
| 400 | INVALID_NAME |
A table or column name that is empty or over the length limit |
| 400 | RESERVED_NAME |
A name in the space the system reserves for itself (a leading underscore on a column, the system prefix on a table) |
| 400 | SYSTEM_COLUMN |
An attempt to alter or drop a column the table owns rather than the caller |
| 400 | UNSAFE_TYPE_CAST |
A column type change whose existing values cannot be converted — export, reload, and change the declaration instead |
| 400 | INVALID_OPERATION |
A schema operation that is well-formed but cannot apply to this target |
| 400 | DDL_EXECUTION_FAILED |
The database refused the schema statement — the message carries what it said |
| 400 | BATCH_DDL_FAILED |
One operation in a batch DDL request failed; the batch is not applied |
| 404 | TABLE_NOT_FOUND |
The table (or the project the request scoped it to) does not exist |
| 404 | INDEX_NOT_FOUND |
The index id does not exist |
| 404 | RELATION_NOT_FOUND |
The relation id does not exist |
| 404 | WEBHOOK_NOT_FOUND |
The webhook id does not exist |
| 404 | RECORD_NOT_FOUND |
The record id does not exist in the table |
| 404 | PROJECT_NOT_FOUND |
The project id does not exist |
| 404 | VIEW_NOT_FOUND |
The view does not exist |
| 404 | JOB_NOT_FOUND |
The bulk job does not exist |
| 404 | AUDIT_LOG_NOT_FOUND |
The audit log entry does not exist |
| 401 | UNAUTHENTICATED |
Authentication is enforced and the request presented no valid secret — send Authorization: Bearer <secret>. Only answered when a master secret is injected; see Connection secrets |
| 403 | FORBIDDEN |
The secret is recognized but may not do this — it is confined to another project, or the route requires the master secret |
| 404 | NOT_FOUND |
Another addressable resource (entity set, policy…) does not exist |
| 409 | DUPLICATE_NAME |
Creating a table/column under a name that is taken |
| 409 | DUPLICATE_SLUG |
Creating a project under a slug that is taken |
| 409 | DUPLICATE_PROJECT_ID |
Creating a project under an id that is taken — only reachable when the request chooses the id. A deleted project still holds its id |
| 409 | SCHEMA_VERSION_CONFLICT |
An optimistic schema update lost the race |
| 409 | LOCK_ACQUISITION_FAILED |
A concurrent schema operation holds the lock — retry |
| 500 | INTERNAL_ERROR |
Our defect, logged on the server — never your request's fault |
| 503 | SECRETS_NOT_CONFIGURED |
A secret-management route was called on a service that has no master secret injected — there is no caller it could tell apart, so it declines rather than issuing credentials to anyone who reaches the port |