Architecture
The one rule: a single source of truth over HTTP
Section titled “The one rule: a single source of truth over HTTP”Every surface is a thin client of one HTTP API. The SvelteKit app, the Tauri desktop and mobile
shells, the Astro sites’ dynamic bits, CLIs and the Python service all consume the same apps/api:
the same auth, the same procedures, the same OpenAPI document. No Tauri IPC, no per-platform data
layer, no duplicated business logic.
Public (Astro, static) Application (SvelteKit, one codebase) Native shells (Tauri 2, no IPC) ┌─────────────────────┐ ┌──────────────────────────────────┐ ┌────────────────────────────┐ │ apps/site │ │ apps/web · SSR (Node/Cloudflare) │ │ Desktop · Win, macOS, Linux│ │ apps/docs │ │ apps/web · static SPA ───────────┼───▶│ Mobile · Android, iOS │ └──────────┬──────────┘ │ PWA install │ └─────────────┬──────────────┘ │ optional └────────────────┬─────────────────┘ │ HTTPS + bearer ▼ ▼ HTTPS (cookie) ▼ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │ apps/api: Hono on Bun │ │ Better Auth (/api/auth/*) · oRPC: RPC (/rpc/*) + REST/OpenAPI (/api/v1/*) · Stripe webhooks │ └──────────────────────┬──────────────────────────────────────────────┬──────────────────────────┘ │ Drizzle (Bun.SQL) │ X-Service-Token ▼ ▼ ┌──────────────────┐ ┌────────────────────────────┐ │ Postgres 17 │ │ services/ai: FastAPI │ │ + pgvector │ │ AI · data · documents │ └────────────────────────────┘The same diagram as Mermaid, for pasting into tools that render it:
flowchart LR subgraph Public["Public (Astro, static)"] SITE[apps/site<br/>marketing · blog · SEO] DOCS[apps/docs<br/>Starlight] end subgraph App["Application (SvelteKit, one codebase)"] WEB[apps/web · SSR<br/>Node or Cloudflare] SPA[apps/web · static SPA] PWA[PWA install] end subgraph Shells["Native shells (Tauri 2, no IPC)"] DESK[Desktop<br/>Win · macOS · Linux] MOB[Mobile<br/>Android · iOS] end SPA --> DESK SPA --> MOB WEB --> PWA API["apps/api: Hono on Bun<br/>Better Auth · oRPC (RPC + REST/OpenAPI) · webhooks"] DB[(Postgres + pgvector<br/>Drizzle)] AI["services/ai: FastAPI<br/>AI · data · documents"] WEB -- HTTPS --> API SPA -- HTTPS + bearer --> API SITE -. optional .-> API API --> DB API -- X-Service-Token --> AIConsequences you feel every day
Section titled “Consequences you feel every day”- Add a feature once. Declare it in
packages/api-contract, implement it inapps/api, call it from any client with full types (api.workspaces.create({...})). REST + OpenAPI come for free.
- Ship the frontend anywhere.
apps/webbuilds for Node, Cloudflare Workers or as a static SPA with one env var:ADAPTER=…. - Auth depends on the transport, not the platform. Browsers use an httpOnly cookie. The Tauri shells use a bearer token. The UI code is identical.
- The database has exactly one client. Only the API touches Postgres (Bun’s native
Bun.SQLthrough Drizzle). Frontends cannot reach it even by accident.
How a request flows
Section titled “How a request flows”- A Svelte component calls
api.workspaces.list({ organizationId })(packages/api-client). - The oRPC link POSTs to
${PUBLIC_API_URL}/rpc/workspaces/list. In the browser the session cookie rides along (credentials: include). In a Tauri shellAuthorization: Bearer …is attached fromlocalStorage. During SSR,hooks.server.tsrewrites the origin to the internalAPI_URLand forwards the browser’s cookies and the visitor’s address, signed.
- Hono routes to the oRPC handler.
requireAuthresolves the session via Better Auth (auth.api.getSession), and the procedure runs against Drizzle. - The same procedure is reachable as
GET /api/v1/workspaces?organizationId=…and documented at/api/v1/openapi.json. That is what the Python service, curl and third parties use.
Hard rules
Section titled “Hard rules”These are the rules the codebase is built around. Breaking one usually means a second source of truth is being created.
- Frontends never import server packages.
@repo/db,@repo/auth/server,@repo/envand@repo/billingare never imported byapps/weborapps/site. The browser-safe surfaces are@repo/auth/client,@repo/auth/permissions,@repo/billing/catalog,@repo/api-clientand@repo/ui.
- Contract first. No endpoint without a contract entry; no client without the typed client.
A new feature is: contract in
packages/api-contract→ implementation inapps/api/src/rpc/→ the UI calls the typed client. REST and OpenAPI follow. Yours plug in through theproduct.tsfile in each, which kit releases never change (Where your code goes). - No Tauri IPC. The shells have no commands and no data layer. They use the same HTTP client as the browser. Native capabilities are added as Tauri plugins.
- SSR is rendering, not a backend.
apps/webhas no server-side business logic.loadfunctions call the API. No form actions: forms validate the contract’s schema and submit to the API, on every target. - The session guard is universal. Route protection is a universal
loadthat works in SSR and in the static SPA, never a server-only layout load. - The UI learns capabilities from the API (
system.authConfig,system.flags,billing.status) instead of mirroring server configuration into frontend env.
- Tenancy is enforced in the API from one shared role definition. The UI only hides what a role cannot do.
- Env is validated once (
packages/env).SKIP_ENV_VALIDATION=1only for steps that never boot the app. - Generated code is regenerated, not edited (
packages/db/src/schema/auth.ts,packages/db/drizzle/,packages/db/drizzle-product/meta/). - One formatter and linter: Biome. Tabs, single quotes, 100 columns.
services/aiis not a public gateway. It is reached only by the API with a service token. The API owns identity, limits and metering.
Queries that stay fast
Section titled “Queries that stay fast”Postgres plans every statement from the statistics its last ANALYZE sampled, and autoanalyze
refreshes them only once 10% of a table has changed. Right after a bulk insert, or when a table was
analyzed while it held few live rows, the planner believes the new rows barely exist, and a few
common shapes become nested loops that read one input again for every row of the other. The
helpers in @repo/db keep a statement’s cost independent of what the statistics say.
-
Id lists go through
inIds()orinSet().inIds(note.id, ids)binds the whole list as one jsonb parameter (inArraybinds one per id, and Postgres refuses more than 65,535) and tests each row against a hash of the list, built once. A bareid in (select jsonb_array_elements_text(…))is a semi join, which stale statistics run as a nested loop: archiving 1,000 of 5,000 rows the statistics had not seen took about 310 ms that way, against 2.5 ms throughinIds(packages/db/src/id-sets.test.tsprints both). The hash never drives an index, so the rest of the WHERE clause narrows the rows (the owner, the parent row). A short list that is the whole condition, such as a page of ids, staysinArray, which probes the primary key once per id.inSet(value, subquery)is the same test for a subquery of a few ids (one owner’s, one parent’s). The planner hashes a set only while it believes the set fits in hash memory (8 MB by default) and scans a larger one again for every row, so a subquery over most of a table stays a plainin (select …)or a join.inIdsis hashed at any length. -
An id list from a request is scoped as well as hashed.
inScopeandidsOutsideScopefrom@repo/dbadd the caller’s organization, workspace and teams toinIds, for a bulk procedure whose ids the tenant guard does not read (Lists of ids). -
A bulk UPDATE looks each row’s new values up by key in one jsonb object, never through a join with the table itself or with a list of rows (
update note n … from changes c where …). Under stale statistics such a join probed one side once per row of the other: 6.5 s for 5,000 rows in a probe, 13 ms once the table was analyzed, and 11 ms by key whatever the statistics say.const titles = JSON.stringify(Object.fromEntries(changes.map((c) => [c.id, c.title])));await db.execute(sql`with m as materialized (select ${titles}::text::jsonb as t)update note n set title = (select t from m) ->> n.idwhere n.owner_id = ${ownerId} and (select t from m) ? n.id`);Bind the object as a parameter, as here. A large object read from a table column arrives as the table stores it, compressed, and every lookup decompresses all of it again: 920 ms against 9 ms for the same 5,000 keys in a probe.
-
analyzeIfStale(tables)after a bulk insert. It compares the rows each table’s last ANALYZE counted with its live rows, and analyzes the tables that more than doubled (plus 1,000 rows), once, however many callers ask at the same time. A few thousand new rows in a much larger table do not trip it: they stay estimated at next to nothing until autoanalyze runs, which is why the two rules above matter. -
retryTransaction(fn)for writers that lock shared rows in different orders. Of two transactions that wait on each other Postgres ends one (a deadlock,40P01), and underrepeatable readorserializableone it cannot serialize (40001). Either is rolled back whole, andretryTransaction(() => db.transaction(…))runs it again, up to five times after a short, jittered pause, while any other error is thrown at once. Wrap the whole transaction, never statements inside one: the conflict aborts the transaction it happened in, and only a rerun of all of it can succeed. The transaction must work from inputs held in memory, never from a side effect of the attempt that lost. Lock rows in one order (by id) where you can; the retry covers the writers you cannot order.
Repository map
Section titled “Repository map”apps/ web/ SvelteKit application: auth, orgs/teams, workspaces, billing, admin (thin client) api/ Hono on Bun: Better Auth, oRPC router (RPC + REST/OpenAPI), webhooks, job worker site/ Astro: marketing, pricing, blog, SEO docs/ Astro Starlight: product documentation native/ Tauri 2: desktop + mobile shells around apps/web's static build (no IPC)packages/ api-contract/ oRPC + Zod contract: the API's single source of truth api-client/ typed client for the contract (browser, SSR, Tauri, Bun scripts) auth/ Better Auth server instance (API) and Svelte client factory (frontends) db/ Drizzle schema (generated auth tables + ours, incl. pgvector document_chunk), migrations, Bun.SQL client billing/ plan catalog + Stripe client email/ provider abstraction (Resend / console) + templates env/ validated server environment (t3-env + Zod) storage/ staged uploads and immutable final files (S3/R2 or local disk + signed URLs) ui/ shared Svelte 5 components (shadcn-svelte/bits-ui + thin form wrappers) + Tailwind tokens tsconfig/ shared TypeScript configsservices/ ai/ FastAPI service (uv): internal, called by the API only: extract (+ OCR) · summarize · embedinfra/ compose.yml self-hosted stack: postgres (pgvector) · migrate · api · web · ai · caddy (+ profiles: worker, backup, monitoring, observability) compose.dev.yml Postgres for local development caddy/ subdomains.Caddyfile (public) · single-origin.Caddyfile (tailnet/LAN) docker/ Dockerfile.static (Astro sites → Caddy) backup/ pg_dump + uploads, S3 copy, restore drill (`@repo/backup`) scripts/ provision.sh · deploy.sh env/ production .env, encrypted (SOPS + age) loadtest/k6/ smoke.jsscripts/ licenses.ts (dependency-licence audit) · rename.ts (rename the kit)docs/DECISIONS.md architecture decision recordsLICENSE.md · THIRD-PARTY.md · CHANGELOG.md · UPGRADING.md · SECURITY.md · CONTRIBUTING.mdInternal packages are consumed from source (no build step). Bun runs TypeScript natively and
Vite compiles Svelte from the workspace. Versions of shared dependencies are pinned once in the root
package.json catalog and referenced with catalog:.
| Layer | Choice | Why (short) |
|---|---|---|
| Runtime · package manager · test runner | Bun | One tool for installs, scripts, the API runtime and tests |
| Task orchestration | Turborepo | Cached, graph-aware build/check/test |
| Lint · format | Biome | One config for TS/JSON/CSS + Svelte/Astro |
| Application UI | SvelteKit 3 + Svelte 5 | Every route works as SSR and SPA |
| Public sites | Astro 7 (+ Starlight) | Content, SEO, docs; static output |
| Styling | Tailwind v4 | Shared tokens in packages/ui/theme.css as light-dark() pairs |
| UI kit | shadcn-svelte on bits-ui | Generated into packages/ui, one copy for every surface |
| Forms · client cache | sveltekit-superforms (SPA mode, Zod 4) · TanStack Query | Schemas shared with the contract; keys and fetchers derived from it |
| API | Hono + oRPC on Bun | Contract-first, end-to-end types, native OpenAPI 3.1 |
| Database | Postgres 17 + pgvector + Drizzle | SQL as the source of truth; Bun-native driver; embeddings in the same database |
| Auth · orgs · teams · admin | Better Auth | Organization (+teams), admin, bearer, OpenAPI plugins; Drizzle adapter |
| Billing | Stripe via @better-auth/stripe |
Organization as customer, per-seat billing, webhooks handled |
| Desktop · mobile shells | Tauri 2 | System webview, small binaries, Android/iOS first-class |
| Compute services | Python 3.13 + FastAPI, uv |
AI, data, document processing, behind the API |
| Edge · static hosting | Cloudflare Workers static assets | Astro sites + optional SvelteKit SSR |
| Server hosting | Docker Compose + Caddy, Tailscale | One container host or your VPS; private/public access |
Full reasoning, alternatives considered and the trade-offs live in docs/DECISIONS.md in the
repository.