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 */
},
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'
// 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). 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. |
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#
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:
- 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 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 source snapshot (always);
- the optional static snapshot;
- the schema version currently stored for production;
- the current function version, manifest, and gateway-verified artifact digest;
- optional sanitized Git provenance.
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.