Skip to content

Commit fbd2013

Browse files
authored
enhance the readme example (#103)
1 parent 1a17380 commit fbd2013

2 files changed

Lines changed: 139 additions & 108 deletions

File tree

README.md

Lines changed: 113 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -16,69 +16,12 @@
1616
> **Developer preview** — surface is settled, edges still being filed. Not
1717
> yet recommended for production.
1818
19-
```bash
20-
npm install typegres better-sqlite3
21-
```
22-
23-
```typescript
24-
import { typegres, expose, sql } from "typegres";
25-
import { SqliteDriver } from "typegres/drivers/sqlite";
26-
import { Integer, Text } from "typegres/sqlite";
27-
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-
)`);
36-
37-
class Users extends db.Table("users") {
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 });
41-
42-
// Derived column — composes back into your typed query API.
43-
@expose() fullName() {
44-
return this.first_name["||"](" ")["||"](this.last_name);
45-
}
46-
}
47-
48-
await Users.insert(
49-
{ first_name: "Alice", last_name: "Smith" },
50-
{ first_name: "Bob", last_name: "Jones" },
51-
).execute(conn);
52-
53-
// `fullName()` works anywhere a column does — select, where, orderBy:
54-
const rows = await Users.from()
55-
.select(({ users }) => ({
56-
id: users.id,
57-
name: users.fullName(),
58-
}))
59-
.execute(conn);
60-
61-
console.log(rows);
62-
await conn.close();
63-
```
64-
65-
For a complete scaffold with migrations + codegen, see the
66-
[examples](#examples). Or try it interactively at
67-
[typegres.com/play](https://typegres.com/play).
68-
69-
## Clients compose the queries
70-
71-
The class surface is the contract. A client composes against `@expose`-marked
72-
methods, the closure is serialized, and the server evaluates it under a
73-
constrained interpreter — so a client can write any query it likes, and still
74-
reach only what you exposed.
75-
7619
```bash
7720
npm install typegres better-sqlite3 zod
7821
```
7922

8023
```typescript
81-
import { typegres, expose, sql } from "typegres";
24+
import { typegres, expose, sql, Relation } from "typegres";
8225
import { doRpc, toRpc, newMessagePortRpcSession, type ShimStub } from "typegres/capnweb";
8326
import { SqliteDriver } from "typegres/drivers/sqlite";
8427
import { Integer, Text } from "typegres/sqlite";
@@ -89,7 +32,8 @@ db.connect(SqliteDriver.create());
8932

