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 .tallpond.schema.ts.

ts
import { defineSchema } from "@tallpond/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.

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

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

Deploying#

The normal path is the self-serve CLI: tallpond login once, then tallpond deploy compiles the schema locally and applies it to your app's deployment, authorized by app ownership (your developer token can only deploy to apps you created). See CLI for the full flow, including bundle hosting. The response contract below is shared by both paths.

POST /deploy (platform/CI)#

The pre-self-serve endpoint, kept for platform-managed deploys — guarded by a shared deploy token, not a user session. tallpond deploy --token … uses it. The CLI runs defineSchema locally and posts the resulting IR.

Headers x-tallpond-deploy-token: <DEPLOY_TOKEN>

Body

json
{
  "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.