Skip to content

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 --> AI
  • Add a feature once. Declare it in packages/api-contract, implement it in apps/api, call it from any client with full types (api.workspaces.create({...})). REST + OpenAPI come for free.
  • Ship the frontend anywhere. apps/web builds 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.SQL through Drizzle). Frontends cannot reach it even by accident.
  1. A Svelte component calls api.workspaces.list({ organizationId }) (packages/api-client).
  2. The oRPC link POSTs to ${PUBLIC_API_URL}/rpc/workspaces/list. In the browser the session cookie rides along (credentials: include). In a Tauri shell Authorization: Bearer … is attached from localStorage. During SSR, hooks.server.ts rewrites the origin to the internal API_URL and forwards the browser’s cookies and the visitor’s address, signed.
  1. Hono routes to the oRPC handler. requireAuth resolves the session via Better Auth (auth.api.getSession), and the procedure runs against Drizzle.
  2. 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.

These are the rules the codebase is built around. Breaking one usually means a second source of truth is being created.

  1. Frontends never import server packages. @repo/db, @repo/auth/server, @repo/env and @repo/billing are never imported by apps/web or apps/site. The browser-safe surfaces are @repo/auth/client, @repo/auth/permissions, @repo/billing/catalog, @repo/api-client and @repo/ui.
  1. Contract first. No endpoint without a contract entry; no client without the typed client. A new feature is: contract in packages/api-contract → implementation in apps/api/src/rpc/ → the UI calls the typed client. REST and OpenAPI follow. Yours plug in through the product.ts file in each, which kit releases never change (Where your code goes).
  2. 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.
  3. SSR is rendering, not a backend. apps/web has no server-side business logic. load functions call the API. No form actions: forms validate the contract’s schema and submit to the API, on every target.
  4. The session guard is universal. Route protection is a universal load that works in SSR and in the static SPA, never a server-only layout load.
  5. The UI learns capabilities from the API (system.authConfig, system.flags, billing.status) instead of mirroring server configuration into frontend env.
  1. Tenancy is enforced in the API from one shared role definition. The UI only hides what a role cannot do.
  2. Env is validated once (packages/env). SKIP_ENV_VALIDATION=1 only for steps that never boot the app.
  3. Generated code is regenerated, not edited (packages/db/src/schema/auth.ts, packages/db/drizzle/, packages/db/drizzle-product/meta/).
  4. One formatter and linter: Biome. Tabs, single quotes, 100 columns.
  5. services/ai is not a public gateway. It is reached only by the API with a service token. The API owns identity, limits and metering.

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() or inSet(). inIds(note.id, ids) binds the whole list as one jsonb parameter (inArray binds one per id, and Postgres refuses more than 65,535) and tests each row against a hash of the list, built once. A bare id 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 through inIds (packages/db/src/id-sets.test.ts prints 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, stays inArray, 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 plain in (select …) or a join. inIds is hashed at any length.

  • An id list from a request is scoped as well as hashed. inScope and idsOutsideScope from @repo/db add the caller’s organization, workspace and teams to inIds, 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.id
    where 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 under repeatable read or serializable one it cannot serialize (40001). Either is rolled back whole, and retryTransaction(() => 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.

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 configs
services/
ai/ FastAPI service (uv): internal, called by the API only: extract (+ OCR) · summarize · embed
infra/
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.js
scripts/ licenses.ts (dependency-licence audit) · rename.ts (rename the kit)
docs/DECISIONS.md architecture decision records
LICENSE.md · THIRD-PARTY.md · CHANGELOG.md · UPGRADING.md · SECURITY.md · CONTRIBUTING.md

Internal 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.