Skip to content

Commit 0cbf0fc

Browse files
ryanrasticlaude
andauthored
typegres() is a sync schema handle; drivers imported explicitly (#95)
typegres() now takes no arguments and returns a Database, so table classes declare at module load with no top-level await. attach() becomes connect(), always synchronous — the async-ness moves to the drivers, and only PgliteDriver.create() is awaited. Drivers are imported explicitly from typegres/drivers/* and constructed with .create(), which deletes the dynamic-import machinery and its lint exemptions while still keeping optional peers out of the root bundle. Dialect is gone from Database: the driver is the source of truth, db.dialect is a passthrough to the first connected one, and later connects must agree. Compile-only suites (provenance, extractor, type-level match) connect a dialect-only test driver instead of declaring a dialect. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 098e59a commit 0cbf0fc

32 files changed

Lines changed: 364 additions & 243 deletions

README.md

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

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

5-
- **Methods on Postgres tables = your API.** No routes. No GraphQL. No auto-CRUD.
6-
- **Every Postgres function, fully typed.** All 77 base types, every operator, nullability tracked at the type level.
7-
- **Clients compose typed SQL across the wire.** Server validates the surface area you expose.
5+
- **Methods on your tables = your API.** No routes. No GraphQL. No auto-CRUD.
6+
- **Every Postgres/SQLite function, fully typed.** All base types, every operator,
7+
nullability tracked at the type level.
8+
- **Clients compose typed SQL across the wire.** Server validates the surface
9+
area you expose.
810
- **Live by default.** `.live()` re-queries when the underlying data changes — pushed directly to clients.
911

1012
> [typegres.com/play](https://typegres.com/play) · [demo.mp4](./assets/demo.mp4) · [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
@@ -15,29 +17,39 @@
1517
> yet recommended for production.
1618
1719
```bash
18-
npm install typegres pg
20+
npm install typegres better-sqlite3
1921
```
2022

2123
```typescript
22-
import { typegres, expose } from "typegres";
23-
import { Int8, Text } from "typegres/postgres";
24+
import { typegres, expose, sql } from "typegres";
25+
import { SqliteDriver } from "typegres/drivers/sqlite";
26+
import { Integer, Text } from "typegres/sqlite";
2427

25-
const { db, conn } = await typegres({
26-
type: "pg",
27-
connectionString: process.env.DATABASE_URL!,
28-
});
28+
const db = typegres();
29+
const conn = db.connect(SqliteDriver.create());
30+
31+
await conn.execute(sql`CREATE TABLE users (
32+
id INTEGER PRIMARY KEY,
33+
first_name TEXT NOT NULL,
34+
last_name TEXT NOT NULL
35+
)`);
2936

3037
class Users extends db.Table("users") {
31-
@expose() id = (Int8<1>).column({ nonNull: true, generated: true });
32-
@expose() first_name = (Text<1>).column({ nonNull: true });
33-
@expose() last_name = (Text<1>).column({ nonNull: true });
38+
@expose() id = Integer.column({ nonNull: true, generated: true });
39+
@expose() first_name = Text.column({ nonNull: true });
40+
@expose() last_name = Text.column({ nonNull: true });
3441

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

48+
await Users.insert(
49+
{ first_name: "Alice", last_name: "Smith" },
50+
{ first_name: "Bob", last_name: "Jones" },
51+
).execute(conn);
52+
4153
// `fullName()` works anywhere a column does — select, where, orderBy:
4254
const rows = await Users.from()
4355
.select(({ users }) => ({
@@ -50,43 +62,88 @@ console.log(rows);
5062
await conn.close();
5163
```
5264

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

69+
## Backends
70+
71+
`typegres()` is a synchronous schema handle — no top-level await, so table
72+
classes can be declared at module load. The backend arrives separately via
73+
`db.connect(driver)`, and the same schema classes and query builder run
74+
against any of them:
75+
76+
```typescript
77+
import { PgDriver } from "typegres/drivers/pg"; // node-postgres
78+
import { PgliteDriver } from "typegres/drivers/pglite"; // in-process WASM Postgres
79+
import { SqliteDriver } from "typegres/drivers/sqlite"; // better-sqlite3
80+
import { DoSqliteDriver } from "typegres/drivers/do"; // Cloudflare Durable Object
81+
82+
const db = typegres();
83+
84+
db.connect(PgDriver.create(process.env.DATABASE_URL!));
85+
db.connect(SqliteDriver.create("dev.db")); // omit the filename for :memory:
86+
db.connect(DoSqliteDriver.create(ctx.storage)); // in the DO constructor — no npm peer needed
87+
db.connect(await PgliteDriver.create()); // the one async driver: booting WASM is real I/O
88+
```
89+
90+
Drivers are imported explicitly from `typegres/drivers/*` so optional peers
91+
stay out of bundles that never use them — install only the one you need.
92+
93+
With exactly one connection (the Durable Object model), it's also the
94+
default: `.execute()` / `.live()` take no argument, and you can ignore what
95+
`connect` returns. Pass a `Connection` explicitly when you have several —
96+
read replicas, database-per-tenant, or a transaction's `tx`.
97+
5798
## How it works
5899

59-
1. **Types codegen'd from the Postgres catalog.** 77 base types, full
100+
1. **Types codegen'd from the Postgres/SQLite catalog/docs.** all base types, full
60101
method/operator coverage, nullability tracked at the type level.
61102
2. **Object-capability queries.** Clients can only reach what you've exposed
62103
as `@expose` methods — columns, relations, scoped reads, mutations. The class
63104
surface is the contract; the schema underneath is free to move.
64105
3. **Object-capability RPC.** The query builder ships to a constrained
65106
interpreter on the server; only `@expose`-marked methods reach evaluation.
66-
4. **Live queries.** `.live()` watches the predicates your query depends
67-
on and re-yields when committed mutations would change the result.
107+
4. **Live queries.** Tables opt in with `db.Table("name", { live: true })`.
108+
`.live()` watches the predicates your query depends on and re-yields when
109+
committed mutations would change the result — via a polling bus on
110+
Postgres, and synchronous mutation capture on SQLite.
68111

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

114+
## Examples
115+
116+
- [`examples/basic`](./examples/basic) — Postgres/PGLite scaffold:
117+
migrations, `tg generate` codegen, relations (`Relation.belongsTo` / `.has`).
118+
- [`examples/sqlite`](./examples/sqlite) — the same scaffold on
119+
better-sqlite3.
120+
- [`examples/chat`](./examples/chat) — full-stack chat on a Cloudflare
121+
Durable Object: SQLite storage, Cap'n Web RPC from the browser, live
122+
queries pushed to clients, and facet-based capability security (the whole
123+
server is the schema — there are no routes).
124+
71125
## Status
72126

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

84142
## Planned
85143

86-
- [ ] SQLite backend (sql-builder is dialect-aware; adapter is stubbed)
87-
- [ ] `pg_notify`-driven live updates (currently a single shared polling loop, not per-subscription)
88-
- [ ] WAL-mode for live updates (currently uses an auxiliary table)
89-
- [ ] Cap'n Web transport (in-flight upstream PR;
144+
- [ ] `pg_notify`-driven live updates (Postgres currently uses a single shared polling loop, not per-subscription)
145+
- [ ] WAL-mode live updates for Postgres (currently uses an auxiliary table)
146+
- [ ] Upstream the Cap'n Web integration (in-tree shim today;
90147
[cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162))
91148

92149
## Development

examples/basic/src/db.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
import { typegres } from "typegres";
2+
import { PgliteDriver } from "typegres/drivers/pglite";
23

3-
export const { db, conn } = await typegres({ type: "pglite" });
4+
// `typegres()` itself is synchronous; only the driver is awaited, because
5+
// booting WASM Postgres is real I/O. Table classes in ./tables reference
6+
// `db` at module load.
7+
export const db = typegres();
8+
export const conn = db.connect(await PgliteDriver.create());

examples/chat/worker/api.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
// a class inline (X.forY(...)); parameterized ones close over their proof.
1414

1515
import { z } from "zod";
16-
import { Database, expose, Relation } from "typegres";
16+
import { typegres, expose, Relation } from "typegres";
1717
import { Integer, Text } from "typegres/sqlite";
1818
import { hashPassword, verifyPassword } from "./auth";
1919

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

2426
const zUsername = z.string().regex(/^[\w-]{1,24}$/);
2527
const zPassword = z.string().min(1).max(128);

examples/chat/worker/chat-do.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import { migrate } from "./migrate";
88

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

1515
constructor(ctx: DurableObjectState, env: Env) {
1616
super(ctx, env);
17-
this.conn = db.attach(new DoSqliteDriver(ctx.storage));
17+
this.conn = db.connect(DoSqliteDriver.create(ctx.storage));
1818
ctx.blockConcurrencyWhile(() => migrate(this.conn));
1919
}
2020

examples/sqlite/src/db.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { typegres } from "typegres";
2+
import { SqliteDriver } from "typegres/drivers/sqlite";
23

3-
// `typegres({ type: "sqlite" })` opens a SqliteDriver against the given
4-
// file (or `:memory:` if omitted). The tests use `:memory:` so each
4+
// Synchronous end to end — no top-level await: `typegres()` is a
5+
// module-load-safe schema handle, and better-sqlite3 opens the database on
6+
// construction. The tests use `:memory:` (the `sqlite()` default) so each
57
// vitest run is hermetic; the `tg generate` CLI reads schema from the
68
// `./dev.db` file produced by `npm run migrate`.
7-
export const { db, conn } = await typegres({ type: "sqlite" });
9+
export const db = typegres();
10+
export const conn = db.connect(SqliteDriver.create());

site/migrate.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
// source of truth for the schema.
55

66
import { typegres } from "typegres";
7+
import { PgDriver } from "typegres/drivers/pg";
78
import { runMigrations } from "./src/demo/seed.ts";
89

9-
const db = await typegres({
10-
type: "pg",
11-
connectionString: process.env["DATABASE_URL"] ?? "postgres://localhost/postgres",
12-
});
10+
const db = typegres();
11+
const conn = db.connect(
12+
PgDriver.create(process.env["DATABASE_URL"] ?? "postgres://localhost/postgres"),
13+
);
1314

1415
console.log("Applying migrations...");
15-
// `db` here is `Database<undefined>` (no principal type plumbed
16-
// through `typegres({ type: "pg" })`); runMigrations only uses
17-
// .execute, so the cast through unknown is safe.
18-
await runMigrations(db as unknown as Parameters<typeof runMigrations>[0]);
16+
// `conn` here is `Connection<undefined>` (no principal type plumbed
17+
// through a bare `typegres()`); runMigrations only uses .execute, so the
18+
// cast through unknown is safe.
19+
await runMigrations(conn as unknown as Parameters<typeof runMigrations>[0]);
1920
console.log("Done.");

site/src/demo/runtime.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// Boots PGlite + typegres in the browser, runs migrations + seed.
2-
// Uses top-level await so schema files can import a ready `db` and
3-
// define tables at module-eval time.
2+
// `typegres()` itself is synchronous — the top-level await here is for
3+
// PGlite's WASM boot and the seed, not for the schema handle, so schema
4+
// files can import a ready `db` and define tables at module-eval time.
45

56
import { ensurePgLiveEventsTable, typegres } from "typegres";
7+
import { PgliteDriver } from "typegres/drivers/pglite";
68
import { runMigrations, runSeed } from "./seed";
79
import type { UserRoot } from "./server/api";
810

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

1114
await runMigrations(conn);
1215
await runSeed(conn);

src/builder/insert.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as sqlite from "../types/sqlite";
44
import type { InsertRow } from "../types/runtime";
55
import { sql } from "./sql";
66
import { typegres } from "../index";
7+
import { SqliteDriver } from "../drivers/sqlite";
78
import { setupDb, db, withinTransaction } from "../test-helpers";
89
setupDb();
910

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

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

175177
test("sqlite: heterogeneous rows raise instead of silently inserting NULL", async () => {
176-
const { db: sdb, conn } = await typegres({ type: "sqlite" });
178+
const sdb = typegres();
179+
const conn = sdb.connect(SqliteDriver.create(":memory:"));
177180
try {
178181
await conn.execute(sql.raw(`CREATE TABLE mixed (
179182
id INTEGER PRIMARY KEY,

src/builder/sql.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { test, expect } from "vitest";
22
import { sql, compile, Ident } from "./sql";
3-
import { Database } from "../database";
3+
import { compileOnlyDb } from "../test-helpers";
44
// Test-only shim: these unit tests exercise SQL emission without a real
55
// Database. Untagged Idents (constructed via the library-internal `new
66
// Ident(name)` path) still pass through — a wrapper for readability.
77
const $ident = (name: string) => new Ident(name);
8-
const pgDb = new Database({ dialect: "postgres" });
9-
const sqliteDb = new Database({ dialect: "sqlite" });
8+
const pgDb = compileOnlyDb("postgres");
9+
const sqliteDb = compileOnlyDb("sqlite");
1010
const pgCtx = { database: pgDb };
1111
const sqliteCtx = { database: sqliteDb };
1212

src/database.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ let poolDb: Database;
1515
let poolConn: Connection;
1616

1717
beforeAll(async () => {
18-
poolDriver = await PgDriver.create(requireDatabaseUrl(), { max: 10 });
19-
poolDb = new Database({ dialect: "postgres" });
20-
poolConn = poolDb.attach(poolDriver);
18+
poolDriver = PgDriver.create(requireDatabaseUrl(), { max: 10 });
19+
poolDb = new Database();
20+
poolConn = poolDb.connect(poolDriver);
2121
});
2222

2323
afterAll(async () => {
@@ -147,7 +147,7 @@ describe("defaultConnection", () => {
147147
};
148148

149149
test("no connection attached → throws", () => {
150-
const empty = new Database({ dialect: "postgres" });
150+
const empty = new Database();
151151
expect(() => empty.defaultConnection).toThrow(/no connection attached/);
152152
});
153153

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

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

180180
// Connection.close() deregisters (then closes its driver), restoring an

0 commit comments

Comments
 (0)