9033
await db.defaultConnection.execute(sql`CREATE TABLE users (
9134
id INTEGER PRIMARY KEY,
92-
name TEXT NOT NULL,
35+
first_name TEXT NOT NULL,
36+
last_name TEXT NOT NULL,
9337
team_token TEXT NOT NULL
9438
)`);
9539
await db.defaultConnection.execute(sql`CREATE TABLE posts (
@@ -98,25 +42,62 @@ await db.defaultConnection.execute(sql`CREATE TABLE posts (
9842
body TEXT NOT NULL
9943
)`);
10044

101-
class Users extends db.Table("users") {
102-
@expose() id = Integer.column({ nonNull: true, generated: true });
103-
@expose() name = Text.column({ nonNull: true });
104-
// No @expose: the server scopes on it, and no client query can select
105-
// or filter by it.
106-
team_token = Text.column({ nonNull: true });
107-
}
108-
10945
class Posts extends db.Table("posts") {
11046
@expose() id = Integer.column({ nonNull: true, generated: true });
11147
@expose() user_id = Integer.column({ nonNull: true });
11248
@expose() body = Text.column({ nonNull: true });
11349
}
11450

51+
class Users extends db.Table("users") {
52+
// 1. Exposed columns: a client may select, filter and order by these.
53+
@expose() id = Integer.column({ nonNull: true, generated: true });
54+
@expose() first_name = Text.column({ nonNull: true });
55+
@expose() last_name = Text.column({ nonNull: true });
56+
57+
// 2. No decorator: invisible. The server scopes on it below, and no
58+
// client query can select it, filter by it, or learn it exists.
59+
team_token = Text.column({ nonNull: true });
60+
61+
// 3. A "derived column": composes back into the typed query API, so a
62+
// client can group and order by it as if it were stored.
63+
// (Note, "derived columns" are just methods that return SQL fragments
64+
// that can reference `this`, the current row).
65+
// Compiles to: "users"."first_name" || ' ' || "users"."last_name"
66+
@expose() fullName() {
67+
return this.first_name["||"](" ")["||"](this.last_name);
68+
}
69+
70+
// 4. Relation: a reachability edge. Reaching a Users row reaches that
71+
// user's posts, and nothing else. (Note, relations are just methods
72+
// that return query builders referencing `this`)
73+
@expose() posts() {
74+
return Relation.has(this, Posts, { user_id: this.id });
75+
}
76+
}
77+
11578
await Users.insert(
116-
{ name: "Alice", team_token: "t-acme" },
117-
{ name: "Bob", team_token: "t-acme" },
118-
{ name: "Carol", team_token: "t-other" }, // different team
79+
{ first_name: "Alice", last_name: "Smith", team_token: "t-acme" },
80+
{ first_name: "Bob", last_name: "Jones", team_token: "t-acme" },
11981
).execute();
82+
83+
// Query it directly on the server. `fullName()` works anywhere a column
84+
// does — select, where, orderBy:
85+
const names = await Users.from()
86+
.select(({ users }) => ({ name: users.fullName() }))
87+
.execute();
88+
89+
console.log(names); // [ { name: 'Alice Smith' }, { name: 'Bob Jones' } ]
90+
91+
// ── Now the same data model, reached by a client over RPC ──────────────
92+
// Nothing about the classes above changes. The `@expose` marks already
93+
// are the contract; all that's left is to hand out a root capability.
94+
95+
// A third user, on a different team, plus some posts:
96+
await Users.insert({
97+
first_name: "Carol",
98+
last_name: "Vance",
99+
team_token: "t-other",
100+
}).execute();
120101
await Posts.insert(
121102
{ user_id: 1, body: "one" },
122103
{ user_id: 1, body: "two" },
@@ -126,6 +107,8 @@ await Posts.insert(
126107

127108
// The capability root — the entire surface a client can reach.
128109
class Api {
110+
// 5. Arguments are validated by a schema before the method ever runs.
111+
//
129112
// Hands back a builder over one team's posts, already joined to authors.
130113
// Everything the client writes is rooted here, so it can only narrow.
131114
@expose(z.string())
@@ -134,6 +117,11 @@ class Api {
134117
.join(Users, ({ posts, users }) => posts.user_id.eq(users.id))
135118
.where(({ users }) => users.team_token.eq(teamToken));
136119
}
120+
121+
@expose(z.string())
122+
team(teamToken: string) {
123+
return Users.from().where(({ users }) => users.team_token.eq(teamToken));
124+
}
137125
}
138126

139127
// Server and client, joined here by a MessagePort so this runs in one
@@ -144,24 +132,70 @@ const api = newMessagePortRpcSession<Api>(port2) as unknown as ShimStub<Api>;
144132

145133
// "Top posters" — written on the client, evaluated on the server. There is
146134
// no endpoint for this: the client composed the group-by, the aggregate and
147-
// the ordering itself. The team scoping is baked into the builder, so the
135+
// the ordering itself, and grouped by `fullName()` — a method, used exactly
136+
// like a column. The team scoping is baked into the builder, so the
148137
// refinement can only narrow it, and Carol's row never appears.
149-
const rows = await doRpc(api, (a) =>
138+
const top = await doRpc(api, (a) =>
150139
a
151140
.feedFor("t-acme")
152-
.groupBy(({ users }) => [users.name])
153-
.select(({ users, posts }) => ({ author: users.name, posts: posts.id.count() }))
141+
.groupBy(({ users }) => [users.fullName()])
142+
.select(({ users, posts }) => ({ author: users.fullName(), posts: posts.id.count() }))
154143
.orderBy(({ posts }) => [posts.id.count(), "desc"])
155144
.execute(),
156145
);
157146

158-
console.log(rows);
147+
console.log(top); // [ { author: 'Alice Smith', posts: 2 }, { author: 'Bob Jones', posts: 1 } ]
148+
149+
// Rows are capabilities too. `.hydrate()` returns row objects rather than
150+
// plain data, and the relation is an edge you can walk from one — so
151+
// reaching Alice reaches Alice's posts, without a second endpoint.
152+
const [alice] = await doRpc(api, (a) =>
153+
a.team("t-acme").where(({ users }) => users.first_name.eq("Alice")).hydrate(),
154+
);
155+
156+
const alicesPosts = await doRpc(alice, (u) =>
157+
u.posts().select(({ posts }) => ({ body: posts.body })).execute(),
158+
);
159+
160+
console.log(alicesPosts); // [ { body: 'one' }, { body: 'two' } ]
159161

160162
port1.close();
161163
port2.close();
162164
await db.defaultConnection.close();
163165
```
164166

167+
For a complete scaffold with migrations + codegen, see the
168+
[examples](#examples). Or try it interactively at
169+
[typegres.com/play](https://typegres.com/play).
170+
171+
## Clients compose the queries
172+
173+
The class surface is the contract. A client composes against `@expose`-marked
174+
members, the closure is serialized, and the server evaluates it under a
175+
constrained interpreter — so a client can write any query it likes, and still
176+
reach only what you exposed.
177+
178+
The client-authored "top posters" query compiles to plain SQL, with
179+
`fullName()` expanded in both the select list and the `GROUP BY` — which is
180+
what "used exactly like a column" means in practice. The team scoping the
181+
client never wrote is in the `WHERE`:
182+
183+
```sql
184+
SELECT (("users"."first_name" || ?) || "users"."last_name") as "author",
185+
"count"("posts"."id") as "posts"
186+
FROM "posts" AS "posts"
187+
JOIN "users" AS "users" ON ("posts"."user_id" = "users"."id")
188+
WHERE ("users"."team_token" = ?)
189+
GROUP BY (("users"."first_name" || ?) || "users"."last_name")
190+
ORDER BY "count"("posts"."id") DESC
191+
-- params: [" ", "t-acme", " "]
192+
```
193+
194+
Note what the client never does: name a table. `Posts` and `Users` are
195+
server-side identifiers, and a closure that references one won't serialize.
196+
A client starts from a capability it was handed and narrows — which is why
197+
`feedFor`'s join and its team scoping can't be composed away.
198+
165199
Swap the MessagePort for `newWebSocketRpcSession` / `newWorkersRpcResponse` and
166200
the same code runs browser-to-server, with capabilities, promise pipelining,
167201
and live subscriptions — see [`examples/chat`](./examples/chat).
@@ -243,17 +277,10 @@ Deeper dive in [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).
243277
- [x] Cap'n Web transport (`typegres/capnweb`) — capabilities, promises, and
244278
live subscriptions over a single WebSocket
245279

246-
> **Import Cap'n Web from `typegres/capnweb`, not from `capnweb`.** The
247-
> transport needs a fork that isn't published yet (closure serialization,
248-
> synchronous replay, `getLocalTarget` — see
249-
> [cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162)),
250-
> so it ships bundled, and `typegres/capnweb` re-exports what you need:
251-
> `RpcTarget`, `RpcStub`, `newWebSocketRpcSession`, `newWorkersRpcResponse`.
252-
> Installing `capnweb` alongside it gives you a second copy whose
253-
> `RpcTarget`/`RpcStub` fail `instanceof` against the bundled one — which
254-
> surfaces as confusing RPC errors at the boundary rather than a clean
255-
> failure. When #162 lands, capnweb becomes an ordinary dependency and these
256-
> imports keep working unchanged.
280+
> **Import Cap'n Web from `typegres/capnweb`, not from `capnweb`.** It ships
281+
> bundled until [cloudflare/capnweb#162](https://github.com/cloudflare/capnweb/pull/162)
282+
> lands, so installing `capnweb` yourself gives you a second copy whose
283+
> `RpcTarget`/`RpcStub` fail `instanceof` against the bundled one.
257284
258285
## Planned
259286

src/readme.test.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ const packTypegres = (): Promise<string> => {
6767
return packed;
6868
};
6969

70-
// Each runnable README section owns an install line and a program, so a
71-
// section is testable in isolation and the install stays honest (the RPC
72-
// section needs zod; Usage doesn't).
70+
// A runnable README section owns an install line and a program, so the
71+
// install line stays honest — whatever the snippet imports has to appear in
72+
// the `npm install` a reader would actually run.
7373
const runReadmeSection = async (
7474
heading: string,
7575
mode: InstallMode,
@@ -142,31 +142,35 @@ const runReadmeSection = async (
142142
fs.rmSync(tmpDir, { recursive: true, force: true });
143143
};
144144

145+
// The Usage snippet runs the whole arc in one program: a direct server-side
146+
// query, then the same data model reached by a client over RPC. The negative
147+
// assertions are what make it meaningful — `Carol` absent proves feedFor's
148+
// team scoping survived a client-authored group-by (she's on another team and
149+
// is deliberately inserted only after the direct query, so she can't leak in
150+
// via that), `not yours` absent proves her post never crossed the wire, and
151+
// `team_token` absent proves the un-@expose'd column stayed invisible even
152+
// though the server filtered on it.
145153
test(
146154
"README.md Usage snippet — working tree (packed tarball)",
147-
() => runReadmeSection("Usage", "working-tree", ["Alice Smith", "Bob Jones"]),
148-
60_000, // typical: ~5s; generous for better-sqlite3 prebuilt download on cache misses.
149-
);
150-
151-
// The RPC section demonstrates the project's actual claim — a client
152-
// composing a query that reaches only the @expose surface — so it's held to
153-
// the same "it runs" bar as Usage. The negative assertions are what make it
154-
// meaningful: `Carol` absent proves feedFor's team scoping survived a
155-
// client-authored group-by (she has a post, on another team), and
156-
// `team_token` absent proves the un-@expose'd column never crossed the wire
157-
// even though the server filtered on it.
158-
test(
159-
"README.md RPC snippet — working tree (packed tarball)",
160155
() =>
161156
runReadmeSection(
162-
"Clients compose the queries",
157+
"Usage",
163158
"working-tree",
164-
// "posts: 2" pins the aggregate itself — without it the test would
165-
// pass on any query that merely returned both names.
166-
["Alice", "Bob", "posts: 2"],
167-
["Carol", "t-acme", "team_token"],
159+
[
160+
// "Alice Smith" (not "Alice") pins the derived column: the client
161+
// grouped by a method, so a plain column read prints the wrong string.
162+
"Alice Smith",
163+
"Bob Jones",
164+
// Pins the aggregate itself — without it the test would pass on any
165+
// query that merely returned both names.
166+
"posts: 2",
167+
// The relation walk — Alice's two posts and only hers.
168+
"body: 'one'",
169+
"body: 'two'",
170+
],
171+
["Carol", "Vance", "t-acme", "team_token", "not yours", "three"],
168172
),
169-
60_000,
173+
60_000, // typical: ~5s; generous for better-sqlite3 prebuilt download on cache misses.
170174
);
171175

172176
// Registry mode: opt-in via env var. Tests the currently-published

0 commit comments

Comments
 (0)