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
109 changes: 83 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

![Typegres playground demo](./assets/demo.gif)

- **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.
- **Methods on your tables = your API.** No routes. No GraphQL. No auto-CRUD.
- **Every Postgres/SQLite function, fully typed.** All 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.

> [typegres.com/play](https://typegres.com/play) · [demo.mp4](./assets/demo.mp4) · [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
Expand All @@ -15,29 +17,39 @@
> yet recommended for production.

```bash
npm install typegres pg
npm install typegres better-sqlite3
```

```typescript
import { typegres, expose } from "typegres";
import { Int8, Text } from "typegres/postgres";
import { typegres, expose, sql } from "typegres";
import { SqliteDriver } from "typegres/drivers/sqlite";
import { Integer, Text } from "typegres/sqlite";

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the comment

const conn = db.connect(SqliteDriver.create());

await conn.execute(sql`CREATE TABLE users (
id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL
)`);

class Users extends db.Table("users") {
@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 });
@expose() id = Integer.column({ nonNull: true, generated: true });
@expose() first_name = Text.column({ nonNull: true });
@expose() last_name = Text.column({ nonNull: true });

// Derived column — composes back into your typed query API.
@expose() fullName() {
return this.first_name["||"](" ")["||"](this.last_name);
}
}

await Users.insert(
{ first_name: "Alice", last_name: "Smith" },
{ first_name: "Bob", last_name: "Jones" },
).execute(conn);

// `fullName()` works anywhere a column does — select, where, orderBy:
const rows = await Users.from()
.select(({ users }) => ({
Expand All @@ -50,43 +62,88 @@ console.log(rows);
await conn.close();
```

For a complete scaffold with migrations + codegen, see
[`examples/basic`](./examples/basic). Or try it interactively at
For a complete scaffold with migrations + codegen, see the
[examples](#examples). Or try it interactively at
[typegres.com/play](https://typegres.com/play).

## Backends

`typegres()` is a synchronous schema handle — no top-level await, so table
classes can be declared at module load. The backend arrives separately via
`db.connect(driver)`, and the same schema classes and query builder run
against any of them:

```typescript
import { PgDriver } from "typegres/drivers/pg"; // node-postgres
import { PgliteDriver } from "typegres/drivers/pglite"; // in-process WASM Postgres
import { SqliteDriver } from "typegres/drivers/sqlite"; // better-sqlite3
import { DoSqliteDriver } from "typegres/drivers/do"; // Cloudflare Durable Object

const db = typegres();

db.connect(PgDriver.create(process.env.DATABASE_URL!));
db.connect(SqliteDriver.create("dev.db")); // omit the filename for :memory:
db.connect(DoSqliteDriver.create(ctx.storage)); // in the DO constructor — no npm peer needed
db.connect(await PgliteDriver.create()); // the one async driver: booting WASM is real I/O
```

Drivers are imported explicitly from `typegres/drivers/*` so optional peers
stay out of bundles that never use them — install only the one you need.

With exactly one connection (the Durable Object model), it's also the
default: `.execute()` / `.live()` take no argument, and you can ignore what
`connect` returns. Pass a `Connection` explicitly when you have several —
read replicas, database-per-tenant, or a transaction's `tx`.

## How it works

1. **Types codegen'd from the Postgres catalog.** 77 base types, full
1. **Types codegen'd from the Postgres/SQLite catalog/docs.** all 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.
4. **Live queries.** Tables opt in with `db.Table("name", { live: true })`.
`.live()` watches the predicates your query depends on and re-yields when
committed mutations would change the result — via a polling bus on
Postgres, and synchronous mutation capture on SQLite.

Deeper dive in [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).

## Examples

- [`examples/basic`](./examples/basic) — Postgres/PGLite scaffold:
migrations, `tg generate` codegen, relations (`Relation.belongsTo` / `.has`).
- [`examples/sqlite`](./examples/sqlite) — the same scaffold on
better-sqlite3.
- [`examples/chat`](./examples/chat) — full-stack chat on a Cloudflare
Durable Object: SQLite storage, Cap'n Web RPC from the browser, live
queries pushed to clients, and facet-based capability security (the whole
server is the schema — there are no routes).

## Status

- [x] Full pg type system + operator/function codegen
- [x] SQLite dialect — typed function/operator surface from the same
codegen; drivers for better-sqlite3 and Durable Objects
- [x] Query builder (`.select` + `.join` + `.where` + `.groupBy` + `.having` + `.orderBy` + `.limit`)
- [x] Mutations (`.insert` / `.update` / `.delete` / `.returning`)
- [x] Subqueries, scalar/array aggregation
- [x] Table codegen from live schema
- [x] Live queries — `.live()` returns an async iterable that
re-yields when committed mutations would change the result
- [x] Table codegen from live schema (`tg generate`, both dialects)
- [x] Live queries — `.live()` returns a `LiveQuery`: an async iterable you
can also `.observe()` for push delivery (including over RPC)
- [x] Capability-rooted RPC — closures composed against `@expose`-marked
classes/methods are serialized, evaluated server-side under a
constrained interpreter, and JSON-streamed back
constrained interpreter, and streamed back
- [x] Cap'n Web transport (`typegres/capnweb`) — capabilities, promises, and
live subscriptions over a single WebSocket

## Planned

- [ ] SQLite backend (sql-builder is dialect-aware; adapter is stubbed)
- [ ] `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;
- [ ] `pg_notify`-driven live updates (Postgres currently uses a single shared polling loop, not per-subscription)
- [ ] WAL-mode live updates for Postgres (currently uses an auxiliary table)
- [ ] Upstream the Cap'n Web integration (in-tree shim today;
[cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162))

## Development
Expand Down
7 changes: 6 additions & 1 deletion examples/basic/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { typegres } from "typegres";
import { PgliteDriver } from "typegres/drivers/pglite";

export const { db, conn } = await typegres({ type: "pglite" });
// `typegres()` itself is synchronous; only the driver is awaited, because
// booting WASM Postgres is real I/O. Table classes in ./tables reference
// `db` at module load.
export const db = typegres();
export const conn = db.connect(await PgliteDriver.create());
10 changes: 6 additions & 4 deletions examples/chat/worker/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
// a class inline (X.forY(...)); parameterized ones close over their proof.

import { z } from "zod";
import { Database, expose, Relation } from "typegres";
import { typegres, expose, Relation } from "typegres";
import { Integer, Text } from "typegres/sqlite";
import { hashPassword, verifyPassword } from "./auth";

// The Durable Object attaches its ctx.storage to this Database, and that
// single connection is the default for every .execute()/.hydrate()/.live().
export const db = new Database({ dialect: "sqlite" });
// Synchronous schema handle — the table classes below are declared against
// it at module load, long before any DO instance exists. The Durable Object
// connects its ctx.storage in its constructor, and that single connection
// is the default for every .execute()/.hydrate()/.live().
export const db = typegres();

const zUsername = z.string().regex(/^[\w-]{1,24}$/);
const zPassword = z.string().min(1).max(128);
Expand Down
4 changes: 2 additions & 2 deletions examples/chat/worker/chat-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import { migrate } from "./migrate";

// One Durable Object holds the whole demo (rooms are rows, not DOs) —
// see the README for how this shards to room-per-DO. typegres runs against
// ctx.storage.sql via DoSqliteDriver (same-thread SQLite).
// ctx.storage.sql via the DO SQLite driver (same-thread SQLite).
export class ChatDo extends DurableObject<Env> {
readonly conn: Connection;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.conn = db.attach(new DoSqliteDriver(ctx.storage));
this.conn = db.connect(DoSqliteDriver.create(ctx.storage));
ctx.blockConcurrencyWhile(() => migrate(this.conn));
}

Expand Down
9 changes: 6 additions & 3 deletions examples/sqlite/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { typegres } from "typegres";
import { SqliteDriver } from "typegres/drivers/sqlite";

// `typegres({ type: "sqlite" })` opens a SqliteDriver against the given
// file (or `:memory:` if omitted). The tests use `:memory:` so each
// Synchronous end to end — no top-level await: `typegres()` is a
// module-load-safe schema handle, and better-sqlite3 opens the database on
// construction. The tests use `:memory:` (the `sqlite()` default) so each
// vitest run is hermetic; the `tg generate` CLI reads schema from the
// `./dev.db` file produced by `npm run migrate`.
export const { db, conn } = await typegres({ type: "sqlite" });
export const db = typegres();
export const conn = db.connect(SqliteDriver.create());
17 changes: 9 additions & 8 deletions site/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
// source of truth for the schema.

import { typegres } from "typegres";
import { PgDriver } from "typegres/drivers/pg";
import { runMigrations } from "./src/demo/seed.ts";

const db = await typegres({
type: "pg",
connectionString: process.env["DATABASE_URL"] ?? "postgres://localhost/postgres",
});
const db = typegres();
const conn = db.connect(
PgDriver.create(process.env["DATABASE_URL"] ?? "postgres://localhost/postgres"),
);

console.log("Applying migrations...");
// `db` here is `Database<undefined>` (no principal type plumbed
// through `typegres({ type: "pg" })`); runMigrations only uses
// .execute, so the cast through unknown is safe.
await runMigrations(db as unknown as Parameters<typeof runMigrations>[0]);
// `conn` here is `Connection<undefined>` (no principal type plumbed
// through a bare `typegres()`); runMigrations only uses .execute, so the
// cast through unknown is safe.
await runMigrations(conn as unknown as Parameters<typeof runMigrations>[0]);
console.log("Done.");
9 changes: 6 additions & 3 deletions site/src/demo/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// Boots PGlite + typegres in the browser, runs migrations + seed.
// Uses top-level await so schema files can import a ready `db` and
// define tables at module-eval time.
// `typegres()` itself is synchronous — the top-level await here is for
// PGlite's WASM boot and the seed, not for the schema handle, so schema
// files can import a ready `db` and define tables at module-eval time.

import { ensurePgLiveEventsTable, typegres } from "typegres";
import { PgliteDriver } from "typegres/drivers/pglite";
import { runMigrations, runSeed } from "./seed";
import type { UserRoot } from "./server/api";

export const { db, conn } = await typegres<UserRoot>({ type: "pglite" });
export const db = typegres<UserRoot>();
export const conn = db.connect(await PgliteDriver.create());

await runMigrations(conn);
await runSeed(conn);
Expand Down
7 changes: 5 additions & 2 deletions src/builder/insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as sqlite from "../types/sqlite";
import type { InsertRow } from "../types/runtime";
import { sql } from "./sql";
import { typegres } from "../index";
import { SqliteDriver } from "../drivers/sqlite";
import { setupDb, db, withinTransaction } from "../test-helpers";
setupDb();

Expand Down Expand Up @@ -147,7 +148,8 @@ test("postgres: column provided in some rows but not others → DEFAULT keyword
});

test("sqlite: pruning defers to rowid autoincrement and declared defaults", async () => {
const { db: sdb, conn } = await typegres({ type: "sqlite" });
const sdb = typegres();
const conn = sdb.connect(SqliteDriver.create(":memory:"));
try {
await conn.execute(sql.raw(`CREATE TABLE tagged (
id INTEGER PRIMARY KEY,
Expand All @@ -173,7 +175,8 @@ test("sqlite: pruning defers to rowid autoincrement and declared defaults", asyn
});

test("sqlite: heterogeneous rows raise instead of silently inserting NULL", async () => {
const { db: sdb, conn } = await typegres({ type: "sqlite" });
const sdb = typegres();
const conn = sdb.connect(SqliteDriver.create(":memory:"));
try {
await conn.execute(sql.raw(`CREATE TABLE mixed (
id INTEGER PRIMARY KEY,
Expand Down
6 changes: 3 additions & 3 deletions src/builder/sql.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { test, expect } from "vitest";
import { sql, compile, Ident } from "./sql";
import { Database } from "../database";
import { compileOnlyDb } from "../test-helpers";
// Test-only shim: these unit tests exercise SQL emission without a real
// Database. Untagged Idents (constructed via the library-internal `new
// Ident(name)` path) still pass through — a wrapper for readability.
const $ident = (name: string) => new Ident(name);
const pgDb = new Database({ dialect: "postgres" });
const sqliteDb = new Database({ dialect: "sqlite" });
const pgDb = compileOnlyDb("postgres");
const sqliteDb = compileOnlyDb("sqlite");
const pgCtx = { database: pgDb };
const sqliteCtx = { database: sqliteDb };

Expand Down
18 changes: 9 additions & 9 deletions src/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ let poolDb: Database;
let poolConn: Connection;

beforeAll(async () => {
poolDriver = await PgDriver.create(requireDatabaseUrl(), { max: 10 });
poolDb = new Database({ dialect: "postgres" });
poolConn = poolDb.attach(poolDriver);
poolDriver = PgDriver.create(requireDatabaseUrl(), { max: 10 });
poolDb = new Database();
poolConn = poolDb.connect(poolDriver);
});

afterAll(async () => {
Expand Down Expand Up @@ -147,7 +147,7 @@ describe("defaultConnection", () => {
};

test("no connection attached → throws", () => {
const empty = new Database({ dialect: "postgres" });
const empty = new Database();
expect(() => empty.defaultConnection).toThrow(/no connection attached/);
});

Expand All @@ -170,11 +170,11 @@ describe("defaultConnection", () => {

test("ambiguous (two attached) → throws until one closes", async () => {
// Fresh db + its own drivers so close() doesn't touch the shared pool.
const fdb = new Database({ dialect: "postgres" });
const d1 = await PgDriver.create(requireDatabaseUrl(), { max: 1 });
const d2 = await PgDriver.create(requireDatabaseUrl(), { max: 1 });
const c1 = fdb.attach(d1);
const c2 = fdb.attach(d2);
const fdb = new Database();
const d1 = PgDriver.create(requireDatabaseUrl(), { max: 1 });
const d2 = PgDriver.create(requireDatabaseUrl(), { max: 1 });
const c1 = fdb.connect(d1);
const c2 = fdb.connect(d2);
expect(() => fdb.defaultConnection).toThrow(/2 connections attached/);

// Connection.close() deregisters (then closes its driver), restoring an
Expand Down
Loading
Loading