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
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,106 @@ For a complete scaffold with migrations + codegen, see the
[examples](#examples). Or try it interactively at
[typegres.com/play](https://typegres.com/play).

## Clients compose the queries

The class surface is the contract. A client composes against `@expose`-marked
methods, the closure is serialized, and the server evaluates it under a
constrained interpreter — so a client can write any query it likes, and still
reach only what you exposed.

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

```typescript
import { typegres, expose, sql } from "typegres";
import { doRpc, toRpc, newMessagePortRpcSession, type ShimStub } from "typegres/capnweb";
import { SqliteDriver } from "typegres/drivers/sqlite";
import { Integer, Text } from "typegres/sqlite";
import z from "zod";

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

await db.defaultConnection.execute(sql`CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
team_token TEXT NOT NULL
)`);
await db.defaultConnection.execute(sql`CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
body TEXT NOT NULL
)`);

class Users extends db.Table("users") {
@expose() id = Integer.column({ nonNull: true, generated: true });
@expose() name = Text.column({ nonNull: true });
// No @expose: the server scopes on it, and no client query can select
// or filter by it.
team_token = Text.column({ nonNull: true });
}

class Posts extends db.Table("posts") {
@expose() id = Integer.column({ nonNull: true, generated: true });
@expose() user_id = Integer.column({ nonNull: true });
@expose() body = Text.column({ nonNull: true });
}

await Users.insert(
{ name: "Alice", team_token: "t-acme" },
{ name: "Bob", team_token: "t-acme" },
{ name: "Carol", team_token: "t-other" }, // different team
).execute();
await Posts.insert(
{ user_id: 1, body: "one" },
{ user_id: 1, body: "two" },
{ user_id: 2, body: "three" },
{ user_id: 3, body: "not yours" },
).execute();

// The capability root — the entire surface a client can reach.
class Api {
// Hands back a builder over one team's posts, already joined to authors.
// Everything the client writes is rooted here, so it can only narrow.
@expose(z.string())
feedFor(teamToken: string) {
return Posts.from()
.join(Users, ({ posts, users }) => posts.user_id.eq(users.id))
.where(({ users }) => users.team_token.eq(teamToken));
}
}

// Server and client, joined here by a MessagePort so this runs in one
// process. `examples/chat` is the same two lines over a WebSocket.
const { port1, port2 } = new MessageChannel();
newMessagePortRpcSession(port1, toRpc(new Api()));
const api = newMessagePortRpcSession<Api>(port2) as unknown as ShimStub<Api>;

// "Top posters" — written on the client, evaluated on the server. There is
// no endpoint for this: the client composed the group-by, the aggregate and
// the ordering itself. The team scoping is baked into the builder, so the
// refinement can only narrow it, and Carol's row never appears.
const rows = await doRpc(api, (a) =>
a
.feedFor("t-acme")
.groupBy(({ users }) => [users.name])
.select(({ users, posts }) => ({ author: users.name, posts: posts.id.count() }))
.orderBy(({ posts }) => [posts.id.count(), "desc"])
.execute(),
);

console.log(rows);

port1.close();
port2.close();
await db.defaultConnection.close();
```

Swap the MessagePort for `newWebSocketRpcSession` / `newWorkersRpcResponse` and
the same code runs browser-to-server, with capabilities, promise pipelining,
and live subscriptions — see [`examples/chat`](./examples/chat).

## Backends

`typegres()` is a synchronous schema handle — no top-level await, so table
Expand Down Expand Up @@ -143,6 +243,18 @@ Deeper dive in [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).
- [x] Cap'n Web transport (`typegres/capnweb`) — capabilities, promises, and
live subscriptions over a single WebSocket

> **Import Cap'n Web from `typegres/capnweb`, not from `capnweb`.** The
> transport needs a fork that isn't published yet (closure serialization,
> synchronous replay, `getLocalTarget` — see
> [cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162)),
> so it ships bundled, and `typegres/capnweb` re-exports what you need:
> `RpcTarget`, `RpcStub`, `newWebSocketRpcSession`, `newWorkersRpcResponse`.
> Installing `capnweb` alongside it gives you a second copy whose
> `RpcTarget`/`RpcStub` fail `instanceof` against the bundled one — which
> surfaces as confusing RPC errors at the boundary rather than a clean
> failure. When #162 lands, capnweb becomes an ordinary dependency and these
> imports keep working unchanged.

## Planned

- [ ] `pg_notify`-driven live updates (Postgres currently uses a single shared polling loop, not per-subscription)
Expand Down
13 changes: 5 additions & 8 deletions examples/chat/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion examples/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"reset:local": "rm -rf .wrangler && echo 'local DO storage cleared — next dev/test re-runs migrations'"
},
"dependencies": {
"capnweb": "file:../../packages/capnweb",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typegres": "file:../..",
Expand Down
3 changes: 1 addition & 2 deletions examples/chat/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { newWebSocketRpcSession } from "capnweb";
import { doRpc, type ShimStub, type Stubbed } from "typegres/capnweb";
import { newWebSocketRpcSession, doRpc, type ShimStub, type Stubbed } from "typegres/capnweb";
import type { Chat, Users, Rooms } from "../worker/api";
import { wireLog } from "./wire-log";

Expand Down
3 changes: 1 addition & 2 deletions examples/chat/tests/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@

import { describe, test, expect, expectTypeOf, vi } from "vitest";
import { SELF } from "cloudflare:test";
import { newWebSocketRpcSession } from "capnweb";
import { byRef, doRpc, type ShimStub } from "typegres/capnweb";
import { newWebSocketRpcSession, byRef, doRpc, type ShimStub } from "typegres/capnweb";
import type { Chat, Users, Rooms, Memberships } from "../worker/api";

type Principal = InstanceType<ReturnType<typeof Users.forPrincipal>>;
Expand Down
3 changes: 1 addition & 2 deletions examples/chat/tests/facet-spike.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@

import { test, expect } from "vitest";
import { env, runInDurableObject } from "cloudflare:test";
import { newWebSocketRpcSession, type RpcTarget } from "capnweb";
import { doRpc, toRpc, type ShimStub } from "typegres/capnweb";
import { newWebSocketRpcSession, doRpc, toRpc, type RpcTarget, type ShimStub } from "typegres/capnweb";
import { Chat, Users, Memberships } from "../worker/api";
import type { ChatDo } from "../worker/chat-do";

Expand Down
3 changes: 1 addition & 2 deletions examples/chat/worker/chat-do.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DurableObject } from "cloudflare:workers";
import { newWorkersRpcResponse, type RpcTarget } from "capnweb";
import { toRpc } from "typegres/capnweb";
import { newWorkersRpcResponse, toRpc, type RpcTarget } from "typegres/capnweb";
import { DoSqliteDriver } from "typegres/drivers/do";
import type { Connection } from "typegres";
import { db, Chat } from "./api";
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
"types": "./dist/exoeval/index.d.mts"
},
"./capnweb": {
"import": "./dist/capnweb/shim.mjs",
"types": "./dist/capnweb/shim.d.mts"
"types": "./dist/capnweb/shim.d.mts",
"import": {
"workerd": "./dist/capnweb/shim-workers.mjs",
"default": "./dist/capnweb/shim.mjs"
}
},
"./drivers/do": {
"import": "./dist/drivers/do.mjs",
Expand All @@ -57,8 +60,8 @@
"dist"
],
"peerDependencies": {
"@electric-sql/pglite": "^0.4.4",
"better-sqlite3": "^12.11.1",
"@electric-sql/pglite": "^0.4.4 || ^0.5.0",
"better-sqlite3": "^12.11.1 || ^13.0.0",
"pg": "^8.20.0"
},
"peerDependenciesMeta": {
Expand Down Expand Up @@ -109,12 +112,12 @@
"secure-json-parse": "^4.1.0",
"tsdown": "^0.22.7",
"typescript": "^6.0.3",
"capnweb": "file:packages/capnweb",
"unplugin-swc": "^1.5.9",
"vitest": "^4.1.10",
"zod": "^4.4.3"
},
"dependencies": {
"camelcase": "^9.0.0",
"capnweb": "file:packages/capnweb"
"camelcase": "^9.0.0"
}
}
18 changes: 18 additions & 0 deletions src/capnweb/shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import { getLocalTarget, RpcStub, RpcTarget } from "capnweb";
import { getTool } from "../exoeval/tool";
import { isPlainObject, isThenable } from "../util";

// capnweb is bundled into this entry point rather than resolved from the
// consumer's node_modules: typegres needs a fork (closure serialization,
// synchronous replay, getLocalTarget) that isn't published. Re-exporting the
// surface a caller needs means they never `import ... from "capnweb"`
// themselves, so exactly one copy is ever in play — which matters because
// RpcTarget/RpcStub identity is compared across the boundary, and two copies
// would fail `instanceof` in ways that surface as baffling RPC errors.
//
// When cloudflare/capnweb#162 lands, capnweb becomes an ordinary dependency
// and these re-exports keep working unchanged.
export {
RpcTarget,
RpcStub,
newWebSocketRpcSession,
newWorkersRpcResponse,
newMessagePortRpcSession,
} from "capnweb";

// --- capnweb shim for @expose classes ---
//
// Adapts typegres's @expose capability vocabulary to capnweb RPC. A class instance crossing
Expand Down
Loading