Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 57 additions & 71 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,73 +1,71 @@
# Typegres

Postgres tables wrapped in TypeScript classes. The methods on those classes —
SQL expressions, aggregates, subqueries, role gates, state transitions — are
your API. Clients compose typed queries against them, end-to-end-typed; no
routes, no GraphQL schema, no auto-CRUD.
![Typegres playground demo](./assets/demo.gif)

The schema underneath stays yours to refactor. The classes are the contract.
- **Methods on Postgres tables = your API.** No routes. No GraphQL. No auto-CRUD.
- **Every Postgres function, fully typed.** All 77 base types, every operator, nullability tracked at the type level.
- **Clients compose typed SQL across the wire.** Server validates the surface area you expose.
- **Live by default.** `.live()` re-queries when the underlying data changes — pushed directly to clients.

> **Status:** clean rewrite in progress. Core architecture is settled. See
> [ARCHITECTURE.md](./ARCHITECTURE.md) for design notes.
> [typegres.com/play](https://typegres.com/play) · [demo.mp4](./assets/demo.mp4) · [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)

## Tenets
## Usage

1. **Clients compose, server constrains.**
The query builder is typed end-to-end and runs client-side; the server only
evaluates what you've marked `@tool`. The class surface is your contract —
refactor the schema underneath without breaking callers.
> **Developer preview** — surface is settled, edges still being filed. Not
> yet recommended for production.

2. **Your API is an abstract data type on top of the database.**
Tables are TypeScript classes; methods — plain SQL expressions, aggregates,
subqueries, whatever — are the public interface. Logic, permissions, and
state transitions live alongside the data, in one place.
```bash
npm install typegres pg
```

3. **Every Postgres capability, as a typed TS method.**
All 77 pg base types, every operator, every function — codegen'd from the
catalog with full overload preservation and compile-time null tracking.
`Int4<1>["+"](Int4<0|1>)` returns `Int4<0|1>`; pg's strictness rules are
captured in the types.
```typescript
import { typegres, Int8, Text, expose } from "typegres";

## Example
const db = await typegres({
type: "pg",
connectionString: process.env.DATABASE_URL!,
});

```typescript
class Users extends db.Table("users") {
id = (Int8<1>).column({ nonNull: true, generated: true });
first_name = (Text<1>).column({ nonNull: true });
last_name = (Text<1>).column({ nonNull: true });
created_at = (Timestamptz<1>).column({ nonNull: true });

// Derived column — part of the public interface, not in the schema.
// `@tool` marks it reachable from the client.
@tool() fullName() {
return this.first_name["||"](sql` `)["||"](this.last_name);
}
@expose() id = (Int8<1>).column({ nonNull: true, generated: true });
@expose() first_name = (Text<1>).column({ nonNull: true });
@expose() last_name = (Text<1>).column({ nonNull: true });

// Related querycomposable, chainable, typed end-to-end.
@tool() todos() {
return Todos.from().where((t) => t.user_id["="](this.id));
// Derived columncomposes back into your typed query API.
@expose() fullName() {
return this.first_name["||"](" ")["||"](this.last_name);
}
}

// `fullName()` works anywhere a column does — select, where, orderBy:
const rows = await Users.from()
.orderBy(({ users }) => users.created_at)
.select(({ users }) => ({
id: users.id,
name: users.fullName(),
}))
.execute(db);

console.log(rows);
await db.close();
```

A live, in-browser demo runs at [/play](https://typegres.com/play) — a
capability-rooted API (`user.orders().where(...)` auto-scopes to the
principal), live queries, and RPC by closure transport, all over PGlite.
For a complete scaffold with migrations + codegen, see
[`examples/basic`](./examples/basic). Or try it interactively at
[typegres.com/play](https://typegres.com/play).

## Architecture sketch
## How it works

- **Types codegen'd from the Postgres catalog.** 77 base types, full
method/operator coverage, nullability tracked at the type level.
- **Capability-based query API.** Clients can only reach what you've exposed
as `@tool` methods — columns, relations, scoped reads, mutations. The class
surface is the contract; the schema underneath is free to move.
1. **Types codegen'd from the Postgres catalog.** 77 base types, full
method/operator coverage, nullability tracked at the type level.
2. **Object-capability queries.** Clients can only reach what you've exposed
as `@expose` methods — columns, relations, scoped reads, mutations. The class
surface is the contract; the schema underneath is free to move.
3. **Object-capability RPC.** The query builder ships to a constrained
interpreter on the server; only `@expose`-marked methods reach evaluation.
4. **Live queries.** `.live()` watches the predicates your query depends
on and re-yields when committed mutations would change the result.

Deeper dive in [ARCHITECTURE.md](./ARCHITECTURE.md); code is annotated
throughout.
Deeper dive in [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).

## Status

Expand All @@ -76,44 +74,32 @@ throughout.
- [x] Mutations (`.insert` / `.update` / `.delete` / `.returning`)
- [x] Subqueries, scalar/array aggregation
- [x] Table codegen from live schema
- [x] Live queries — `qb.live(db)` returns an async iterable that
- [x] Live queries — `.live()` returns an async iterable that
re-yields when committed mutations would change the result
- [x] Capability-rooted RPC — closures composed against `@tool`-marked
- [x] Capability-rooted RPC — closures composed against `@expose`-marked
classes/methods are serialized, evaluated server-side under a
constrained interpreter, and JSON-streamed back

## Planned

- [ ] SQLite backend (sql-builder is dialect-aware; adapter is stubbed)
- [ ] `pg_notify`-driven live updates (currently polls; see `src/live/ISSUES.md` #5)
- [ ] `pg_notify`-driven live updates (currently a single shared polling loop, not per-subscription)
- [ ] WAL-mode for live updates (currently uses an auxiliary table)
- [ ] Cap'n Web transport (in-flight upstream PR;
[cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162))

## Quick start

```bash
npm install typegres pg
```

```typescript
import { Database, PgDriver, sql } from "typegres";

const driver = await PgDriver.create(process.env.DATABASE_URL!);
const db = new Database(driver);

const rows = await db.execute(sql`SELECT 1 + 1 AS sum`);
```

For a working scaffold with migrations + codegen, see
[`examples/basic`](./examples/basic).

## Development

> Recommended: [Nix the package manager](https://nixos.org/download/)
> + [direnv](https://direnv.net). The `.envrc` (`use flake`) auto-activates
> the pinned toolchain when you `cd` into the repo, and `bin/startpg`
> works out of the box. Without Nix, point `DATABASE_URL` at any local
> Postgres and skip `startpg`.

```bash
./bin/startpg # one-time dev Postgres socket
./bin/startpg # one-time dev Postgres socket (Nix)
npm install
npm run check # lint + typecheck + tests
./bin/tg generate # table codegen (reads typegres.config.ts)
```

## License
Expand Down
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/demo.mp4
Binary file not shown.
File renamed without changes.
File renamed without changes.
16 changes: 6 additions & 10 deletions ISSUES.md → docs/ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,11 @@
— bigint, bytes, dates, ±Infinity, NaN, undefined as tagged sentinels (e.g.
`["bigint","42"]`) — is the v0.2 fix; see Endo's `pass-style` for prior art.

10. **Rename `@tool` → `@expose`.** The decorator is the only gatekeeper for
what crosses the wire (columns, relations, methods, properties — for any
caller: humans, frontends, agents). The current name is sticky baggage from
the codemode/agents framing and undersells the broader role. `@expose`
matches the verb the README and landing copy already use ("Mark methods
exposed", "what you've marked"). Mechanical but wide: `src/exoeval/tool.ts`,
`src/index.ts` re-export, codegen template in `src/tables/generate.ts`, all
demo schemas (regen via `tg generate`), demo `api.ts`, README example, the
inline snapshots in `src/tables/generate.test.ts`, and site copy.
10. ~~**Rename `@tool` → `@expose`.**~~ Done at launch prep. The exported
decorator and namespace are `expose` / `expose.unchecked`; the
`tool.ts` file kept its name internally. ESLint rule, codegen
template, generated/ outputs, schema files, demo, README, and site
copy all updated.

## Missing features

Expand Down Expand Up @@ -89,7 +85,7 @@
Likely answer is both: pre-compiled for production traffic, gas-metered
for dev/admin/exploratory use.

15. **Site: upgrade Next.js 14 → 16.** `npm audit` reports 4 high-severity
15. ~~**Site: upgrade Next.js 14 → 16.**~~ `npm audit` reports 4 high-severity
Next.js CVEs, all fix-gated on the 16.x major. They're server-side
vulnerabilities (image optimizer, RSC, rewrites, Server Components DoS)
that don't affect our static-export deployment to GitHub Pages — no Next
Expand Down
6 changes: 3 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ export default [
selector: "AssignmentExpression[left.type='MemberExpression'][left.computed=true][left.property.value='__proto__']",
message: "Don't assign to __proto__ — use Object.setPrototypeOf if you really mean it.",
}, {
// `@tool.unchecked` skips the zod schema typegres uses to validate
// `@expose.unchecked` skips the zod schema typegres uses to validate
// RPC arguments. Legitimate only for internal methods with generics
// that can't be expressed in zod, or test fixtures. Every use must
// be acknowledged with a disable comment + reason.
selector: "MemberExpression[object.name='tool'][property.name='unchecked']",
message: "Don't use @tool.unchecked — it skips RPC arg validation. Use @tool(zSchema) instead. If the method's signature is genuinely inexpressible in zod (or this is a test fixture), add `// eslint-disable-next-line no-restricted-syntax -- <reason>`.",
selector: "MemberExpression[object.name='expose'][property.name='unchecked']",
message: "Don't use @expose.unchecked — it skips RPC arg validation. Use @expose(zSchema) instead. If the method's signature is genuinely inexpressible in zod (or this is a test fixture), add `// eslint-disable-next-line no-restricted-syntax -- <reason>`.",
}],
"@typescript-eslint/no-restricted-types": ["error", {
types: {
Expand Down
2 changes: 1 addition & 1 deletion site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import swc from "unplugin-swc";
// interactive bits (playground, demo, dark-mode toggle).
//
// SWC plugin handles TC39 stage-3 decorators that typegres uses on
// `@tool()` — the friction Next 14's compiler couldn't accommodate.
// `@expose()` — the friction Next 14's compiler couldn't accommodate.
//
// Tailwind 4 is wired through postcss.config.js (Astro picks it up
// automatically) — `@astrojs/tailwind` was only a convenience
Expand Down
94 changes: 94 additions & 0 deletions site/src/components/WireLog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Wire activity panel for the playground. Subscribes to the demo's
// `wireLog` and renders one row per closure-shipped / chunk-received,
// color-coded by kind so viewers can see RPC traffic happening — the
// thing that's invisible in a single-page demo otherwise.

import { useEffect, useState } from "react";
import type { WireEntry, WireEntryKind } from "@/demo/wire-log";
import { wireLog } from "@/demo/wire-log";

const LABELS: { [K in WireEntryKind]: { tag: string; tw: string } } = {
"query-request": { tag: "RPC query request", tw: "text-sky-400" },
"query-response": { tag: "RPC query response", tw: "text-emerald-400" },
"live-request": { tag: "RPC live request", tw: "text-violet-400" },
"live-update": { tag: "RPC live update", tw: "text-amber-400" },
"error": { tag: "RPC error", tw: "text-red-400" },
};

const fmtTime = (t: number): string => {
const d = new Date(t);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
};

// Trim the IIFE wrapper RpcClient.run adds around the closure source.
// `(${fnString})(api, ${captures})` → `${fnString}` so the log shows
// just the closure the caller wrote. Multiline closures defeated a
// `.*`-based regex; locate the `)(api, ` boundary by string search
// instead.
const stripIife = (code: string): string => {
const trimmed = code.trim();
if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) {return trimmed;}
const tailIdx = trimmed.lastIndexOf(")(api, ");
if (tailIdx < 1) {return trimmed;}
return trimmed.slice(1, tailIdx);
};

const summarize = (e: WireEntry): string => {
switch (e.kind) {
case "query-request":
case "live-request":
return stripIife(e.code).replace(/\s+/g, " ");
case "query-response":
case "live-update":
if (e.rows !== null) {return `${e.rows.toLocaleString()} row${e.rows === 1 ? "" : "s"}`;}
return `${e.bytes.toLocaleString()} bytes`;
case "error":
return e.message;
}
};

export const WireLog = () => {
const [entries, setEntries] = useState<WireEntry[]>([]);

useEffect(() => wireLog.subscribe(setEntries), []);

// Newest first: viewers always see the latest event without scrolling.
// The bounded buffer in `wireLog.push` evicts oldest from the front, so
// entries[N-1] is most-recent — reverse for display.
const display = entries.slice().reverse();

return (
<div className="flex flex-col h-full min-h-0 bg-gray-950 border-t border-gray-800">
<div className="px-4 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide shrink-0 flex items-center justify-between border-b border-gray-800">
<span>Wire</span>
<button
onClick={() => wireLog.clear()}
className="text-[10px] normal-case font-normal text-gray-500 hover:text-gray-300 transition-colors"
>
clear
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto px-4 py-2 font-mono text-[11px] leading-relaxed">
{display.length === 0 ? (
<div className="text-gray-600">No traffic yet.</div>
) : (
display.map((e, i) => {
const { tag, tw } = LABELS[e.kind];
const arrow = e.kind === "query-request" || e.kind === "live-request" ? "→" : "←";
return (
<div key={`${e.t}-${i}`} className="flex gap-2 whitespace-pre-wrap break-all">
<span className="text-gray-600 shrink-0">{fmtTime(e.t)}</span>
<span className={`${tw} shrink-0`}>{arrow} [{tag}]</span>
<span className="text-gray-300">{summarize(e)}</span>
</div>
);
})
)}
</div>
</div>
);
};
14 changes: 7 additions & 7 deletions site/src/demo/schema/customers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Int8, Text, TypegresLiveEvents, tool } from "typegres";
import { Int8, Text, TypegresLiveEvents, expose } from "typegres";
import { db } from "../runtime";
import { Orders } from "./orders";
import { Organizations } from "./organizations";
export class Customers extends db.Table("customers", { transformer: TypegresLiveEvents.makeTransformer() }) {
// @generated-start
@tool() id = (Int8<1>).column({ nonNull: true, generated: true });
@tool() organization_id = (Int8<1>).column({ nonNull: true });
@tool() name = (Text<1>).column({ nonNull: true });
@tool() email = (Text<1>).column({ nonNull: true });
@expose() id = (Int8<1>).column({ nonNull: true, generated: true });
@expose() organization_id = (Int8<1>).column({ nonNull: true });
@expose() name = (Text<1>).column({ nonNull: true });
@expose() email = (Text<1>).column({ nonNull: true });
// relations
@tool() organization() { return Organizations.scope(Customers.contextOf(this)).where(({ organizations }) => organizations.id["="](this.organization_id)).cardinality("one"); }
@tool() orders() { return Orders.scope(Customers.contextOf(this)).where(({ orders }) => orders.customer_id["="](this.id)).cardinality("many"); }
@expose() organization() { return Organizations.scope(Customers.contextOf(this)).where(({ organizations }) => organizations.id["="](this.organization_id)).cardinality("one"); }
@expose() orders() { return Orders.scope(Customers.contextOf(this)).where(({ orders }) => orders.customer_id["="](this.id)).cardinality("many"); }
// @generated-end
}
22 changes: 11 additions & 11 deletions site/src/demo/schema/inventory_positions.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import { Database, Int8, Text, TypegresLiveEvents, sql, tool } from "typegres";
import { Database, Int8, Text, TypegresLiveEvents, sql, expose } from "typegres";
import { z } from "zod";
import { db } from "../runtime";
import { Locations } from "./locations";
import { Organizations } from "./organizations";
import { OrderLines } from "./order_lines";
export class InventoryPositions extends db.Table("inventory_positions", { transformer: TypegresLiveEvents.makeTransformer() }) {
// @generated-start
@tool() id = (Int8<1>).column({ nonNull: true, generated: true });
@tool() organization_id = (Int8<1>).column({ nonNull: true });
@tool() location_id = (Int8<1>).column({ nonNull: true });
@tool() sku = (Text<1>).column({ nonNull: true });
@tool() on_hand = (Int8<1>).column({ nonNull: true, default: sql`0` });
@tool() reserved = (Int8<1>).column({ nonNull: true, default: sql`0` });
@expose() id = (Int8<1>).column({ nonNull: true, generated: true });
@expose() organization_id = (Int8<1>).column({ nonNull: true });
@expose() location_id = (Int8<1>).column({ nonNull: true });
@expose() sku = (Text<1>).column({ nonNull: true });
@expose() on_hand = (Int8<1>).column({ nonNull: true, default: sql`0` });
@expose() reserved = (Int8<1>).column({ nonNull: true, default: sql`0` });
// relations
@tool() location() { return Locations.scope(InventoryPositions.contextOf(this)).where(({ locations }) => locations.id["="](this.location_id)).cardinality("one"); }
@tool() organization() { return Organizations.scope(InventoryPositions.contextOf(this)).where(({ organizations }) => organizations.id["="](this.organization_id)).cardinality("one"); }
@tool() order_lines() { return OrderLines.scope(InventoryPositions.contextOf(this)).where(({ order_lines }) => order_lines.inventory_position_id["="](this.id)).cardinality("many"); }
@expose() location() { return Locations.scope(InventoryPositions.contextOf(this)).where(({ locations }) => locations.id["="](this.location_id)).cardinality("one"); }
@expose() organization() { return Organizations.scope(InventoryPositions.contextOf(this)).where(({ organizations }) => organizations.id["="](this.organization_id)).cardinality("one"); }
@expose() order_lines() { return OrderLines.scope(InventoryPositions.contextOf(this)).where(({ order_lines }) => order_lines.inventory_position_id["="](this.id)).cardinality("many"); }
// @generated-end

// inventory_control-only: adjust on_hand by a signed delta.
// Authorization comes from hydration: `InventoryPositions.contextOf(this)`
// returns the principal that scoped the read, and `this.id` is
// therefore already in that principal's tenant. Only the role gate
// is checked here.
@tool(z.lazy(() => z.instanceof(Database)), z.number())
@expose(z.lazy(() => z.instanceof(Database)), z.number())
async adjust(db: Database<any>, delta: number): Promise<{ id: string; on_hand: string }> {
const user = InventoryPositions.contextOf(this);
if (!user) {
Expand Down
Loading
Loading