Skip to content

DO PoC#86

Open
ryanrasti wants to merge 12 commits into
mainfrom
ryan_do_poc
Open

DO PoC#86
ryanrasti wants to merge 12 commits into
mainfrom
ryan_do_poc

Conversation

@ryanrasti

Copy link
Copy Markdown
Owner

No description provided.

ryanrasti and others added 12 commits July 14, 2026 17:01
A Cloudflare Durable Object chat app whose data layer is typegres running
inside the DO's SQLite, driven end-to-end over Cap'n Web (see plan). Phase 0
scaffolds the worker/DO + vitest-pool-workers (workerd runs locally here) and
pins the SqlStorage binding/result contract as a living artifact.

Key findings that shape the coming DoSqliteDriver:
- numbers bind as REAL (7/2 = 3.5), same as better-sqlite3 -> integer affinity
  comes from the dialect's CAST, which works in workerd too;
- SqlStorage REJECTS BigInt (opposite of better-sqlite3) -> the driver must map
  typegres's boolean 0n/1n to plain 0/1;
- blobs bind from Uint8Array, return as ArrayBuffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SqlStorage always marshals INTEGER results to a JS number (never bigint), so
values above 2^53 come back lossy (9223372036854775807 -> ...776000) -- worse
than better-sqlite3, which can return bigint. It stores int64 correctly, so
CAST(col AS TEXT) reads the exact value. Chat ids are small, but the
limitation is now on record with its workaround.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A typegres Driver over Cloudflare's SqlStorage. Compiles typegres queries to
{text, ?-values}, down-converts BigInt→Number (SqlStorage rejects BigInt),
runs ctx.storage.sql.exec, and normalizes native results (number/string/
ArrayBuffer/null) to the string form deserialize expects (blobs as \x-hex).
Single-connection; no-op close. Built/tested in the example; promote to core
once proven.

Test: a real typegres select (.upper()/.orderBy()) runs against the DO SQLite
inside workerd and deserializes correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 4-table schema (users, rooms, room_members, messages) as module-level
typegres tables bound to a driverless Database; each DO attaches its own
DoSqliteDriver. The DO runs the migration in blockConcurrencyWhile on init.
Relation methods (room.messages(), message.author(), ...) use the codegen's
scope propagation.

Test: inserts across all four tables + a messages⋈users join run through the
DO's SQLite in workerd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User (the main cap) and Room, with @expose gating as the authorization story:
reads return client-refinable query builders (createRoom/joinRoom/room(id)
hand back Room caps; room(id) enforces membership at grant time), mutations
(post) execute server-side. authenticate() is the auth entry.

Test (in-process, no wire): createRoom + post + read, and a non-member is
denied a Room cap until joinRoom — all against the DO's SQLite. Cap'n Web
(Phase 4) gates the same graph over the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ase 4)

The DO serves its @expose capability graph to clients over a real WebSocket
via Cap'n Web (newWorkersWebSocketRpcResponse + toRpc(user)); clients author
typegres queries as record-replay closures that run server-side against the
DO's SQLite. Auth is a ?user= token → the authenticated User is the main cap.

typegres now exports the shim at `typegres/capnweb` (toRpc/fromRpc/doRpc/
ShimStub); tsdown builds it with swc (decorators) and capnweb external.

Tests (real workerd, WebSocket transport):
- client authors createRoom + a .rooms().select(...).execute(conn) query,
  replayed in the DO;
- @expose gating holds over the wire: a non-member's room(id) rejects.

Notes: schema columns/relations are @expose'd so client-authored selects can
read them; isolated storage is off (a live WS keeps the DO's SQLite open), so
each test uses its own DO id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A React chat client (client/) over the Cap'n Web capability graph: login →
create/join rooms → post + read messages, every call authoring a typegres
query that runs in the DO. client/api.ts is the framework-agnostic doRpc glue.

Made the example a real deployable Cloudflare app:
- vite builds client/ → dist/client, served as static assets;
- wrangler.jsonc gains an `assets` binding + the Worker routes /ws to the DO
  and everything else to ASSETS;
- the node-only typegres drivers (pg/better-sqlite3/pglite) are aliased to an
  empty stub so their native deps stay out of the workerd bundle (the DO uses
  only DoSqliteDriver);
- scripts: dev (vite watch + wrangler dev), build, deploy, preview.

`wrangler deploy --dry-run` bundles clean; 14 tests still green; README added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against a real workerd (wrangler dev) driven by a headless browser;
all three are browser/React-runtime bugs the node and in-process workerd tests
could not surface.

1. Login sent the capnweb stub through setState. connect() returns a capnweb
   stub, which is a *callable* Proxy. `setClient(connect(name))` made React
   treat it as a functional updater and invoke `stub(prevState)` -> the wire
   showed a spurious `main(null)` call and every later query pipelined off its
   broken result ("'' is not a function"). Store it via an initializer:
   `setClient(() => c)`.

2. joinRoom was not idempotent. Opening a room you already belong to (every
   room in your sidebar) ran a plain INSERT into room_members and hit a UNIQUE
   constraint. Skip the insert when membership already exists (the DO is
   single-threaded; typegres has no ON CONFLICT yet). Regression test added.

3. Feed auto-scroll effect used a concise arrow body, so useEffect returned the
   value of scrollIntoView() as its "cleanup" -> React later called it and threw
   "destroy is not a function" on every feed update. Use a block body.

Full create -> open -> post -> read round trip now works in the minified
production bundle with zero console errors; 15 workerd tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Previously the sidebar only listed rooms you'd already joined, and nothing
surfaced the id of a room someone else created -- so there was no way to join
one through the UI (only the plumbing, User.joinRoom, existed).

Add a public directory:
- User.allRooms() -- a client-refinable builder over every room. Any
  authenticated user may list it; a Room *capability* is still only granted
  once you're a member, so the @expose authorization story is unchanged.
- Client lists allRooms() alongside rooms() and diffs them: rooms you're in are
  clickable, rooms you're not in show a [Join] button that joins then opens.

Verified in a real browser: a second user sees another's room in the directory,
joins it, reads the existing message, and both users exchange messages. Added a
workerd regression test for discover -> join -> read. 16 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Promote DoSqliteDriver into typegres with typegres/core and typegres/do-sqlite
entries so Workers avoid optional node peers (drop empty.ts aliases). Reshape
the chat demo around ChatApi.userByName, client-authored doRpc queries (no
api/feed wrappers), Tailwind, and tg generate from migrate DDL. Add LLM-oriented
guidelines.md.
- tg generate emits runtime imports from typegres/core (worker-safe).
- SQLite introspector only marks INTEGER PRIMARY KEY as generated when the
  PK is single-column (composite pk=1 is not a rowid alias); regression test.
- Chat schema: created_by → created_by_user_id so relation naming has no clash;
  drop generate post-process rewrites.
Expand guidelines with the relation-naming rule (strip trailing _id).
Replace generate-schema.mjs with TypeScript that imports MIGRATE_SQL from
src/migrate.ts, matching examples/sqlite's node --experimental-strip-types
pattern.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant