Skip to content

TypeScript client

GET /rel/database.ts serves a single, self-sufficient TypeScript file, generated fresh from your live schema. Drop it into your project and import it — there's no build-time codegen step to wire in, no ORM, and no .save(). It knows every table, column, function, and relationship your database already has, and gives you typed query building over exactly the JSON shape described in The query language.

Fetching it

curl http://localhost:8080/rel/database.ts?schemas=hotel > src/database.ts

schemas restricts which schemas get included — omit it and every schema but pg_catalog is exported. The endpoint is off by default outside of dev mode; see Configuration reference for http.typescript.enable and http.typescript.schemas. If you'd rather have your editor/LSP watch a real file on disk instead of re-curling by hand, typescript.helper_path has rel write database.ts straight to a path of your choosing, on startup and on every schema reload.

The file has no imports and depends on nothing else in your project — copy it in, commit it if you like, and it just works.

Building a query

database.ts exports relation(), func(), and wellknown() — thin builders that return a Querier, typed against your actual schema:

import { relation } from "./database"

const properties = await relation("hotel.properties", (join) => ({
  where: [">=", "star_rating", 4],
  join: {
    rooms: join("hotel.rooms<;id:property_id", {
      select: "*",
    }),
  },
  select: { id: "id", name: "name", star_rating: "star_rating", rooms: "rooms" },
})).get()

properties comes back typed as an array of exactly the shape you asked for — id: number, name: string, rooms: Table__Hotel__Rooms[] — not any. Get a column name wrong, or select a joined alias that isn't declared, and it's a compile error, not a runtime surprise.

relation()'s second argument is a callback, and it's what makes a joined column's shortcut type-check correctly: the join it receives already knows which relation it's being called from, so it only offers the foreign keys reachable from that relation, and only accepts a shortcut that's actually valid there — get it wrong and it's a compile error, with your editor's autocomplete listing the valid shortcuts for that relation. The scoping recurses: a join nested inside another join gets a join of its own, scoped to its target, so the same type-checking applies at every depth without you having to tell it what relation you're embedding into:

const properties = await relation("hotel.properties", (join) => ({
  join: {
    rooms: join("hotel.rooms<;id:property_id", (join) => ({
      join: {
        room_type: join("hotel.room_types>;id:room_type_id"),
      },
    })),
  },
})).get()

The second argument is also optional entirely: relation("hotel.properties") alone, with no query, is a bare select-all (own columns plus every joined relation, the same default an omitted select already has server-side).

Writing back uses the same Querier, with .write() instead of .get():

await relation("hotel.properties", {
  select: { id: "id", name: "name" },
}).write({ id: 1, name: "Marina Bay Grand Hotel" })

write()'s argument type is derived independently from .get()'s return type, because they can genuinely differ — a column marked get in your select is read-only and drops out of the write type entirely; one marked set is the opposite, accepted on write but never returned on read. See The query language ## Selecting fields.

Only a column with no other way to end up with a value — not nullable, no default, not an identity/generated column — is mandatory in that write type; everything else (a nullable column, one with a default, an identity column) is optional, so inserting a new row only requires typing out the columns that actually need it. This follows a column through a select map's own rename too: select: { display_name: "name" } still requires display_name if name itself is required.

wellknown() builds a call to a registered well-known query instead of an ad hoc relation — same Querier, but its params and result shape come from the query's own registered definition rather than from what you pass to relation().

database.json

GET /rel/database.json serves the same introspected schema as plain JSON instead of TypeScript types — for a non-TypeScript consumer, or for tooling built directly on the raw introspected facts. See database.json.

Keeping it in sync

database.ts/database.json are a snapshot of the schema at the moment you fetched them — rel doesn't push updates to a client that already downloaded the file. If you add a column, add a table, or change a relationship, re-fetch to pick it up; until you do, the old file just describes the schema as it was, and a query against a field that no longer exists fails at the server, not at compile time. If you're actively iterating on the schema, typescript.helper_path (above) is the more convenient loop, since it rewrites the file on your behalf every time the schema reloads — you still have to let your editor pick up the change, but there's no manual re-fetch step.