Schema Declaration & Deploy
Production tables, buckets, resource types, and access rules are declared in the app repo
with defineSchema and applied to a deployment with the /deploy endpoint. The gateway
compiles the declared schema (the IR) into an isolated per-app Postgres schema
(app_<deploymentId>) and an access-metadata blob the database API
enforces. The default schema file is .osgarden.schema.ts.
import { defineSchema } from "@osgarden/schema";
export default defineSchema({
tables: {
/* creator-owned, mountable */
},
buckets: {
/* private file buckets — planned */
},
resources: {
/* resource types */
},
});Tables#
Top-level tables are creator-owned; each row lives in its creator's namespace and can be mounted into resource types.
tables: {
messages: (t) => {
t.uuid('id').primaryKey()
t.text('body').notNull()
t.text('kind').notNull()
t.timestamps()
t.index(['createdAt'])
},
}Column types: text, uuid, integer, bigint, boolean, jsonb, timestamp.
Modifiers: .primaryKey(), .notNull(), .unique(), .default(v),
.references('table.column'). Helpers: .timestamps() (adds createdAt/updatedAt),
.index(columns, { unique }).
Every physical table implicitly carries id, owner_principal, createdAt, updatedAt.
Declaring a reserved column (owner_principal) is rejected at validation. Identifiers
must be letter-led, single-underscored (no __, which is the physical-name separator).
Resource types#
resources: {
room: (r) => {
r.visibility('discoverable') // 'members' | 'unlisted' | 'discoverable'
r.payer('actor') // 'actor' | 'owner' | 'split' (default actor for now)
r.defaultRole('writer') // role granted on join (default reader)
r.grant({ owner: 'admin', admin: 'writer', writer: null, reader: null })
r.static(['global']) // app-owned singleton slugs, reconciled at deploy
// Publish a creator-owned table into this resource: rows stay owned by
// their creator; the resource is a context they appear in.
r.shares('messages', (m) => {
m.onMemberRemove('remove') // 'remove' | 'hide' | 'retain'
m.onOwnerDelete('tombstone') // 'remove' | 'tombstone' | 'retain' (planned)
// A union grants any passing value: creators edit their own row,
// admins edit any row.
m.access({ read: 'reader', create: 'writer', update: ['creator', 'admin'], delete: 'creator', unlink: 'admin' })
})
// Declare a table owned by the resource entity itself: rows belong to
// the resource and die with it.
r.owns('auditLog', (t) => {
t.jsonb('event').notNull()
t.timestamps()
t.access({ read: 'admin', create: 'writer' })
})
},
}| Field | Meaning |
|---|---|
visibility | members (hidden, invite/request only), unlisted (anyone with the id can view/join, not browsable), or discoverable (appears in browse). |
payer | Who pays for resource-owned data: actor, owner, or split. split currently bills the actor (true split is planned). |
defaultRole | Role granted to a self-joining/invited member. |
grant | Per-role assignment ceiling. |
static | Schema-declared, app-owned singleton resources. |
r.owns(name, cb) declares a resource-owned table (belongs to the resource entity,
dies with it). r.shares(table, cb) publishes a top-level creator-owned table into
this resource type (the row lives in its creator's namespace; the resource is a context
it appears in — see Ownership models for
the full contrast).
Access values#
public, reader/writer/admin/owner (≥ role), resourceOwner, creator (the row
inserter on shared tables, or the file uploader — files planned), none. A verb's rule
may be a single value or a union array (e.g. update: ['creator', 'admin']) — any
passing value grants the op; if only a row-scoped value like creator passes, the op is
scoped to the caller's own rows. Default-deny: an unset verb denies. See
database.md.
Parsed but not enforced yet#
These schema fields are accepted and stored (so schemas are forward-compatible) but their
runtime is planned, not shipped: files(...) buckets, channel(...) and
presence(...) (realtime), and visitors({ read }). See
not-yet-implemented.md.
Migrations#
At deploy the engine diffs the stored schema against the new one and classifies each change:
- safe (e.g. new table/column) → applied automatically.
- ambiguous / dangerous (e.g. a drop or type change) → blocked (
409), pending an explicit custom migration.
Custom migrations ship as migration(async (db) => { … }) and run against a scoped
handle at deploy time.
POST /deploy#
Platform/CLI-only — guarded by a shared deploy token, not a user session. The
osgarden CLI runs defineSchema locally and posts the resulting IR.
Headers x-osgarden-deploy-token: <DEPLOY_TOKEN>
Body
{
"deploymentId": "abc123",
"appId": "…",
"schema": {
/* SchemaIR from defineSchema */
}
}deploymentId must be alphanumeric (it becomes the Postgres schema name).
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { "version": "…", "applied": 7, "noop": false } | Applied (or noop: true if the schema was unchanged). |
| 400 | { "error": "invalid_request" } | Missing fields or bad deploymentId. |
| 401 | { "error": "unauthorized" } | Bad/missing deploy token. |
| 409 | { "error": "migration_blocked", "changes": [ … ] } | Ambiguous/dangerous changes need an explicit migration. |
| 503 | { "error": "deploy_disabled" } | DEPLOY_TOKEN not configured. |
Deploy also creates the _app resource and reconciles declared static resources.