Developer guide

Building on the runtime

You write plain TypeScript against the @warpspeed/runtime primitives. The control plane compiles each tier entry to its managed target and deploys it — you never touch sockets, servers, or infrastructure.

Overview

A Warpspeed app is split into three tiers. Each tier is a small TypeScript module that exports a single definition built from a runtime primitive. The same shared types flow across all three, so the boundaries between them stay type-safe.

Edge

defineRoom

A WebSocket server, one instance per room. Owns per-connection state next to your users.

Regional

defineRegional

Region-local authoritative tables, reducers, and procedures. The edge writes and subscribes here.

Global

defineGlobal

Durable, authoritative persistence. Declarative tables with reducers (writes), procedures (request/response + I/O), and queries (reads).

Mental model

Warpspeed gives each connection its own Edge actor running close to the user, so their interactions resolve in-frame with no round-trip to a distant origin. The Regional tier is real region-local authoritative state (matchmaking queues, lobbies, local ladders), and the Global tier is the single durable source of truth shared across all regions.

Data flows in two directions, and using both is what makes the platform feel instant and stay consistent:

  • Writes (upward) — a client message hits the Edge room, which calls a region-local reducer with ctx.call(name, args). That reducer updates regional tables and/or forwards a write up to a global reducer with ctx.global.call(name, args). The edge never talks to global directly — only to its region.
  • Fan-out (downward) — there is no publish. A write to a table IS the fan-out: global tables a region declares in subscribesToGlobal are mirrored down into that region; the edge subscribes to the regional tables it cares about, and each authoritative row change is pushed to it as a delta in onTopic.
  • Request/response — when the edge needs a value back (a computed rank, the result of an external API call), it calls a procedure with ctx.callProcedure(name, args), which resolves with the procedure's return value.

The Edge actor is the authority for a single user's own interactions: on a local action it can broadcast an optimistic update immediately, then reconcile when the authoritative row arrives via onTopic. Because a user is connected from one device at a time, their own writes never conflict — there is no merge step. Warpspeed deliberately does not auto-merge edge state across users; if your app needs cross-user CRDTs or conflict resolution, that is yours to define.

Getting started

Scaffold a new three-tier project and deploy it:

bash
# Scaffold a new project (creates warpspeed.json + src/ + types/)
npx warp init my-app
cd my-app

# Type-check your tiers locally (zero install — types are generated)
npm run typecheck

# Deploy every tier to production
npx warp deploy

warp init writes a complete sample app: an Edge room, a Regional matchmaker, and a Global leaderboard wired together, plus ambient types for @warpspeed/runtime so your editor type-checks everything with no dependency to install.

Project layout

Every scaffolded project follows the same shape:

text
my-app/
├─ warpspeed.json            # manifest: tiers, entries, environments
├─ DOCUMENTATION.md          # this guide, in your repo
├─ AGENTS.md                 # primer for AI agents working in the repo
├─ src/
│  ├─ edge/room.ts           # Edge tier   → defineRoom
│  ├─ regional/matchmaker.ts # Regional    → defineRegional
│  ├─ global/leaderboard.ts  # Global      → defineGlobal
│  └─ shared/                # types shared across every tier
├─ assets/                   # static files (images, js libs, …) → ctx.assetUrl()
└─ types/warpspeed-runtime.d.ts

The warpspeed.json manifest maps each tier to one or more entries. Every entry becomes its own deployed unit, addressable at /<entryName>:

jsonc
{
  "tiers": {
    "edge":     { "entries": { "room": "src/edge/room.ts" }, "allowedOrigins": [] },
    "regional": { "entries": { "matchmaker": "src/regional/matchmaker.ts" } },
    "global":   { "entries": { "leaderboard": "src/global/leaderboard.ts" } }
  },
  "environments": {
    "production": { "tiers": ["edge", "regional", "global"] },
    "staging":    { "tiers": ["edge", "regional"] },
    "preview":    { "tiers": ["edge"] }
  }
}

Edge tier — defineRoom

The Edge tier is a WebSocket server. One room instance exists per logical room id (the session query param). The runtime routes every connection and message into your handlers — you never manage sockets directly.

ts
import { defineRoom } from "@warpspeed/runtime"

interface RoomState {
  players: Record<string, { id: string; points: number }>
}

