Architecture Overview
This guide explains the high-level architecture of Sayr, how the different packages interact, and the flow of data through the system.
System Overview
Section titled “System Overview”Sayr is built as a monorepo with multiple applications and shared packages:
┌─────────────────────────────────────────────────────────────────┐│ Client Browser │└───────────────────────────┬─────────────────────────────────────┘ │ ┌─────────────┴─────────────┐ ▼ ▼┌─────────────────────────┐ ┌─────────────────────────┐│ apps/start │ │ apps/marketing ││ (TanStack Start) │ │ (Astro) ││ Port 3000 │ │ Port 3002 │└───────────┬─────────────┘ └─────────────────────────┘ │ │ HTTP + WebSocket ▼┌─────────────────────────┐│ apps/backend ││ (Hono on Bun) ││ Port 5468 │└───────────┬─────────────┘ │ ┌───────┼───────┐ ▼ ▼ ▼┌───────┐ ┌─────┐ ┌───────┐│ DB │ │Redis│ │ MinIO ││(Postgres)│ │ │ (S3) │└───────┘ └─────┘ └───────┘Applications
Section titled “Applications”apps/start (Frontend)
Section titled “apps/start (Frontend)”The main user-facing application built with:
- TanStack Start - Full-stack React framework
- React 19 - UI library
- TanStack Router - Type-safe routing
- Shadcn/ui - Component library (via
@repo/ui)
Key responsibilities:
- User authentication flows
- Organization management UI
- Task boards and management
- Real-time updates via WebSocket
apps/backend (API Server)
Section titled “apps/backend (API Server)”The backend API server built with:
- Hono - Fast web framework
- Bun - JavaScript runtime
- WebSocket - Real-time communication
Key responsibilities:
- REST API endpoints
- WebSocket connections for real-time updates
- Authentication session management
- Business logic and validation
apps/worker (Background Jobs)
Section titled “apps/worker (Background Jobs)”Processes background jobs and webhooks:
- GitHub webhook processing - Syncs issues, PRs
- Queue consumption - Handles async tasks
apps/marketing (Documentation & Marketing)
Section titled “apps/marketing (Documentation & Marketing)”Static site for docs and marketing:
- Astro - Static site generator
- Starlight - Documentation theme
Shared Packages
Section titled “Shared Packages”@repo/database
Section titled “@repo/database”Central database package using Drizzle ORM:
// Schema definitionsimport { schema } from "@repo/database";
// Database clientimport { db } from "@repo/database";
// CRUD functionsimport { getTaskById, createTask, updateTask } from "@repo/database";
// Typesimport type { TaskWithLabels, OrganizationWithMembers } from "@repo/database";@repo/auth
Section titled “@repo/auth”Authentication configuration using Better Auth:
import { auth } from "@repo/auth";Supports:
- GitHub OAuth
- Doras OAuth (internal)
- Session management
@repo/ui
Section titled “@repo/ui”Shared component library based on Shadcn/ui:
import { Button } from "@repo/ui/components/button";import { Dialog } from "@repo/ui/components/dialog";import { cn } from "@repo/ui/lib/utils";@repo/storage
Section titled “@repo/storage”File storage client for MinIO/S3:
import { uploadFile, getFileUrl } from "@repo/storage";@repo/queue
Section titled “@repo/queue”Job queue abstraction:
import { enqueue, processQueue } from "@repo/queue";@repo/util
Section titled “@repo/util”Shared utilities:
import { generateSlug, formatDate, ensureCdnUrl } from "@repo/util";Data Flow
Section titled “Data Flow”Request Flow (REST API)
Section titled “Request Flow (REST API)”1. Client makes HTTP request └─► apps/backend (Hono API)
2. Global middleware ├─► Parse cookies / headers └─► Load & validate session └─► Attach user/session to context
3. Route handler executes ├─► Route-specific authorization check │ └─► hasOrgPermission / ownership / scope ├─► Route-specific input validation └─► Execute business logic
4. Database operations └─► @repo/database (Drizzle ORM) └─► PostgreSQL
5. Response returns └─► apps/backend → ClientReal-time Updates (WebSocket)
Section titled “Real-time Updates (WebSocket)”1. Client initiates WebSocket connection └─► apps/backend /ws (upgradeWebSocket)
2. Server accepts connection ├─► Generate wsClientId (unique per connection) ├─► Create connection metadata entry (wsClients) │ ├─► connectedAt │ ├─► heartbeat state (lastPing / lastPong / latency) │ └─► rate‑limit state (lastMessageAt / offenceCount) └─► Attempt session lookup from request headers ├─► Authenticated → clientId = user.id └─► Unauthenticated → clientId = "ANONYMOUS"
3. Server sends connection status └─► CONNECTION_STATUS ├─► authenticated: true | false └─► wsClientId
4. Initial server‑side subscription (best‑effort) ├─► If `orgId` query param is present │ └─► Auto‑subscribe to `${orgId}:public` └─► Otherwise └─► Subscribe to default/public or waiting room
(Note: this does not grant access to private channels)
5. Client explicitly subscribes to channels └─► WS message: { type: "SUBSCRIBE", orgId, channel }
6. Per‑SUBSCRIBE authorization (route‑level) ├─► Rate‑limit check (MIN_MESSAGE_INTERVAL) ├─► Waiting‑room enforcement │ └─► Only SUBSCRIBE / UNSUBSCRIBE / PONG allowed ├─► Channel access rules │ ├─► public │ │ └─► Allowed for anonymous clients │ ├─► private org channels │ │ ├─► Requires valid session │ │ └─► safeGetOrganization(orgId, userId) │ └─► admin channels │ └─► Requires user.role === "admin" └─► On failure ├─► Send ERROR └─► Optionally close socket
7. Subscription state update ├─► Unsubscribe from any previous rooms ├─► Add client to rooms[`${orgId}:${channel}`] ├─► Send SUBSCRIBED (INDIVIDUAL) └─► Broadcast USER_SUBSCRIBED (CHANNEL)
8. Backend data mutation occurs └─► Example: task created / updated └─► broadcast(orgId, "tasks", { type: "CREATE_TASK", data })
9. Broadcast fan‑out ├─► Resolve rooms[`${orgId}:tasks`] ├─► Skip sender if applicable ├─► Attach metadata │ ├─► ts │ ├─► orgId │ └─► channel └─► Send message with scope = "CHANNEL"
10. Client receives broadcast ├─► Validate orgId / channel relevance └─► Update local application state
11. Heartbeat & liveness management (parallel) ├─► Server sends PING every 30 seconds ├─► Client replies with PONG ├─► RTT / latency tracked per connection └─► Server closes sockets with no PONG after 60 seconds
12. Disconnect / unsubscribe lifecycle ├─► Triggered by close, error, rate‑limit, or timeout ├─► Remove client from all rooms ├─► Broadcast USER_UNSUBSCRIBED to affected channels └─► Remove wsClients entry and release resourcesAuthentication Flow
Section titled “Authentication Flow”1. User clicks "Sign in with GitHub" └─► App sets `login_origin` cookie └─► Redirects to GitHub OAuth
2. GitHub redirects back with `code` └─► /api/auth/callback/github
3. Callback exchanges code for tokens └─► @repo/auth validates user └─► Session created └─► Session stored in DB (@repo/database) └─► Session cookie set (HttpOnly)
4. Callback redirects to auth-check └─► /login/auth-check
5. Auth-check validates *presence of session* └─► Reads `login_origin` cookie └─► Clears `login_origin` └─► Redirects user to original app URL
6. Subsequent requests authenticated └─► Session cookie sent automaticallyPermission System
Section titled “Permission System”Sayr uses a team-based permission system:
Organization └─► Teams (with permission sets) └─► Members (users assigned to teams)Permission Categories
Section titled “Permission Categories”| Category | Permissions |
|---|---|
admin | administrator, manageMembers, manageTeams, manageSettings |
content | manageLabels, manageCategories |
tasks | create, edit, delete, assign |
Permission Checking
Section titled “Permission Checking”// In API routesconst isAuthorized = await hasOrgPermission( session.userId, orgId, "tasks.create" // category.permission);
if (!isAuthorized) { return c.json({ error: "Permission denied" }, 401);}The administrator permission grants full access to all other permissions.
WebSocket Channels
Section titled “WebSocket Channels”| Channel | Purpose | Subscribers |
|---|---|---|
tasks | Task updates | Users viewing task board |
admin | Admin updates | Users in admin panel |
public | Public board updates | Anonymous viewers |
Message Types
Section titled “Message Types”type WSMessageType = | "CREATE_TASK" | "UPDATE_TASK" | "DELETE_TASK" | "CREATE_LABEL" | "UPDATE_LABEL" | "DELETE_LABEL" // ... more typesDatabase Schema Overview
Section titled “Database Schema Overview”Core Entities
Section titled “Core Entities”┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ user │────►│ member │◄────│ organization │└──────────────┘ └──────────────┘ └──────────────┘ │ ▼ ┌──────────────┐ │ team │ └──────────────┘Task Relationships
Section titled “Task Relationships”┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ task │────►│ taskAssignee │◄────│ user │└──────────────┘ └──────────────┘ └──────────────┘ │ ├────►┌──────────────┐ ┌──────────────┐ │ │ taskLabel │◄────│ label │ │ └──────────────┘ └──────────────┘ │ └────►┌──────────────┐ │ taskComment │ └──────────────┘Environment Configuration
Section titled “Environment Configuration”Frontend (apps/start)
Section titled “Frontend (apps/start)”VITE_URL_ROOT=http://admin.app.localhost:3000VITE_ROOT_DOMAIN=app.localhostVITE_PROJECT_NAME=SayrBackend (apps/backend)
Section titled “Backend (apps/backend)”DATABASE_URL=postgresql://...STORAGE_URL=http://localhost:9000INTERNAL_SECRET=...Shared
Section titled “Shared”Both apps need access to:
- Database connection
- Auth configuration
- Storage credentials
Tracing and Observability
Section titled “Tracing and Observability”Sayr uses OpenTelemetry for distributed tracing:
import { createTraceAsync } from "@repo/opentelemetry";
const traceAsync = createTraceAsync();
const result = await traceAsync( "task.create", () => createTask(data), { description: "Creating new task" });Traces are sent to Axiom (when configured) for analysis and debugging.
Key Design Decisions
Section titled “Key Design Decisions”Why Turborepo?
Section titled “Why Turborepo?”- Shared code - Common packages used across apps
- Parallel builds - Faster CI/CD pipelines
- Consistent tooling - Same linting/formatting everywhere
Why Bun for Backend?
Section titled “Why Bun for Backend?”- Performance - Faster startup and execution
- Native TypeScript - No build step needed
- WebSocket support - Built-in, performant WebSockets
Why TanStack Start?
Section titled “Why TanStack Start?”- Full-stack - Server functions + client rendering
- Type-safe routing - Catch errors at compile time
- React 19 - Latest React features
Why Drizzle ORM?
Section titled “Why Drizzle ORM?”- Type-safe queries - Full TypeScript inference
- SQL-like syntax - Familiar to SQL developers
- Performance - Lightweight, fast queries
Related Guides
Section titled “Related Guides”- Local Development — Set up your development environment
- Database Guide — Detailed Drizzle ORM patterns and queries
- Adding Features — End-to-end feature implementation walkthrough
- Code Style Guide — Coding conventions and best practices