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