ADR-0010: The client: generated types, and resumable stores¶
Status: Accepted, amended by artifactr ADR-0063, reflexr ADR-0061 and reflexr ADR-0064 Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
lattice RFC-0002 makes @portalr/client the only code in the UIs that talks to the server. Its types are generated from each package's committed documents, never written. Its REST calls go through openapi-fetch and TanStack Query. It keeps a WebSocket store per stream that resumes by seq, and one reducer per protocol whose exhaustiveness the compiler checks. ADR-0007 left phase 3 to settle where openapi-typescript, which needs TypeScript 6, runs.
The documents are artifactr's and reflexr's OpenAPI documents and WebSocket JSON Schemas, reflexr's rules schema, and grantr's OpenAPI document. Two of their traits shape the code:
- pydantic marks a discriminator with a default as optional, so
typeis optional on every frame and event, and a union can't be narrowed on it. - reflexr's events are open: its schema types an event as
{type, ...}, since the application's events share the log with reflexr's own. artifactr's schema names every event, and keeps one it doesn't know asUnknownEvent.
Decision¶
scripts/generate.tswritessrc/generated/:openapi-typescript7.13.0 for the three OpenAPI documents, andjson-schema-to-typescriptfor the three JSON Schemas. A drift test calls the same function and fails if a committed file differs;moon run client:typesrewrites them.- A constant property is required, since every frame and event carries its discriminator, so the unions narrow on
type. At first the generator made it so before compiling the schemas; the schemas now say so themselves (#82), and the transform is gone. - TypeScript 6 is
client's owntypescript, asnpm:typescript@6.0.3, whichopenapi-typescriptloads. Bun's isolated installs keep it toclient, andclient'stypecheckruns the root's TypeScript 7 by its path, so every package is still checked with 7. json-schema-to-typescriptis at 15.0.4. 16.0.0 needs js-yaml 5, and the root'soverrideshold js-yaml at 4.3.2 for the Bruno CLI. Renovate holds it below 16, andclient's TypeScript at 6.- REST:
createApi(surfaces, session)makes oneopenapi-fetchclient per surface, each request carrying the session'sAuthorizationandX-Tenant.createQueries(api)makes TanStack Query's options for every read.keysnests each read's key under its workspace, then splits each resource into itslistand eachdetail, so invalidating the workspace refetches everything read from it, and a list refetches without its details.stale.artifactrandstale.reflexrname the reads an event makes stale;stale.artifactrlists every event artifactr's schema names.@tanstack/react-queryis a peer dependency; app and admin own theQueryClient. - A
Streamper workspace stream, generic over the protocol, which classifies each frame for it (welcome, replay complete, event, result, error, or another, such as a live frame):- It says
hellofrom the lastseqit applied, applies each event once and in order, skips an event at or below thatseq(a frame sent twice, or a replay overlapping a tail read over REST), and followsreplay_completetolive. WithfromHead, its firsthellostarts at the head, and it resumes fromwelcome'shead_seqafter. - On a drop it reconnects after a random wait up to a ceiling that grows from 100 ms to 5 seconds, and resumes. On
welcomewithresetit discards its state and rebuilds from the replay. After 4400, 4401 or 4403 it stops. Its URL is a function, read at every connect, soreconnect()after a refreshed token, or after 4401, connects with the new one. - A command carries a
command_id, and a command still unanswered when the connection drops is sent again with the same id. It settles asdone,refusedor, if the store is closed first,closed. A create the client chose an id for, sent again and refused asinvalid_state, isdone, since the first try made it; forpost_message,donemeans the message was posted, not that its turn ran. - An
errorframe's message is in the snapshot. - A browser's WebSocket can't send headers, so
streamUrlputs the session in the query, asaccess_tokenandtenant. - Components read it through
subscribeandsnapshot, whichuseSyncExternalStoretakes.
- It says
- artifactr's reducer has a handler for every event its schema names, typed as a mapped type over them, so the compiler fails when the schema gains one. An unknown event changes nothing.
- Its state holds threads with their messages, artifacts' kinds and latest versions, proposals' statuses, and runs, with their tools and the text they are streaming. An artifact's content is read over REST.
- reflexr's reducer handles reflexr's own run and rule events by name, and reads their fields as JSON, without redeclaring their types, until reflexr's schema names them (#83). Its state holds stored rules, with their firings, and runs, with their statuses.
- The tests:
- Unit tests run against a scripted stream server on
Bun.serve: a drop mid-stream, a frame sent twice, a tail, a start at the head, a reset, a reset whose replay is cut short, a command resent after a drop, a create refused on its retry, closing with commands unanswered, an error frame, a refusal that stops the store, a reconnect with a refreshed token, and a reconnect during the backoff. client:apistarts docplan and oncall from their libraries' compose files, and runs Vitest'sapiproject against them in CI's Acceptance job. A client there closes, events are committed over REST while it is away, and it opens again: it applies everyseqfrom 1 to the head once.
- Unit tests run against a scripted stream server on
Options considered¶
| Option | For | Against |
|---|---|---|
Types generated by two tools, and a store per stream over one generic Stream (chosen) |
Every type is the server's own. One resume, one command path, for both protocols | Two generators, and a TypeScript 6 alias beside one of them |
| Hand-written types for the WebSocket frames | No generator for JSON Schema | Copies of the server's types, which drift from them unless something checks; lattice RFC-0002 rules them out |
A codegen package of its own, holding openapi-typescript and TypeScript 6 |
client's tsc would be 7 without a path |
A fifth web package, for one script |
| Typing reflexr's own events by hand | A typed reflexr reducer | Copies reflexr's models; the fix belongs in reflexr's schema |
Consequences¶
- Easier: a change to any package's document fails
client's drift test, and a new artifactr event fails its typecheck until the reducer andstale.artifactrhandle or ignore it. - Easier: app and admin read state through one store per stream and one set of query keys.
- Harder: reflexr's reducer and
stale.reflexrread its events by name, and nothing checks them against reflexr's events until its schema names them (#83). - To revisit: the TypeScript 6 alias goes when
openapi-typescriptsupports TypeScript 7's API.