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 */
  },
  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'
      // 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). The object form { default, allow } lets each instance pick its state at create time — one type can hold both public and private instances instead of forking into two types.
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#

reader/writer/admin/owner (≥ role, in the room the row lives in), creator (the row inserter on shared tables, or the file uploader), none. public and resourceOwner were removed when rooms shipped — use owner, and express visibility as a grant. 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.

A verb may also be provenance-gated: create: { role: 'writer', via: 'submitMove' } requires the caller to hold the role and the operation to be executing inside the named function (via also accepts an array of names). The caller stays actor and payer — via makes the function the mandatory path, it never widens authority. Deploys validate via names against the uploaded functions manifest.

Parsed but not enforced yet#

File buckets are shipped on both private and resource scopes. The schema fields still accepted and stored for forward compatibility but not enforced at runtime are channel(...), 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 dev compiles the schema locally and applies it to your app's dev environment (rehearsing any migration), and tallpond deploy applies it to production — with a confirmation prompt, since deploy means prod and nothing else. Both are authorized by app ownership (your developer token can only deploy to apps you created). See CLI for the full command flow.

Deploy also creates the _app resource and reconciles declared static resources.

Production release capture#

A production deploy does more than apply SchemaIR. Before network mutation, the CLI builds a canonical source snapshot of the project and, when dist/ (or --dir) is present, a separate static snapshot. After schema and functions deploy sequentially, the CLI uploads only app-scoped content-addressed blobs the gateway reports missing and finalizes an immutable release linking:

The gateway revalidates snapshot digests, checks every referenced digest against its verified app-scoped blob inventory, and confirms schema/function versions have not changed before creating the release. Blob reads and hosting still fail closed on storage drift. A static release atomically becomes the app's active_release_id; hosting serves that release's static snapshot. --no-bundle creates a retained source-only release and leaves the active static release unchanged.

Whole-file content addressing means unchanged files, renames, and deletes upload no file bytes; only the complete bytes of changed/new files are sent. Source is retained for owner recovery with tallpond clone [app] [destination] [--release id]. Remote builds, public clone/forks, chunked uploads, and garbage collection are not part of the current contract.