Declare structures
A virtual data structure is how you tell Orkestia what your app stores. You do not create Postgres tables by hand. You declare databases, tables, fields, indexes, uniqueness, and an ownership policy. The platform validates a safe profile and applies it through data.appdata.structure.apply.
With dry_run=true, apply returns the compiled plan (and serving SQL when the compiler emits it) without writing. With dry_run=false, it writes catalog metadata (appdata_database, appdata_table, appdata_field, appdata_policy) and, when the plan includes serving DDL, executes that DDL on the app's serving backend — the instance the catalog points at, not a DSN you supplied.
Shape
databases:
- slug: farm
tables:
- slug: property
ownership:
kind: organization # owner | app | organization
capability_namespace: property
fields:
- { slug: name, type: text, required: true }
- { slug: hectares, type: number }
- { slug: active, type: bool }
Ownership is the isolation contract. Set it when you declare the table — changing it later is a migration, not a query-time flag. See Ownership & workspaces.
A table whose new or changed rows should start a composition (a queue, an inbox, pending items) declares events: true, then gets a change trigger with trigger.create. See Keep scheduled compositions cheap.
A table that is a log rather than a set — messages, run events, an outbox — also declares an append policy, which gives it server-allocated positions and exactly-once replay. See Ordered append.
ownership.kind | Who the rows belong to |
|---|---|
owner (default) | The signed-in end-user. Every op is filtered to that principal. |
app | Shared catalog / seed data for the app. Org seed goes through appdata.publish-as-app. |
organization | A workspace inside your app (a farm, a clinic, a tenant). Scoped to the user's active workspace and gated by workspace role. |
Sensitive fields
Mark a field that holds personal or confidential data with sensitive: true. By default this only affects the audit trail: the change is recorded, but the value is stored as {"redacted": true}. Reads return the field as before.
To withhold it from reads as well, turn enforcement on for the table:
- slug: contacts
metadata:
enforce_sensitive: true
fields:
- { slug: name, type: text, required: true }
- { slug: email, type: email, sensitive: true }
Apply the structure again (dry_run: false). On a table with enforce_sensitive: true, a sensitive field:
| Surface | Behaviour |
|---|---|
Record workflows (record.read, record.query, *-as-app) | Never returned. Writes, upserts and unique checks still use it. |
| Data API REST | The column is present and reads as null. It cannot be set through the table endpoint. |
SQL (Query, /sql/v1, direct logins) | Reads as null in the table view. Selecting it from the base table is refused (42501). |
| Audit trail | Redacted, as without the flag. |
Things to know before you turn it on:
- Write sensitive fields through workflows. A mini app cannot set one through the Data API, so a
requiredsensitive field makes Data API inserts on that table fail. Userecord.create/*-as-app, or make the field optional. - Do not use a sensitive field as a key. An
appendposition, scope or idempotency key, aleasefield, or amembershipmatch field cannot be sensitive on an enforcing table; the apply refuses it. - There is no per-role reveal yet. Once enforced, no end user or operator reads the value. Leave the flag off on tables whose screens need to show the field.
- Turning the flag off and re-applying makes the field readable again.
Functions (RPCs)
An app can ship its own SQL functions. Declare them in rpcs, a list on a database, next to tables:
databases:
- slug: farm
tables:
- slug: property
# ...
rpcs:
- name: Total hectares
slug: farm_total_hectares
description: Hectares across the caller's active properties
returns: numeric
args:
- { name: Minimum, slug: min_hectares, type: number, required: false }
sql_body: >-
SELECT coalesce(sum(hectares), 0) FROM property
WHERE active AND hectares >= coalesce(min_hectares, 0)
The apply creates each one as a Postgres function in the app's schema. rpcs placed anywhere else (at the top level, or on a table) creates nothing; the apply says so in its warnings.
| Key | Rule |
|---|---|
name, slug, sql_body | Required. The function is named after slug. |
args | Each needs name, slug and type (a field type). required: false makes it optional (NULL when omitted). Refer to an argument by its slug in the body. |
returns | record (default), void, text, uuid, boolean, integer, bigint, numeric, json, jsonb. To return a list, aggregate it (jsonb_agg(...)) and return jsonb. |
language, security | Only sql and invoker, which are the defaults. |
sql_body | One statement. A trailing ; is fine; any other ; is refused, even in a comment or a string. Up to 8000 characters. |
Limits: 100 functions per app, counted across all of its databases (they share one schema), and 20 arguments per function.
A function runs as the caller (SECURITY INVOKER), so it sees exactly the rows the caller could read on their own: ownership and workspace isolation still apply inside it. Refer to tables by slug (the live views).
Call a function from the Data API (call) or PostgREST (/rpc/<slug>). The Query console cannot call app functions.
Apply
An org-member token (dashboard, SDK, or MCP agent) starts the apply workflow with the compiled plan. Exact input fields live in the catalog under data.appdata.structure.apply.
discover data.appdata.structure.apply
get schema
start — plan + identity_app_uuid
watch
Read the warnings
applied: true means the apply succeeded, not that every key you sent was used. The result's warnings list what the platform ignored, in dry runs and live runs alike. A key placed where the declaration does not read it creates nothing and is named there:
VDS warning at data_structure: Ignored key(s) 'rpcs': not part of the declaration here, nothing was created from them Hint: Keys read here: databases, description, metadata, name, version. 'rpcs' is read on a database.
Run with dry_run: true first and read warnings before applying.
What you get back
After a successful apply, the structure is metadata Orkestia owns:
- Virtual database / table / field definitions scoped to
(organization, identity app). - Per-table access policy (owner-forced, owner-only, capability namespace).
- An audit trail of applied plans.
No DSN is returned. Callers use record workflows or the Data API. Operators inspect the live database in Query.
Rules of thumb
- Prefer narrow fields and uniqueness you actually need. Uniqueness is enforced by the platform, not by your app.
- Do not invent a second tenant column "to be safe" — ownership + the injected principal is the tenant filter.
- Keep shared reference data on
ownership.kind: appand tenant business data onownerororganization. - Resolve live workflow names from the catalog. Do not copy slugs from this page into production without checking.
