Skip to content

Latest commit

 

History

History
1263 lines (1019 loc) · 51.8 KB

File metadata and controls

1263 lines (1019 loc) · 51.8 KB

API Reference

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 for main, open this file at its tag: docs/API.md at v0.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.

Scoping a request

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.

What this is not

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-Id is 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.

Choosing the project id

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.

REST API

Schema Management (DDL)

# 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 operations

A 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. Set false to 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.

settings replaces 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 changelog

Column types

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

Data Operations (DML)

# 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 query

Batch writes live under /api/batch, not under /api/data — see Batch Operations.

Query Parameters

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

Filter Operators

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

Complex query — POST /api/data/{table}/query

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; a condition (column, operator, value) or a group (logic: "and"|"or", filters: child nodes).
  • select — optional column list; omitted selects all. _id is always fetched even when left out, so the envelope id of every row is that row's own.
  • orderBy — optional column or column:desc entries.
  • page / pageSize — 1-based; pageSize is 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.

Batch Operations

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 operations

An 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" } ]

GraphQL

Endpoint: /graphql

The schema is fixed and does not grow a type per table. A table is named in an argumentrecords(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.

Reading the schema

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.

Reading rows

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
  }
}

Aggregating

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.

Writing rows

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
  }
}

Subscribing

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.


OData

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/$batch

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


WebSocket (Real-time)

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) => { /* … */ });

Hub methods

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.

Client events

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.


Webhook

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"
}

Write Options

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.

Validation Options

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)

Auto-Apply Options

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

Advanced Options

Option Default Description
deferValidation false Defer validation until after bulk insert
expectedVersion null Expected version for optimistic locking

Preset Configurations

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 Operations

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 export

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


Schema Evolution

Update Column

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

Expression fields

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.


Formula columns

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

Attachment Type

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.

Schema

{
  "url": "https://s3.example.com/bucket/file.pdf",
  "filename": "report.pdf",
  "size": 1048576,
  "mimeType": "application/pdf",
  "uploadedAt": "2026-01-01T00:00:00Z"
}

Usage

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

Connection secrets

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.

Turning it on

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 an Authorization header, so a SignalR or GraphQL-subscription client running in a page is refused at /hubs/morph/negotiate once 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.

Issuing secrets

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 names master and service are reserved and are refused with 400 VALIDATION_ERROR.
  • projectIdnull for every project, or a project id to confine the secret to it. A confined secret addressing another project is answered 403 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.

Column encryption

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

Row-Level Security Policies

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.


Audit retention

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 } }

settings replaces 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.


Health Checks

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.


Errors

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