export default defineRoom<RoomState>({
  // Subscribe to a table on this region's instance; row deltas arrive in onTopic.
  // "scores" is a global table mirrored down into the region.
  subscriptions: ["scores"],

  onStart(ctx) {
    ctx.state.players = {}
    ctx.log("room started", ctx.roomId)
  },

  onJoin(conn, ctx) {
    ctx.state.players[conn.id] = { id: conn.id, points: 0 }
    ctx.broadcast({ type: "presence", players: Object.keys(ctx.state.players) })
  },

  async onMessage(conn, message, ctx) {
    if (message.type === "score") {
      // Optimistic: broadcast to clients NOW, before the write is confirmed.
      ctx.broadcast({ type: "scored", player: conn.id, points: message.points, authoritative: false })
      // Authoritative write path: call a region-local reducer. The confirmed row
      // returns later as a "scores" delta in onTopic.
      await ctx.call("score", { player: conn.id, points: message.points })
    }
  },

  // Authoritative reconcile — a "scores" row changed (mirrored down from global).
  onTopic(table, delta, ctx) {
    if (table === "scores") ctx.broadcast({ type: "row", authoritative: true, ...delta })
  },

  onLeave(conn, ctx) {
    delete ctx.state.players[conn.id]
    ctx.broadcast({ type: "presence", players: Object.keys(ctx.state.players) })
  },
})

The handlers map to the WebSocket lifecycle: a connection opening fires onJoin, an inbound message fires onMessage, and a disconnect fires onLeave. onStart runs once when the room instance wakes.

ctx.state is per-room durable state. ctx.broadcast(message) sends to every connection in the room. conn.send(message) targets a single connection. ctx.call(reducer, args) invokes a region-local reducer — the authoritative write path — and subscriptions + onTopic receive table row deltas: a write to a subscribed table IS the fan-out.

Both send paths accept binary: an ArrayBuffer or any ArrayBufferView (e.g. Uint8Array) is transmitted verbatim as a binary WebSocket frame — a view is sliced to its byteOffset/byteLength window, so views into larger pooled buffers are safe. Strings go out as text frames; everything else is JSON-serialized. Inbound binary frames arrive in onMessage as an ArrayBuffer, symmetric with the send path.

An edge module can also serve plain HTTP. Add an onFetch(request, ctx) handler and it runs for any request that is not a WebSocket upgrade — a web page, a REST/JSON endpoint, or a webhook — right next to the realtime socket. The starter uses this to host its own demo client: the leaderboard UI is served straight from the edge, so after deploying you just open the edge URL in a browser and the page connects back to the room that served it — no separate static site to run.

ts
export default defineRoom<RoomState>({
  // ...onJoin / onMessage / onLeave / onTopic...

  async onFetch(request, ctx) {
    // JSON API: GET /leaderboard?player=<id>
    if (ctx.method === "GET" && ctx.path.endsWith("/leaderboard")) {
      const player = ctx.query.get("player")
      if (!player) return ctx.json({ error: "missing ?player" }, 400)
      const rank = await ctx.callProcedure("rankFor", { player })
      return ctx.json(rank)
    }
    // A web page for browsers.
    if (ctx.method === "GET" && ctx.path === "/") {
      return ctx.html("<h1>Realtime room</h1><p>Connect over WebSocket.</p>")
    }
    return null // fall through to the default status payload
  },
})

onFetch runs in the stateless edge worker — there is no live socket, so reach the backend with ctx.rpc, ctx.call, and ctx.callProcedure. Return a Response, a string (served as HTML), or a plain object (served as JSON); null falls through to the default status. The WebSocket allowedOrigins allowlist does not gate onFetch — it is a public web surface.

Regional tier — defineRegional

A Regional module is real region-local authoritative state — its own durable instance per region. You author it like the global tier: declarative tables plus reducers over a predicate ctx.db. The edge invokes these reducers with ctx.call. A reducer can touch its own tables and/or forward a write up to global with ctx.global.call.

ts
import { defineRegional, table, column } from "@warpspeed/runtime"

export const tables = {
  totals: table({
    player: column.text().primaryKey(),
    points: column.int().default(0),
    region: column.text(),
  }),
}

export default defineRegional({
  tables,
  // Mirror the global "scores" table down into this region so the edge can
  // subscribe to it and receive authoritative deltas.
  subscribesToGlobal: ["scores"],
  reducers: {
    // Invoked from the edge via ctx.call("score", { player, points }).
    score(ctx, args) {
      const existing = ctx.db.totals.find((r) => r.player === args.player)
      const points = (existing?.points ?? 0) + args.points
      ctx.db.totals.upsert((r) => r.player === args.player, { player: args.player, points, region: ctx.region })
      // Forward the running total UP to the global tier (fire-and-forget).
      ctx.global.call("submitScore", { player: args.player, points, region: ctx.region })
    },
  },
})

