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' })
    })
  },
}
FieldMeaning
visibilitymembers (hidden, invite/request only), unlisted (anyone with the id can view/join, not browsable), or discoverable (appears in browse).
payerWho pays for resource-owned data: actor, owner, or split. split currently bills the actor (true split is planned).
defaultRoleRole granted to a self-joining/invited member.
grantPer-role assignment ceiling.
staticSchema-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:

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

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