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
7720npm install typegres better-sqlite3 zod
7821```
7922
8023``` typescript
81- import { typegres , expose , sql } from " typegres" ;
24+ import { typegres , expose , sql , Relation } from " typegres" ;
8225import { doRpc , toRpc , newMessagePortRpcSession , type ShimStub } from " typegres/capnweb" ;
8326import { SqliteDriver } from " typegres/drivers/sqlite" ;
8427import { Integer , Text } from " typegres/sqlite" ;
@@ -89,7 +32,8 @@ db.connect(SqliteDriver.create());
8932
9033await 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) ` );
9539await 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-
10945class 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+
11578await 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 ();
120101await 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.
128109class 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
160162port1 .close ();
161163port2 .close ();
162164await 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+
165199Swap the MessagePort for ` newWebSocketRpcSession ` / ` newWorkersRpcResponse ` and
166200the same code runs browser-to-server, with capabilities, promise pipelining,
167201and 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
0 commit comments