The edge connects only to its region — never to global. The region decides whether a write stays local, goes up to global via ctx.global.call, or both. Regional modules can also declare procedures (request/response with I/O — see Procedures) and queries (read-only).

Global tier — defineGlobal

The Global tier is the single, cross-region authoritative store. Describe your tables with table and column, then expose reducers (mutating transactions) and queries (read-only). There is no publish: a write to a public table IS the fan-out — regions that subscribe to it get each row change mirrored down, and on to every subscribed edge room.

ts
import { defineGlobal, table, column } from "@warpspeed/runtime"

export const tables = {
  scores: table({
    player: column.text().primaryKey(),
    points: column.int().default(0),
    region: column.text(),
    updatedAt: column.timestamp(),
  }),
}

export default defineGlobal({
  tables,

  reducers: {
    // Invoked from a region via ctx.global.call("submitScore", args). The upsert
    // itself fans out: every region subscribed to "scores" gets the new row.
    submitScore(ctx, args) {
      ctx.db.scores.upsert((r) => r.player === args.player, {
        player: args.player, points: args.points, region: args.region, updatedAt: Date.now(),
      })
    },
  },

  queries: {
    topPlayers(ctx, args) {
      return ctx.db.scores.all().sort((a, b) => b.points - a.points).slice(0, args.limit ?? 10)
    },
  },
})

ctx.db[table] is a predicate-based handle: all(), find(pred), filter(pred), count(pred?), insert(row), update(pred, patch), upsert(pred, row), delete(pred). State is durable and persisted across restarts.

Procedures

A reducer is a fire-and-forget transaction — it returns nothing, and its effect reaches clients later as a subscription delta. A procedure is the request/response complement: it returns a value to the caller and may perform server-side I/O (outbound HTTP, randomness, UUID minting) that a reducer cannot. Declare procedures with the procedures map on defineRegional or defineGlobal, and invoke them from an edge room with ctx.callProcedure(name, args).

ts
import { defineGlobal, table, column, type ProcedureContext } from "@warpspeed/runtime"

export default defineGlobal({
  tables,
  procedures: {
    // Returns a value to the caller; reads run transactionally.
    rankFor(ctx: ProcedureContext<typeof tables>, args: { player: string }) {
      const all = ctx.db.scores.all().sort((a, b) => b.points - a.points)
      const idx = all.findIndex((r) => r.player === args.player)
      return { rank: idx < 0 ? null : idx + 1, total: all.length }
    },

    // Server-side I/O + atomic write — only a procedure can do this.
    enrichProfile(ctx: ProcedureContext<typeof tables>, args: { player: string }) {
      const requestId = ctx.newUuidV7()
      const res = ctx.http.fetch("https://example.com/api/profile/" + args.player)
      const profile = res.ok ? res.json() : null
      ctx.withTx((tx) => tx.db.scores.update((r) => r.player === args.player, { updatedAt: Date.now() }))
      return { requestId, profile }
    },
  },
})

// edge room — callProcedure resolves with the returned value
const { rank, total } = await ctx.callProcedure("rankFor", { player })

The procedure ctx exposes db (each op in its own transaction), withTx (group ops into one atomic transaction), http.fetch, random(), and newUuidV4() / newUuidV7(). Arguments and the return value cross the wire as JSON, and ctx.callProcedure requires a regional tier — it runs on the regional instance when declared there, otherwise on global.

Subscriptions & fan-out

A room observes authoritative data by subscribing to tables. Each matching row change is pushed down to the room and delivered to your onTopic(table, delta, ctx) handler as a TableDelta — you never poll. Declare static subscriptions on the room; a bare table name watches the whole table, and a { table, query } entry watches only a slice.

ts
export default defineRoom<RoomState>({
  subscriptions: [
    "presence",                                                  // whole table
    { table: "scores", query: { op: "eq", column: "gameId", value: "g-42" } }, // a slice
  ],

  onTopic(table, delta, ctx) {
    // delta.kind is "insert" | "update" | "delete"; delta.row is the row.
    // Only "scores" rows with gameId === "g-42" ever arrive here.
    if (table === "scores") ctx.broadcast({ type: "score", ...delta })
  },
})

