Building with Shelving
A guide to structuring a full-stack TypeScript project on Shelving. It describes a proven layout for a project with a React frontend, a Bun HTTP API, and a shared contract package between them — where Shelving provides the schema system, endpoint contracts, request handling, data providers, and UI component library.
Follow the file and folder names in this guide exactly. The layout works because every kind of file has exactly one place it can live, and because the client and server communicate only through typed contracts defined in a shared package.
Principles
- Three top-level packages. All source code lives in
shared/,api/, orapp/.shared/is the contract layer;api/is the server;app/is the client. - The contract lives in
shared/. Database schemas, endpoint definitions, domain logic, and utilities used by both sides are defined once inshared/and imported by bothapi/andapp/. Neither side ever redeclares a type or a validation rule the other side also needs. - Schema-first. Data shapes are defined as Shelving schemas (runtime validators), and TypeScript types are derived from them with
ValidatorType. You never write a type by hand and a validator separately. - Hard boundaries, mechanically enforced.
app/must never import fromapi/, andapi/must never import fromapp/. This is enforced by the linter, not by convention (see Boundaries). - Dependency injection via a context object, owned by the composition root. Handlers and services never construct their own providers (database, external APIs). They receive a single context object, so the same code runs against real providers in production, debug/in-memory providers locally, and mock providers in tests. The full context type (
APIContext) is defined inapi/— never inshared/. Shared code stays generic over a context typeC, and declares only small named context slices — reusable ones likeDatabaseContextinshared/util/context.ts, adapter-specific ones alongside each adapter (see Dependency injection). - Group by domain, not by file type. Feature code sits in a folder named after the domain entity it handles, with its components, styles, helpers, and tests together.
- Every code PR gets a disposable preview environment. CI deploys a full app + API stack per pull request at deterministic URLs, and tears it down when the PR closes. Reviewing against a real deployment — not just reading the diff — is a core part of the workflow (see CI).
- Document the repo for agents. An
AGENTS.mdat the repo root is the operating manual LLM coding agents read before working; keeping it current is part of the definition of done (see AGENTS.md).
Top-level layout
project/
├── shared/ # Contract layer: schemas, endpoints, domain logic, shared utils
│ ├── collection/ # Database collection schemas + derived types
│ ├── endpoint/ # Typed endpoint contracts (URL + request/response schemas)
│ ├── config/ # Shared configuration data (e.g. form definitions) built from schemas
│ ├── integration/ # Clients/adapters for external third-party APIs
│ ├── util/ # Generic shared utilities (each file = one topic)
│ ├── img/ # Shared static images
│ └── font/ # Shared font files + their @font-face CSS
├── api/ # Server package
│ ├── start.ts # Local dev server entry (Bun.serve)
│ ├── worker.ts # Production serverless/edge entry
│ ├── index.ts # Request dispatch + top-level error handling
│ ├── handlers/ # Endpoint handlers (thin: attach services to endpoints)
│ ├── services/ # Domain behaviour (the actual work)
│ ├── config/ # Server-side configuration (processors, mappings)
│ └── util/ # Server-only utilities: DI context, secrets env, middleware, test helpers
├── app/ # Client package
│ ├── index.html # HTML shell (production build input)
│ ├── start.html # HTML shell (local dev, without third-party scripts)
│ ├── index.tsx # React root mount
│ ├── App.tsx # Top-level app: route table + global providers
│ ├── start.ts # Local dev server entry (serves SPA + static assets)
│ ├── style.css # Global stylesheet entry
│ ├── util/ # Client-only utilities (API client, app context)
│ └── <feature>/ # One folder per functional area (see below)
├── e2e/ # Playwright end-to-end tests (bun:test based)
├── scripts/ # Build/CI helper scripts
├── docs/ # Project documentation
├── .github/
│ └── workflows/ # CI: test gate + per-PR preview deploy, production deploy, smoke, preview teardown
├── AGENTS.md # Operating manual for LLM coding agents (see the AGENTS.md section)
├── CLAUDE.md # One line: points agents at AGENTS.md
├── global.d.ts # Ambient module declarations (CSS modules, image imports)
├── tsconfig.json # Includes the path aliases for shared/*
├── biome.json # Lint/format config, including the boundary rules
├── package.json # Scripts follow the test:* / build:* / start:* pattern
└── test.env # Committed fake-but-valid env for deterministic test runsThe shared/ package
shared/ is imported by both api/ and app/, so everything in it must be safe to ship to the browser. No secrets, no server-only dependencies.
shared/collection/ — database schemas
One file per collection, plus a barrel index.ts. Each file defines:
- The collection schema with
COLLECTION(...)fromshelving. - Derived variants with
.pick(...)etc. (e.g. the "create" payload). - Response-shape schemas with
DATA(...), built from the collection's own result schemas (ORDER.item,ORDER.items,ORDER.queryItems) so the client can hydrate its local database from them. - A function for each query that the server answers in full (e.g.
getMyOrdersQuery()), so the server and the client build the exact same query. - The derived TypeScript types via
ValidatorType.
// shared/collection/order.ts
import { COLLECTION, DATA, DICTIONARY, OPTIONAL, type Query, STRING, StringSchema, TIMESTAMP, UNKNOWN, type ValidatorType } from "shelving";
/** Represent an order in the database. */
export const ORDER = COLLECTION("order", STRING, {
user: STRING,
form: new StringSchema({ title: "Form", min: 1 }),
date: TIMESTAMP,
data: DICTIONARY(UNKNOWN),
});
export type Order = ValidatorType<typeof ORDER>;
export type OrderItem = ValidatorType<typeof ORDER.item>;
/** Represent an order that's being created. */
export const CREATE_ORDER = ORDER.pick("form", "data");
export type CreateOrder = ValidatorType<typeof CREATE_ORDER>;
/** Response type for a submitted order. */
export const ORDER_RESULT = DATA({ order: ORDER.item, redirect: OPTIONAL(STRING) });
export type OrderResult = ValidatorType<typeof ORDER_RESULT>;
/** Response type for my orders: the query it answers, and every order that matches it. */
export const MY_ORDERS_RESULT = DATA({ orders: ORDER.queryItems });
/** The query that `MY_ORDERS_RESULT` answers. The server and the client both call this. */
export function getMyOrdersQuery(user: string): Query<OrderItem> {
return { user, $order: "!date", $limit: 20 };
}Rules:
- Schema constants are
UPPER_SNAKE_CASE; derived types arePascalCasewith the same name. - The type is always derived from the schema — never written independently.
shared/collection/index.tsis a barrel:export * from "./order.js";, one line per module, alphabetically sorted.- Results that return rows use the collection's own schemas, so
HydratingAPIProvidercan find the collection:ORDER.itemfor one row.NULLABLE(ORDER.item)deletes the row onnull, using theidfrom the payload.ORDER.itemsfor a list of rows. NeverARRAY(ORDER.item): it validates the same value, but it does not hydrate.ORDER.queryItemsfor{ query, items }, whenitemsis the complete answer toquery. The client then knows the query has loaded, even with no rows.DATA({ orders: ORDER.items, people: PERSON.items })to return several collections in one response (one level deep).
- Build each
queryItemsquery in one shared function. Query times match byJSON.stringify(query), so the server and the client must write the keys in the same order. - The
queryin aqueryItemsvalue is validated in full byORDER.query: unknown keys and bad values throw.
shared/endpoint/ — endpoint contracts
One file per domain area, plus a barrel index.ts. An endpoint is a value created with GET(...)/POST(...) from shelving, binding a URL pattern to a request schema and a response schema:
// shared/endpoint/order.ts
import { CREATE_ORDER, ORDER_RESULT } from "shared/collection";
import { POST } from "shelving";
export const ORDER_ENDPOINT = POST("/order", CREATE_ORDER, ORDER_RESULT);This single constant is the entire client/server contract:
- The server imports it and attaches a handler:
ORDER_ENDPOINT.handler(...). - The client imports it and calls it through the API provider — with the request and response fully typed and validated on both ends.
Rules:
- Endpoint constants are named
<NOUN>_ENDPOINT. - Endpoint files contain only endpoint definitions — no logic.
- Request/response schemas come from
shared/collection(orDATA(...)shapes defined alongside them); endpoints never define ad-hoc inline shapes.
shared/config/ — shared configuration data
Configuration that both sides need — e.g. multi-step form definitions built from schema classes. Each config area is a folder with an index.ts barrel that also asserts completeness:
// shared/config/<area>/index.ts
export const FORMS = {
alpha,
beta,
} satisfies ImmutableDictionary<FormSchema>;The satisfies clause is the pattern to copy: barrels that aggregate config use it to type-check that every entry conforms (and, on the API side, that every shared config entry has a matching server-side counterpart — see api/config/ below).
shared/<domain>/ — shared domain logic
A domain area that needs classes/logic on both sides (e.g. a schema subclass for multi-step forms plus a processor that validates and forwards submissions) gets its own folder: one class per file, named after the class (FooSchema.ts exports FooSchema), plus a barrel index.ts.
Any shared class that receives providers at call time (a processor, a forwarder-style "port" class) is generic over a context type C — it never names the application's full context. See Dependency injection.
shared/integration/ — external API adapters
One folder per third-party service: shared/integration/<service>/ containing the provider class (<Service>Provider.ts), a types.ts for the wire types, optional util.ts, tests, and a barrel index.ts. Integrations that hold secrets take them as constructor arguments — the secret values themselves are read only in api/ (see env split below).
Each integration also exports its context slice: a small interface declaring only the piece of the DI context this integration needs at call time. A forwarder class that sends submissions to the service extends the shared base class parameterised by that slice, never by the application's full context:
// shared/integration/crm/index.ts (excerpt)
import { BaseForwarder } from "shared/order";
import type { APIProvider, Data } from "shelving";
/** Slice of the DI context that the CRM integration requires. */
export interface CRMContext {
crm: APIProvider<Data, string>;
}
export class CRMForwarder<I extends Data, O> extends BaseForwarder<I, O, CRMContext> {
// ...reads `context.crm` and nothing else.
}The composition root's APIContext satisfies CRMContext structurally, so the forwarder plugs in with zero casts — see Dependency injection.
shared/util/ — shared utilities
Flat folder, one file per topic (phone.ts, cookie.ts, env.ts, …), each with a co-located <topic>.test.ts. Two files deserve special mention:
shared/util/env.ts— parses public environment variables only. It starts with a comment warning that these values are inlined into client bundles. Required values throw at module load:if (!process.env.API_URL) throw new ReferenceError(...). Export parsed values (e.g.URLobjects viarequireURL), not raw strings.shared/util/context.ts— declares small, descriptive, reusable context slices —DatabaseContext { db: DBProvider }and the like — that shared service callbacks reuse when they need context. What it must never declare is the full server context: the "large bundle" shape is owned byapi/util/context.ts, whereAPIContextcomposes these slices into its final shape. Nothing inapp/ever imports the full server context, and defining it inshared/forces generic shared code to depend on the whole application environment. See Dependency injection.
Dependency injection: context slices and the composition root
Everything server-side receives its dependencies through a single context object, but who names that object's type matters as much as the injection itself. The rules:
The composition root owns the context type
The server's full DI context is defined in api/util/context.ts, named APIContext, with APIHandlers = EndpointHandlers<APIContext> alongside. It does not live in shared/. This mirrors app/util/context.ts — each side owns its own context file. Nothing in app/ ever imports the server context, and defining it in shared/ forces generic shared code to depend on the whole application environment.
What shared/ does declare is slices: small, descriptive interfaces that each name one capability. The full context only ever exists at the composition root, as a composition of slices.
Shared code never names the full context
Any shared class that receives providers at call time — a forwarder-style "port" class, a processor — is generic over a context type C. This is exactly the idiom Shelving itself uses for EndpointHandlers<C> and Endpoint.handler<C>(). The rule: if a shared module hard-codes the application's context type, it is mis-layered — make it generic over C.
Code declares the context slice it needs
Slices come from two places:
Cross-cutting, reusable slices live in
shared/util/context.ts—DatabaseContext { db: DBProvider }and similar. Shared service callbacks that need a capability name the slice, not the application context:`ts
// shared/util/context.ts (excerpt)
import type { DBProvider } from "shelving";/** Slice of the DI context that provides database access. */
export interface DatabaseContext {
db: DBProvider;
}`Adapter-specific slices live with their adapter — e.g.
shared/integration/crmexportsCRMContext { crm: APIProvider<Data, string> }and its forwarder extendsBaseForwarder<I, O, CRMContext>.
The composition root then composes the full context by extension:
// api/util/context.ts
import type { OrderProcessors, OrderRedirectContext } from "shared/order";
import type { CRMContext } from "shared/integration/crm";
import type { DatabaseContext } from "shared/util/context";
import type { EndpointHandlers } from "shelving";
/** Dependency-injection context assembled at each API entry point. */
export interface APIContext extends DatabaseContext, CRMContext, OrderRedirectContext {
processors: OrderProcessors<APIContext>;
}
export type APIHandlers = EndpointHandlers<APIContext>;Structural typing plus contravariance does the assembly with zero casts: a Forwarder<I, CRMContext> is assignable wherever a Forwarder<I, APIContext> is expected, because APIContext satisfies the slice.
Note the self-referential processors: OrderProcessors<APIContext> — a context containing consumers of itself is a genuinely recursive relationship, and stating it once at the root keeps it out of the shared files, where the same relationship degenerates into an import cycle.
Method-specific requirements accumulate as intersections
When one method needs more context than the class's C — e.g. redirect resolution needs { websiteURL: URL } — declare a slice for it (OrderRedirectContext, exported next to the code that needs it) and type that method's parameter as C & OrderRedirectContext. Do not constrain the whole class: C is inferred from constructor arguments (the list of forwarders), and the inferred slice won't satisfy an unrelated constraint, so a class-level constraint breaks inference.
Default generic contexts to C = unknown, never C = void
When a class is constructed with an empty list (a processor with no forwarders), there is no inference site, so C falls back to the default — and void fails assignability against any real context, while unknown correctly means "works with any context".
Keep mapper/transform callbacks pure
Mapping callback types stay input → output. Don't thread the context through mapping callbacks speculatively — in practice no mapper uses it, and pure mappers are a stronger contract. Add the context parameter only when a real need appears.
Make required-ness honest
A context field that every real composition root sets (like websiteURL) is required, not optional. If only tests omit a field, the field isn't optional — the tests are incomplete; have them supply mocks instead.
The api/ package
Entry points — three composition roots
The API has no global state. All providers are assembled into an APIContext (defined in api/util/context.ts — see Dependency injection) at an entry point and passed down. There are exactly three places that construct a context:
api/start.ts— local dev server. RunsBun.serveon the port derived from theAPI_URLenv var. Builds a context from debug/in-memory providers (MemoryDBProviderwrapped inDebugDBProvider, mock external APIs wrapped inDebugAPIProvider), wraps the handler in logging + CORS middleware.api/worker.ts— production serverless/edge entry. Builds a context from real providers (persistent KV/database provider, real external API clients wrapped inLoggingAPIProvider) using secrets fromapi/util/env.ts.api/util/test.ts— test helper exportingcreateMockAPIContext(), which builds a context entirely fromMock*providers so tests exercise handlers in-process with no network I/O.
Because the context interface is the same in all three, handlers and services are identical across dev, production, and test.
api/index.ts — dispatch
One exported function, handleAPI(request, context), which delegates to Shelving's handleEndpoints(API_URL, HANDLERS, request, context, handleAPI) and converts anything thrown into an error Response with getErrorResponse(thrown, DEBUG) (the DEBUG flag prevents leaking error details in production). All top-level error handling lives here and nowhere else.
api/handlers/ — thin handler lists
One file per domain area plus an index.ts that assembles the master list. A handler file imports the endpoint from shared/endpoint, attaches .handler(...), and immediately delegates to a service:
// api/handlers/order.ts
import { ORDER_ENDPOINT } from "shared/endpoint";
import { createOrder } from "../services/order.js";
import type { APIHandlers } from "../util/context.js";
export const ORDER_HANDLERS: APIHandlers = [
ORDER_ENDPOINT.handler((create, _request, context) => {
return createOrder(create, context);
}),
];api/handlers/index.ts exports HANDLERS: APIHandlers — a flat array spreading each area's handlers, plus tiny inline handlers for infrastructure routes (GET("/") health check, robots.txt, favicon.ico).
Rules:
- Handlers contain no domain logic — they unpack the request and call a service.
- Handler arrays are named
<AREA>_HANDLERS. - Handlers return plain data; Shelving validates it against the endpoint's response schema.
api/services/ — domain behaviour
One file per domain entity; functions named as verbs (createOrder, not orderCreate). A service receives validated payload + APIContext, performs the work (validate deeper, write via context.db, forward via context providers), and returns the response-shape data. Co-located *.test.ts files test services through createMockAPIContext().
api/config/ — server-side configuration
Mirrors shared/config/ folder-for-folder. Where shared/config/<area>/ defines what the data looks like (schemas — needed by the client to render forms), api/config/<area>/ defines what the server does with it (processors, forwarding/mapping to external systems — server-only because it encodes business rules and touches secret-bearing providers). The api/config/<area>/index.ts barrel uses satisfies { [K in keyof typeof SHARED_CONFIG]: ... } to force a compile error if a shared config entry is missing its server counterpart.
api/util/ — server-only utilities
api/util/context.ts— definesAPIContext(the DI context interface, composed by extending the reusable slices fromshared/util/context.tsand each adapter's own slice) andAPIHandlers = EndpointHandlers<APIContext>. See Dependency injection.api/util/env.ts— parses secret environment variables (API keys etc.). Starts with a comment: "these values are server-side secrets — only import this file in API code, never in APP code."api/util/middleware.ts— request middleware as higher-order functions namedwith*:withCORS(handler),withLogging(handler). Each takes aRequestHandlerand returns a new one.api/util/test.ts— the mock-context factory described above, plus shared test fixtures.
The app/ package
Entry points
app/index.html— production HTML shell. References./style.cssand./index.tsx; the bundler resolves and bundles from here.app/start.html— identical shell for local dev, minus any third-party scripts you don't want running locally.app/index.tsx— mounts<App />into#root. Nothing else.app/App.tsx— the composition root of the client: theROUTEStable, the app-level context value, and the provider tree (Shelving'sApp,Navigation,PageCatcher,Router,Dialogs,Notices). Every page component is registered here.app/start.ts— local dev server:Bun.serveon the port derived fromAPP_URL, serving the HTML shell for all SPA routes and static files (e.g./img/*mapped toshared/img/).
Routing
Routes are declared as a plain object mapping path patterns to page components, using Shelving's Router:
const ROUTES: Routes = {
"/": HomePage,
"/order/{form}": OrderPage,
"/debug/{path}": DEBUG && DebugPage, // conditionally registered routes use && with an env flag
"/**": () => <ErrorPage reason="Page not found" />,
};Feature folders
Everything below the entry points is grouped by functional area, one folder per domain entity: app/<feature>/. A feature folder holds, together:
- The page component:
<Feature>Page.tsx(+<Feature>Page.module.cssif it needs page-specific layout). - Feature-local components, each
PascalCase.tsxwith a matching.module.csswhen styled. - Feature-local hooks and helpers.
- Co-located
*.test.tsfiles.
Never create app/components/, app/hooks/, or other type-based folders. If a component is generic enough to leave its feature folder, it belongs in the Shelving UI library (shelving/ui), not in a app-level shared folder.
app/util/
Client-only cross-feature utilities. Two standard files:
app/util/api.ts— the local database and the single API client instance. All features call endpoints throughAPI, and read rows fromDB:ts export const DB = new MemoryDBProvider(); export const API = new HydratingAPIProvider(new ClientAPIProvider({ url: API_URL }), DB); export const { DBContext, useItem, useQuery } = createDBContext(DB); export const { APIContext, useAPI } = createAPIContext(API);app/util/context.ts— the app-level React context: anAppContextDatainterface, thecreateContextvalue, and arequireAppContext()accessor that throws if used outside the provider.
Data flow
The client has one local database (DB, a plain MemoryDBProvider). API results fill it, and components read from it.
Fetch with
useAPI(), read withuseQuery()/useItem(). A component calls the endpoint withuseAPI(endpoint, payload)and ignores the result.HydratingAPIProviderwrites the rows intoDBbefore the call resolves, and theuseQuery()/useItem()stores update fromDB.Loading. A store over
DBis loading untilDBhas the rows, or has a time for the query or item. AqueryItemsresult sets the query time, so an empty list shows as empty and not as loading. ANULLABLE(X.item)result ofnullmarks the item as missing.Exact queries.
useQuery()must use the same query (from the shared function) as the server sends inqueryItems, or the store waits for a query time that never comes.Paging is an API feature.
useQuery()shows every row inDBthat matches, with no$limit. To load more, call the endpoint withgetQueryItemsAfter(page)and keep the newest page in state:tsx const [page, setPage] = useState(first); const next = getQueryItemsAfter(page.orders); // `undefined` when there are no more. {next && <Button onClick={async () => setPage(await API.call(MY_ORDERS_ENDPOINT, next))}>Show more</Button>}Writes are commands. Send writes through endpoints.
DBis a read model: a write to it changes only this client. An optimistic edit inDBis fine, and the next response that returns the row replaces it.Deleted rows. A list result never deletes rows. A row the server deleted stays in
DBuntil the client deletes it locally (e.g. an optimistic delete) or the page reloads.Partial rows. Do not hydrate part of a row into a collection. Use a separate collection for a projection (e.g.
PEOPLEfor the public part ofPERSON) and accept that the two can drift.
Component conventions
- Components are named function declarations returning
ReactElement— not arrow functions, notJSX.Element. - Page components use the
*Pagesuffix and are registered inApp.tsx. Layout components use the*Layoutsuffix. - No inline
styleprops, ever. All styling lives in.module.cssfiles named after their component (OrderPage.module.cssforOrderPage.tsx). - Reusable components expose styling options as variants: boolean props mapped to class names in the CSS module. Pages compose existing
shelving/uiprimitives (Button,Notice,Page,Row, layouts, transitions) rather than styling raw elements.
Boundaries
Three mechanisms keep the packages honest:
- Linter-enforced import bans (Biome
noRestrictedImportsoverrides):- Files under
app/**may not importapi/**— "Client bundles must not import server-only modules".- Files under
api/**may not importapp/**— "API code must not import client modules".shared/**imports neither, by construction (nothing in it references the other packages).
- Files under
- Files under
- The env split. Public env vars are parsed only in
shared/util/env.ts; secret env vars only inapi/util/env.ts. A secret can never leak into a client bundle because the only file that reads it is un-importable fromapp/. - Path aliases. Cross-package imports of
shared/go through bare aliases defined intsconfig.jsonpaths, which also enforce that folders with barrels are imported as the barrel:
"paths": {
"shared/collection": ["./shared/collection/index.ts"],
"shared/config/*": ["./shared/config/*/index.ts"],
"shared/endpoint": ["./shared/endpoint/index.ts"],
"shared/<domain>": ["./shared/<domain>/index.ts"], // one alias per shared domain folder, e.g. "shared/order"
"shared/integration/*": ["./shared/integration/*/index.ts"],
"shared/img/*": ["./shared/img/*"],
"shared/font/*": ["./shared/font/*.css"],
"shared/util/*": ["./shared/util/*.ts"]
}Import rules that follow:
- Cross-package imports use the alias, no
.jssuffix:import { ORDER_ENDPOINT } from "shared/endpoint";. - Relative imports within a package use explicit
.jsextensions:import { createOrder } from "../services/order.js";. - Type-only imports use
import type(enforced byverbatimModuleSyntax). - Core types and utilities come from
shelving(andshelving/uifor components) — don't reimplement what the library provides.
A global.d.ts at the root declares the ambient module types for *.module.css, *.css, and image imports so the aliases type-check.
Testing layout
Three tiers, all on bun:test:
- Unit tests — co-located with source as
<name>.test.ts, in all three packages. The unit test script runsbun testacrossapp api shared. - In-process API tests — service/handler tests build a context with
createMockAPIContext()fromapi/util/test.tsand call services directly. No server, no network. - E2E tests — in the top-level
e2e/folder:*.test.tsfiles driving Playwright, plus support modules (device.tsfor browser/device setup,steps.tsfor reusable step runners,env.ts,debug.ts). Tests safe to run against a live deployment include@smokein their name, so a smoke run is justbun test e2e -t @smoke.
Environment handling for tests:
test.envis a committed, fake-but-valid env file. Test scripts load it explicitly with--no-env-file --env-file=test.envso runs are deterministic regardless of local.env.- Do not name it
.env.test— Bun auto-loads that duringbun test, which makes smoke/manual runs target the wrong URLs. scripts/startWhile.tsboots the app + API on per-run derived ports, waits until they're reachable, runs the wrapped command (the e2e suite), then tears them down. This gives the e2e script a self-contained one-command run.
Scripts and commands
package.json scripts follow a strict prefix pattern so groups can be run with --parallel/--sequential wildcards:
"scripts": {
"test": "bun run --parallel test:*", // the full local gate
"test:lint": "biome check .",
"test:type": "tsgo --noEmit",
"test:unit": "bun --no-env-file --env-file=test.env test --concurrent app api shared",
"test:e2e": "bun --no-env-file --env-file=test.env run scripts/startWhile.ts bun test e2e",
"fix": "bunx biome check --write",
"build": "bun run --sequential build:api build:app",
"build:app": "bun build app/index.html --target=browser --env=inline --minify --outdir=.build/app",
"build:api": "bun build api/worker.ts --env=inline --outfile=.build/api/worker.js",
"start": "bun run --parallel start:*",
"start:app": "bun run app/start.ts",
"start:api": "bun --watch run api/start.ts"
}Conventions:
test:*scripts are the CI gate; plaintestruns them all.- Builds output to
.build/(git-ignored, never hand-edited).postbuild:*scripts can verify the bundle (e.g. grep the output to confirm no secret env values were inlined into the client bundle — a cheap, high-value safety check). - Servers derive their ports from
APP_URL/API_URLenv vars rather than hardcoding or taking--portflags, so every environment (dev, test, CI, prod) is configured the same way.
Required public env vars follow the same shape: DEBUG, APP_URL, API_URL (plus any project-specific public URLs), validated eagerly in shared/util/env.ts so a misconfigured environment crashes at boot, not mid-request.
CI: the test gate, preview environments, and deployment
This is a key part of the workflow, not an afterthought. Every code pull request gets its own fully deployed, disposable preview environment — real app, real API, at URLs derived from the PR number — and production deploys automatically on merge to the default branch. Review happens against a live deployment, not just a diff.
The setup is four workflows in .github/workflows/. The hosting below is described using Cloudflare (Pages for the static app bundle, Workers for the API) as the example technology — any host that supports named/branch deployments for static sites and named serverless deployments for the API works the same way.
| Workflow | Trigger | What it does |
|---|---|---|
deploy-preview.yml | PR opened / synchronize / reopened | Full test gate, then deploy a per-PR preview environment and post a sticky PR comment with the URLs |
deploy-prod.yml | Push to main | Full test gate, then deploy to production |
smoke.yml | deployment_status success (+ manual dispatch) | Run @smoke-tagged e2e tests against the freshly deployed URL |
teardown-preview.yml | PR closed | Delete the per-PR deployments and update the sticky comment |
The preview workflow (deploy-preview.yml)
Structure — two jobs, deploy gated on test:
name: Deploy Preview
on:
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- '**/*.md'
- 'docs/**'
permissions:
contents: read
deployments: write
pull-requests: write
concurrency:
group: preview-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
# Cache the package-manager cache AND the e2e browser binaries, both keyed on the lockfile.
- uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- run: bun install --frozen-lockfile
- if: steps.playwright-cache.outputs.cache-hit != 'true'
run: bunx playwright install chromium
- run: bun run test # the FULL gate: lint + type + unit + e2e
deploy:
needs: test
runs-on: ubuntu-latest
env:
SLUG: pr-${{ github.event.pull_request.number }}
WORKER_NAME: pr-${{ github.event.pull_request.number }}-api
APP_URL: https://pr-${{ github.event.pull_request.number }}.${{ vars.PAGES_SUBDOMAIN }}.pages.dev
API_URL: https://pr-${{ github.event.pull_request.number }}-api.${{ vars.WORKERS_SUBDOMAIN }}.workers.dev
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
environment:
name: preview
url: https://pr-${{ github.event.pull_request.number }}.${{ vars.PAGES_SUBDOMAIN }}.pages.dev
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
# ... same bun cache + install steps ...
# Env values are BAKED INTO the bundles at build time (--env=inline),
# so write .env from a single multi-line repo secret before building.
- name: Write .env
run: |
cat > .env <<'EOF'
${{ secrets.ENV_PREVIEW }}
EOF
[ -s .env ] || (echo "ENV_PREVIEW is empty" && exit 1)
- run: bun run build:app
- run: bun run build:api
# Static bundle → branch-named deployment; API bundle → per-PR named worker.
- run: bunx [email protected] pages deploy .build/app --project-name ${{ vars.PAGES_SUBDOMAIN }} --branch ${{ env.SLUG }}
- run: bunx [email protected] deploy .build/api/worker.js --name ${{ env.WORKER_NAME }}
- name: Post PR comment
uses: actions/github-script@v8
with:
script: |
# Sticky-comment pattern: first line is a fixed marker; find a comment
# starting with the marker and update it, otherwise create it.
# Body: a small table with App URL, API URL, worker name, short commit SHA.The load-bearing decisions, each worth copying:
- Deterministic URLs derived from the PR number. The slug
pr-<n>names everything: the Pages branch alias (pr-<n>.<project>.pages.dev), the Worker (pr-<n>-api), the concurrency group. Anyone — human or LLM — can compute the preview URLs from the PR number alone, without reading the PR comment or querying the host. paths-ignorefor docs. Both deploy workflows ignore'**/*.md'and'docs/**'. Deploying because a README changed is pure waste. The trade-off (see gotchas below) is that docs-only PRs get no preview, which agents must know about.- Deploy gated on the full test gate. The
deployjob hasneeds: test, andtestruns the entire local gate (bun run test= lint + type + unit + e2e), not a subset. Partial gates let e2e regressions reach deployed previews. - Per-PR concurrency with
cancel-in-progress. Grouppreview-pr-<n>means a new push cancels the previous in-flight run — only the latest commit's preview survives, and runs for different PRs never queue behind each other. - One multi-line env secret per environment. The whole
.envfile lives in a single repo secret (ENV_PREVIEW,ENV_PROD) that the workflow writes to disk before building. One place to rotate, and the[ -s .env ]guard fails fast if the secret is missing/empty. Because builds use--env=inline, env values are frozen into the bundle — changing an env value means rebuilding and redeploying, not editing host config. - The sticky PR comment. The comment starts with a fixed marker line (
### Preview Environment); each deploy finds the existing comment by that marker and updates it rather than posting a new one. The PR thread stays clean and always shows the current state. - GitHub
environment:on the deploy job. Declaringenvironment: {name, url}creates a GitHub Deployment, which (a) shows the preview link directly on the PR, and (b) emits thedeployment_statusevent that triggers the smoke workflow. - Pinned deploy CLI.
bunx wrangler@<exact-version>— an unpinned deploy tool is a supply-chain and reproducibility risk in CI. - Cache the package cache and browser binaries keyed on the lockfile. Two
actions/cacheentries (package-manager cache, Playwright browsers), both keyed onhashFiles('bun.lock'), keep the test job fast.
The production workflow (deploy-prod.yml)
Identical shape, different trigger and targets:
- Trigger:
pushtomain, with the samepaths-ignorefor**/*.mdanddocs/**. - Same two-job
test→deploystructure, same.env-from-secret pattern (ENV_PROD). - Deploys the static bundle to the production branch alias (
--branch main) and the API to the fixed production worker name (api). - Concurrency group
deploy-prodwithcancel-in-progress: true— rapid merges collapse into one deploy of the latest commit. environment: {name: production, url: <prod app URL>}so production deploys also trigger smoke.
The smoke workflow (smoke.yml)
Runs the @smoke subset of the e2e suite against whatever was just deployed:
on:
deployment_status: # fired by any deploy job that declares `environment:`
workflow_dispatch: # manual runs against the configured APP_URL
jobs:
smoke:
if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
steps:
# ... checkout, bun + playwright setup with the same caches ...
- name: Use deployment URL as APP_URL
if: github.event.deployment_status.environment_url
run: echo "APP_URL=${{ github.event.deployment_status.environment_url }}" >> "$GITHUB_ENV"
- run: bun run smoke # bun test e2e -t @smoke, against $APP_URLNotes:
deployment_statusfires for both preview and production deployments — one workflow smoke-tests everything, because the target URL comes from the event (environment_url), not from config.- Only e2e tests with
@smokein the test name run. Keep@smokeon exactly the cases that are safe and meaningful against a live deployment (idempotent flows, test-mode forms) — never destructive ones. - If production sits behind bot protection / a WAF, give the smoke runner a shared-secret bypass header (sent on every Playwright request, matched by a WAF "skip" rule) via a secret env var; leave it unset for local/preview runs that aren't behind the WAF.
The teardown workflow (teardown-preview.yml)
Previews are disposable — tear them down when the PR closes (merged or not):
- Trigger:
pull_request: types: [closed], same per-PR concurrency group. - Delete the per-PR worker by name (
wrangler delete pr-<n>-api --force || true—|| truebecause a docs-only PR never deployed one). - Delete the static-host deployments for the PR: list the project's deployments as JSON, filter to those matching the PR slug (by branch name, alias, or URL), delete each by id. This matching-by-slug step is the fiddliest part — deterministic slugs (decision 1 above) are what make it possible.
- Mark the PR's GitHub Deployments
inactiveand delete them, so the PR doesn't show stale "Active" environments. - Update the sticky PR comment to say the preview has been torn down.
Gotchas (read these before touching CI)
- **
.mdanddocs/**changes do not trigger previews or deploys.** This is thepaths-ignoreworking as intended, but it has consequences an LLM must anticipate: a docs-only PR gets no preview environment and its deploy-gated checks may show as skipped; a docs-only merge tomaindoes not redeploy production. If you genuinely need a preview for a docs-only PR, include a code change or run the deploy manually. Conversely: don't wait for a preview URL to appear on a docs-only PR — it never will. - A new push cancels the in-flight deploy. If you push twice quickly, the first run is cancelled mid-way by
cancel-in-progress— a cancelled run is not a failure. Judge preview state by the latest run and the commit SHA in the sticky comment. - Env is frozen at build time. With
--env=inlinebuilds, changingENV_PREVIEW/ENV_PRODdoes nothing to already-deployed bundles. Rebuild and redeploy (push a commit, or re-run the deploy workflow). - Preview and production must not share state. The per-PR worker gets its own bindings; keep preview database/KV namespaces separate from production (e.g. a
preview_idbinding alongside the production id in the worker config). - Smoke failures against previews can be opaque (process kills with no output). The diagnostic loop: reduce the failing e2e test to its first steps, add
console.debugcheckpoints around suspect boundaries, re-push to re-trigger the preview + smoke run, and read which checkpoints printed in the Actions logs.
AGENTS.md — make the repo legible to LLMs
Create an AGENTS.md file at the repository root so LLM coding agents can understand the codebase. This is not optional documentation — for a project maintained with agent assistance, AGENTS.md is the agents' operating manual, and an agent that hasn't read it will misplace files, skip the test gate, and violate conventions it had no way to know about. Pair it with a minimal CLAUDE.md that forces the read:
# CLAUDE.md
Read `AGENTS.md` at the start of every session before doing any work.Required sections
Write AGENTS.md with these sections, in this order. Keep every claim current — a stale map is worse than no map, because agents trust it over re-discovering the code.
- Project Snapshot — runtime/tooling, the three packages, where reusable components come from, and a pointer to this guide for structural rationale.
- Fast Start — install command, run command, local URLs, and the required/optional env vars (with what breaks when each is missing).
- Canonical Commands — the exact commands for lint, type-check, unit tests, the full gate, e2e/smoke variants, and build. Agents should never have to guess a script name.
- CI & Deployment — the workflow list from the CI section above, condensed to the facts an agent needs mid-task: what triggers a preview, the deterministic preview URL scheme, that
.md/docs changes don't trigger deploys, that env is baked at build time, and that concurrent pushes cancel in-flight deploys. - Current Known Baseline — dated statement of which gate commands currently pass. When an agent's change breaks this, updating the section is part of the change.
- Repository Map — one line per important file/folder: entry points, handler/service/config folders, the shared contract folders, the env-parsing files (flagging which is public and which is secret).
- File Organization Rules — the placement rules from this guide, restated imperatively ("
api/handlers: request handlers only…"). This is the section that stops files landing in the wrong place. - Component / Coding Conventions — naming, styling rules (no inline styles, CSS-module variants), import rules (aliases vs
.jsrelative paths), TypeScript settings that bite (verbatimModuleSyntax,exactOptionalPropertyTypes), error-handling and logging conventions. - Architecture Notes — the handful of decisions everything hangs off: routing, endpoint contracts + context injection, the enforced import boundaries, how servers derive ports from URLs.
- Agent Workflow Recommendations — what to run before coding and before finishing (the full gate, autofix), and any UI-verification steps.
- Common Pitfalls — every trap that has actually cost time (env auto-loading quirks, port assumptions, cache issues). This section is earned, not written up front.
Maintenance rules
Write these into AGENTS.md itself, so agents keep it alive:
- Treat
AGENTS.mdas ongoing memory: when the user says "add instructions", "remember to do this", or "always do this", the rule goes in this file. - When a convention is discovered that isn't written down, write it down in the matching section.
- When the baseline changes (a gate command starts failing), update Current Known Baseline with the exact failing command and file.
- Refactoring the file for clarity is encouraged; letting it drift from reality is the one failure mode to avoid — an agent reading a stale map will confidently do the wrong thing.
Style essentials
The broader coding style is a project choice, but these rules interact directly with the layout above:
- Naming:
PascalCasefor types/classes/components;camelCasefor functions/variables;UPPER_SNAKE_CASEfor exported constants (schemas, endpoints, handler lists, config dictionaries). Module-private functions and constants get a_prefix and are not exported. - One component per file, file named after its export. Test files co-located as
*.test.ts. - Barrels: every multi-file
shared/folder has anindex.tsof alphabetisedexport * from "./Module.js";lines, and the tsconfig alias points at it. - Verb prefixes carry contracts:
require*throws when missing;get*returnsundefinedinstead;with*returns a wrapped/updated copy (including middleware);handle*is a top-level request entry point;format*/parse*are display/parse pairs. Pick the prefix that matches the behaviour. - Errors: user-facing errors are thrown as plain strings (
throw "Unknown form";) and surfaced by the UI notice system; unexpected errors use Shelving's typed error classes (RequiredError,UnexpectedError,ResponseError). - Logging:
console.logis banned by the linter; useconsole.debug/console.warn/console.error.
Checklist for adding a new domain feature
Adding a feature called order touches these files, in this order:
shared/collection/order.ts— collection schema, create/pick variants, result schema, derived types. Add toshared/collection/index.ts.shared/endpoint/order.ts—export const ORDER_ENDPOINT = POST("/order", CREATE_ORDER, ORDER_RESULT);. Add toshared/endpoint/index.ts.- (If both sides need config/domain classes)
shared/config/order/and/orshared/order/, with barrels. api/services/order.ts—createOrder(create, context)doing the actual work, plusorder.test.tsusing the mock context.api/handlers/order.ts—ORDER_HANDLERSattaching the endpoint to the service. Spread intoapi/handlers/index.ts.- (If the server needs its own config)
api/config/order/, keeping thesatisfiescompleteness check in its barrel. app/order/OrderPage.tsx(+ feature-local components/styles/tests inapp/order/).- Register the route in
app/App.tsx. - Run the full test gate locally, then open a PR — CI runs the same gate and deploys a preview environment at the deterministic per-PR URLs. Verify the feature against the live preview (smoke tests run against it automatically) before merging.
If every file in that list is in the right place, the client and server share one schema, one endpoint contract, and zero duplicated types.
Upgrade notes
From CacheDBProvider to HydratingAPIProvider
CacheDBProvider and the paging in QueryStore are removed. To upgrade an app:
- Replace the client database chain (e.g.
new CacheDBProvider(new RESTDBProvider(api))) with a plainMemoryDBProvider, and wrap the API client inHydratingAPIProviderwith that database (seeapp/util/api.tsabove). - Change result schemas to the collection's own schemas:
ARRAY(X.item)→X.items, and{ query, items }results →X.queryItems. - Add a shared query function for each
queryItemsresult, and use it in both the handler anduseQuery(). - In each component, call
useAPI()for the endpoint that loads the data, and keepuseQuery()/useItem()to read it. - Replace
QueryStore.fetchAfter()/fetchBefore()/hasAfterwith an API call andgetQueryItemsAfter(). To poll for new rows, call the endpoint again on a timer. - Rename
getAfterQuery()/getBeforeQuery()togetQueryAfter()/getQueryBefore(). - A row deleted on the server now stays in the local database until the page reloads, unless the app deletes it locally. Check that optimistic deletes run.