The filter is evaluated at the regional fan-out point, not in your room. A write that does not match your filter never wakes the room — no CPU, no billing — and matching rows arrive already filtered. This is what keeps a table with millions of rows cheap to watch a slice of.

Query model

Filters are declarative JSON — no code is shipped to the matcher. v1 supports:

  • { op: "all" } — the whole table (what a bare string compiles to).
  • { op: "eq", column, value } — column equals a scalar.
  • { op: "in", column, values } — column is one of a set.
  • { op: "and", clauses } — every eq/in clause holds (each on a distinct column).

Values match by exact type and value — 1 never matches "1". { op: "range" } is reserved and rejected at subscribe time with ERR_UNSUPPORTED_QUERY; other validation errors include ERR_UNKNOWN_TABLE and ERR_UNKNOWN_COLUMN.

Runtime-managed subscriptions

When the slice depends on runtime state — which game a user joined, a channel they switched to — register it imperatively with ctx.subscribe. It resolves once the filter is live upstream (so a row written after it resolves is guaranteed to be delivered) and returns a handle you can atomically re-target or drop.

ts
async onMessage(conn, msg, ctx) {
  if (msg.type === "watch") {
    const sub = await ctx.subscribe(
      "scores",
      { op: "eq", column: "gameId", value: msg.gameId },
      { snapshot: true },                 // replay currently-matching rows first
    )
    ctx.state.sub = sub                   // keep the handle to re-target later
  }
  if (msg.type === "switch") {
    // Atomic swap: no missed rows, no duplicate window.
    await ctx.state.sub.update({ op: "eq", column: "gameId", value: msg.gameId })
  }
  if (msg.type === "stop") {
    await ctx.state.sub.unsubscribe()
  }
}

With { snapshot: true }, currently-matching rows arrive first as synthetic inserts, then live deltas. On an update, rows that leave the set produce a synthetic delete and rows that enter produce a synthetic insert; rows in both sets are not duplicated.

Durability & recovery

Subscriptions are durable: they survive edge hibernation and regional failover. After recovery the runtime re-registers them and calls onResync(ctx), so you can re-baseline a materialized view — for example, re-subscribe with a snapshot or clear cached rows.

Filtered global mirror

A regional module's subscribesToGlobal entries can also be filtered with { table, where }. The where filter (same all/eq/in/and grammar) is pushed into the mirror subscription — evaluated at the global fan-out point — with tokens like $region substituted at boot, so non-matching global rows never transit down to the region at all. It is static config, validated at deploy time (ERR_UNKNOWN_TABLE / ERR_UNKNOWN_COLUMN / ERR_UNSUPPORTED_QUERY).

ts
export default defineRegional({
  tables,
  subscribesToGlobal: [
    "users",                                                              // whole table
    // Mirror only this region's slice of the global `characters` table.
    { table: "characters", where: { op: "eq", column: "homeRegion", value: "$region" } },
  ],
})

A global row updated out of the filter arrives in the region as a delete; into the filter, as an insert. And an edge room subscribed (filtered or not) to a mirrored table sees the intersectionof both filters — the region only holds its mirrored slice, and the room's own filter narrows that slice further.

Cross-tier writes

Writes flow upward by name. The edge calls a region-local reducer; the region optionally forwards up to global:

ts
// edge room
await ctx.call("score", { player, points })          // -> region-local reducer

// regional reducer
ctx.global.call("submitScore", { player, points, region })  // -> global reducer

ctx.call and ctx.global.call are fire-and-forget — the confirmed result comes back as a subscription delta in onTopic, not as a return value. When you need a value back, use a procedure via ctx.callProcedure instead. Name your reducers once in a shared module (the sample uses a REDUCERS constant) so both ends agree on the wire name.

Shared contracts

Put the message types every tier exchanges in src/shared/ and import them on each tier. Because the same types are used on both sides of every boundary, a change to a contract surfaces as a type error in every tier that needs updating.

ts
// src/shared/protocol.ts
export interface ScoreEvent { type: "score"; points: number }
export interface LeaderboardRow { player: string; points: number; region: string }

Connecting clients

Clients connect directly to the Edge tier over a WebSocket. Opening the socket fires onJoin; messages you send fire onMessage.

js
const ws = new WebSocket("wss://edge.warpspeed.run/<org>/<project>/room?session=lobby&conn=alice")

ws.onopen = () => ws.send(JSON.stringify({ type: "score", points: 10 }))
ws.onmessage = (e) => console.log("from room:", JSON.parse(e.data))

Lock down who can connect with tiers.edge.allowedOrigins in warpspeed.json. List the browser origins permitted to open a connection; a request whose Origin is not listed is rejected with 403. An empty list allows any origin (handy for local development).

Static assets

Drop images, client JS libraries, fonts, audio, or any other static files into the assets/ directory at your project root. On warp deploy they are uploaded out-of-band — they never count against your source bundle — and published to a branded, edge-cached host backed by object storage, on a path unique to the environment you deployed to:

text
assets/img/logo.png  →  https://assets.warpspeed.run/<org>/<project>/<env>/img/logo.png

Each file may be up to 50MB. Uploads go straight to object storage, so large files are never constrained by request-body limits.

Resolve an asset's absolute URL from any tier with ctx.assetUrl(path) — for example, serving an HTML page from an edge onFetch handler:

ts
onFetch(request, ctx) {
  return ctx.html(`<img src="${ctx.assetUrl("img/logo.png")}" alt="logo" />`)
}

Only files whose contents changed are re-uploaded on later deploys (content-addressed by hash), and the edge cache is invalidated automatically on each deploy so updates go live immediately. Assets publish on every deploy, and ctx.assetUrl()always resolves to the current environment's copy — so a preview deploy never overwrites production's files. Set "assets": "public" in warpspeed.json to use a different directory, or "assets": false to disable asset upload entirely.

Deploying

Deploy from the project root. Each tier deploys as its own step with live logs:

bash
warp deploy                 # production — edge + regional + global
warp deploy --env staging   # staging — whichever tiers that environment enables
warp deploy --tier edge     # deploy only one tier

In production, each unit is published at a branded URL of the form https://<tier>.warpspeed.run/<org>/<project>/<module>. The deploy output prints the live URL for every unit.

Schema migrations

When a stateful unit (regional or global) already has a live database, every deploy runs a schema gate before anything is touched. Changes fall into two classes:

  • Additive — new tables, new reducers/procedures, new indexes, and new columns declared with .default(...). These migrate in place automatically: all existing rows are preserved and new columns are backfilled from their defaults. Connected clients are briefly disconnected and reconnect on their own.
  • Breaking — removing, retyping, or renaming an existing column, adding a column without a default, or changing a primary key. These are rejected before anything is mutated: the step fails with the engine's exact reason, units that depend on the new schema are skipped, and the previous version of every affected unit stays fully live.

Preview what a deploy would do — without deploying — with:

bash
warp diff                   # classified schema diff vs the live environment
warp deploy --dry-run       # same thing

Each stateful unit is reported as SAFE, ADDITIVE, or BREAKING with a change summary. warp diff exits non-zero when anything is breaking, so it works as a CI gate.

If you genuinely need a breaking change, deploy with --allow-breaking. The affected database is snapshotted, backed up, cleared, re-published with the new schema, and every row that still fits is restored (columns that survived keep their data; removed columns are dropped; new columns take their defaults). Rows that no longer fit the new schema are dropped — the deploy log reports exactly how many were restored and links the full pre-rebuild backup.

bash
warp deploy --allow-breaking        # prompts for confirmation
warp deploy --allow-breaking --yes  # non-interactive (CI)

API reference

The full surface of the runtime primitives:

Edge

ts
defineRoom<S>(handlers: {
  // bare name = whole table, or { table, query, snapshot } for a filtered slice
  subscriptions?: Array<string | { table: string; query?: SubQuery; snapshot?: boolean }>
  onStart?(ctx: RoomContext<S>): void | Promise<void>
  onJoin?(conn: Connection, ctx: RoomContext<S>): void | Promise<void>
  onMessage?(conn: Connection, message: any, ctx: RoomContext<S>): void | Promise<void>
  onLeave?(conn: Connection, ctx: RoomContext<S>): void | Promise<void>
  onTopic?(table: string, delta: TableDelta, ctx: RoomContext<S>): void | Promise<void>
  onResync?(ctx: RoomContext<S>): void | Promise<void>   // after failover/wake recovery
})

interface RoomContext<S> {
  readonly roomId: string
  state: S
  // ArrayBuffer/ArrayBufferView -> binary frame verbatim; string -> text; else JSON
  broadcast(message: unknown): void
  // fire-and-forget write: invoke a region-local reducer (result arrives via onTopic)
  call(reducer: string, args: unknown): Promise<void>
  // request/response: invoke a regional (or global) procedure and await its return value
  callProcedure<T = unknown>(procedure: string, args: unknown): Promise<T>
  // register a filtered table subscription; resolves once active upstream
  subscribe(table: string, query?: SubQuery, opts?: { snapshot?: boolean }): Promise<SubscriptionHandle>
  log(...args: unknown[]): void
  assetUrl(path: string): string       // absolute URL for a file in assets/
}

interface TableDelta<Row = any> {
  kind: "insert" | "update" | "delete"
  row: Row
  oldRow?: Row        // pre-image on update/delete when available
  synthetic?: boolean // true for snapshot / membership-transition deltas
}

type SubQuery =
  | { op: "all" }
  | { op: "eq"; column: string; value: string | number | boolean }
  | { op: "in"; column: string; values: Array<string | number> }
  | { op: "and"; clauses: SubQuery[] }

interface SubscriptionHandle {
  readonly id: string
  readonly table: string
  readonly query: SubQuery
  update(query: SubQuery, opts?: { snapshot?: boolean }): Promise<void>  // atomic re-target
  unsubscribe(): Promise<void>
}

interface Connection {
  readonly id: string
  // ArrayBuffer/ArrayBufferView -> binary frame verbatim (view sliced to its
  // window); string -> text frame; else JSON
  send(message: unknown): void
  close(code?: number): void
}

Regional

ts
defineRegional<Schema>(def: {
  tables: Schema                       // region-local durable tables
  // global tables to mirror down; bare name or { table, where } to push a filter
  // ($region substituted per region at boot; validated at deploy time)
  subscribesToGlobal?: Array<string | { table: string; where?: SubQuery }>
  reducers?:   Record<string, (ctx: RegionalContext<Schema>, args) => unknown>   // fire-and-forget writes (edge ctx.call)
  procedures?: Record<string, (ctx: ProcedureContext<Schema>, args) => unknown>  // request/response + I/O (edge ctx.callProcedure)
  queries?:    Record<string, (ctx: RegionalContext<Schema>, args) => unknown>   // read-only
})

interface RegionalContext<Schema> {
  readonly region: string              // this instance's region id (e.g. "us-east")
  db: { [table in keyof Schema]: TableHandle }  // predicate-based table handles
  global: { call(reducer: string, args: unknown): void }  // forward a write UP to global
  log(...args: unknown[]): void
  assetUrl(path: string): string       // absolute URL for a file in assets/
}

interface TableHandle<Row = any> {
  all(): Row[]
  find(pred: (row: Row) => boolean): Row | undefined
  filter(pred: (row: Row) => boolean): Row[]
  count(pred?: (row: Row) => boolean): number
  insert(row: Row): Row
  update(pred: (row: Row) => boolean, patch: Partial<Row> | ((row: Row) => Partial<Row>)): number
  upsert(pred: (row: Row) => boolean, row: Row | ((existing: Row | null) => Row)): Row
  delete(pred: (row: Row) => boolean): void
}

Global

ts
defineGlobal<Schema>(def: {
  tables: Schema                       // globally-authoritative durable tables
  reducers?:   Record<string, (ctx: GlobalContext<Schema>, args) => unknown>     // fire-and-forget writes (regional ctx.global.call)
  procedures?: Record<string, (ctx: ProcedureContext<Schema>, args) => unknown>  // request/response + I/O (edge ctx.callProcedure)
  queries?:    Record<string, (ctx: GlobalContext<Schema>, args) => unknown>     // read-only
})

interface GlobalContext<Schema> {
  readonly region: "global"
  db: { [table in keyof Schema]: TableHandle }  // a public-table write IS the fan-out
  log(...args: unknown[]): void
  assetUrl(path: string): string       // absolute URL for a file in assets/
}

table<T>(columns: T): T
column.text() | column.int() | column.bool() | column.timestamp()
  .primaryKey() .default(value) .index()

// ProcedureContext adds (procedures only — reducers are deterministic):
withTx<T>(body: (tx) => T): T          // group db ops into one atomic transaction
http.fetch(url, init?): { status, ok, json(), text() }  // synchronous outbound HTTP
random(): number ; newUuidV4(): string ; newUuidV7(): string

Ready to build? Scaffold a project with npx warp init and ship your first deploy.

Get started