#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge #set page(width: auto, height: auto, margin: 1.5em) #set text(font: "Fira Sans", size: 9pt, fill: rgb("#1a1a2e")) // === Color Palette === #let c-edge = rgb("#e8f4f8") #let c-gateway = rgb("#fce4ec") #let c-api = rgb("#e8eaf6") #let c-session = rgb("#e0f2f1") #let c-client = rgb("#fff3e0") #let c-infra = rgb("#f3e5f5") #let c-bus = rgb("#fff9c4") #let c-future = rgb("#f5f5f5") #let s-main = rgb("#37474f") + 0.8pt #let s-data = rgb("#7b1fa2") + 0.8pt #let s-bus = rgb("#e65100") + 0.8pt #let s-dim = rgb("#bbb") + 0.6pt #let label-sm(body) = text(size: 7pt, body) #let label-xs(body) = text(size: 6.5pt, fill: rgb("#666"), body) #let section(title) = { v(2em) align(center, text(size: 13pt, weight: "bold", fill: rgb("#37474f"), title)) v(0.6em) } // ============================================================ // TITLE // ============================================================ #align(center)[ #text(size: 18pt, weight: "bold")[ajet-chat] #v(0.2em) #text(size: 9pt, fill: rgb("#666"))[System Architecture — Clojure · http-kit · PostgreSQL · Datastar · MinIO] ] // ============================================================ // 1. SYSTEM OVERVIEW // ============================================================ #section[1 · System Overview] #align(center)[ #diagram( spacing: (3.5em, 2.4em), node-stroke: s-main, node-corner-radius: 4pt, // External node((2.5, 0.0), [*Internet*], stroke: none, fill: none), node((-0.5, 0.0), [*Webhooks*\ #label-xs[git · CI · monitoring]], fill: c-client), // nginx node((2.5, 1.0), [*nginx*\ #label-xs[TLS termination · prod only]], fill: c-edge, width: 16em), // Auth Gateway node((2.5, 2.2), [*Auth Gateway*\ #label-xs[http-kit · session/token validation · rate limit · CORS · routing]], fill: c-gateway, width: 26em), // Services row node((-0.5, 4.0), [*API*\ #label-xs[stateless REST]\ #label-xs[reads / writes → PG]\ #label-xs[publishes → NATS]], fill: c-api), node((1.8, 4.0), [*Web SM*\ #label-xs[Hiccup + Datastar SSE]\ #label-xs[browser connections]], fill: c-session), node((3.8, 4.0), [*TUI SM*\ #label-xs[SSE + HTTP POST]\ #label-xs[terminal connections]], fill: c-session), node((5.2, 4.0), [*Mobile SM*\ #label-xs[(future)]], fill: c-future, stroke: s-dim), // Event bus node((2.5, 5.8), [*NATS*\ #label-xs[pub/sub event bus]\ #label-xs[community-scoped subjects]], fill: c-bus), // Data stores node((-0.5, 7.5), [*PostgreSQL*\ #label-xs[data store · FTS]\ #label-xs[Auth GW + API only]], fill: c-infra), node((-2.2, 4.0), [*MinIO*\ #label-xs[S3 files · avatars]], fill: c-infra), // Clients node((1.8, 8.0), [*Browsers*], stroke: none, fill: none), node((3.8, 8.0), [*Terminals*\ #label-xs[CLI + TUI]], stroke: none, fill: none), node((5.2, 8.0), [#text(fill: rgb("#aaa"))[*Phones*]], stroke: none, fill: none), // --- Edges --- // Internet → nginx → Auth Gateway edge((2.5, 0.0), (2.5, 1.0), "-|>"), edge((-0.5, 0.0), (2.5, 2.2), "-|>"), edge((2.5, 1.0), (2.5, 2.2), "-|>"), // Auth Gateway → services edge((2.5, 2.2), (-0.5, 4.0), "-|>", label: label-sm[`/api/*`], label-side: left), edge((2.5, 2.2), (1.8, 4.0), "-|>", label: label-sm[`/web`], label-side: left), edge((2.5, 2.2), (3.8, 4.0), "-|>", label: label-sm[`/ws/tui`], label-side: right), edge((2.5, 2.2), (5.2, 4.0), "-|>", stroke: s-dim), // API → PG (data reads/writes — only API + Auth GW touch PG) edge((-0.5, 4.0), (-0.5, 7.5), "<-|>", stroke: s-data, label: text(size: 7pt, fill: rgb("#7b1fa2"))[data], label-side: left), // API → NATS (publish events after writes) edge((-0.5, 4.0), (2.5, 5.8), "-|>", stroke: s-bus, label: text(size: 7pt, fill: rgb("#e65100"))[pub], label-side: left), // NATS → SMs (subscribe for events) edge((2.5, 5.8), (1.8, 4.0), "-|>", stroke: s-bus, label: text(size: 7pt, fill: rgb("#e65100"))[sub], label-side: right), edge((2.5, 5.8), (3.8, 4.0), "-|>", stroke: s-bus), edge((2.5, 5.8), (5.2, 4.0), "-|>", stroke: s-dim), // SMs → API (fetch full data after event notification) edge((1.8, 4.0), (-0.5, 4.0), "-|>", stroke: s-data, bend: 20deg, label: text(size: 7pt, fill: rgb("#7b1fa2"))[fetch], label-side: right), edge((3.8, 4.0), (-0.5, 4.0), "-|>", stroke: s-data, bend: 12deg), // API → MinIO (files) edge((-0.5, 4.0), (-2.2, 4.0), "-|>", stroke: s-data, label: text(size: 7pt, fill: rgb("#7b1fa2"))[files], label-side: left), // SMs → clients edge((1.8, 4.0), (1.8, 8.0), "-|>", label: label-sm[SSE], label-side: left), edge((3.8, 4.0), (3.8, 8.0), "-|>", label: label-sm[SSE], label-side: left), edge((5.2, 4.0), (5.2, 8.0), "-|>", stroke: s-dim), ) ] // Legend #v(0.5em) #align(center)[ #rect(stroke: 0.5pt + rgb("#ccc"), radius: 4pt, inset: 0.6em)[ #text(size: 7pt)[ #box(rect(fill: c-gateway, width: 0.7em, height: 0.7em, radius: 2pt)) Auth #h(0.8em) #box(rect(fill: c-api, width: 0.7em, height: 0.7em, radius: 2pt)) API #h(0.8em) #box(rect(fill: c-session, width: 0.7em, height: 0.7em, radius: 2pt)) Session Mgr #h(0.8em) #box(rect(fill: c-infra, width: 0.7em, height: 0.7em, radius: 2pt)) Infrastructure #h(0.8em) #box(rect(fill: c-bus, width: 0.7em, height: 0.7em, radius: 2pt)) NATS #h(0.8em) #line(length: 1em, stroke: s-bus) pub/sub #h(0.6em) #line(length: 1em, stroke: s-data) data ] ] ] // ============================================================ // 2. AUTH GATEWAY DETAIL // ============================================================ #section[2 · Auth Gateway Flow] #align(center)[ #diagram( spacing: (3.5em, 2em), node-stroke: s-main, node-corner-radius: 4pt, // Incoming node((0, 0), [*Client Request*\ #label-xs[HTTPS / SSE]], fill: c-client), // Steps node((0, 1), [*TLS Termination*\ #label-xs[nginx (prod only)]], fill: c-edge), node((0, 2), [*Extract Token*\ #label-xs[cookie or Authorization header]], fill: c-gateway), node((0, 3), [*DB Lookup*\ #label-xs[sessions / api\_tokens table]], fill: c-gateway), // Branch node((-1.5, 4), [*401 Unauthorized*], fill: rgb("#ffcdd2"), stroke: rgb("#c62828") + 0.8pt), node((1.5, 4), [*Attach User Context*\ #label-xs[user\_id, role, community\_id]], fill: c-gateway), // Rate limit node((1.5, 5), [*Rate Limit Check*\ #label-xs[per-user / per-IP]], fill: c-gateway), // Route node((1.5, 6), [*Route to Service*], fill: c-gateway), // Targets node((0, 7), [*API*], fill: c-api), node((1.5, 7), [*Web SM*], fill: c-session), node((3, 7), [*TUI SM*], fill: c-session), // Edges edge((0, 0), (0, 1), "-|>"), edge((0, 1), (0, 2), "-|>"), edge((0, 2), (0, 3), "-|>"), edge((0, 3), (-1.5, 4), "-|>", label: label-sm[invalid], label-side: left), edge((0, 3), (1.5, 4), "-|>", label: label-sm[valid], label-side: right), edge((1.5, 4), (1.5, 5), "-|>"), edge((1.5, 5), (1.5, 6), "-|>"), edge((1.5, 6), (0, 7), "-|>", label: label-sm[`/api/*`], label-side: left), edge((1.5, 6), (1.5, 7), "-|>", label: label-sm[`/web`], label-side: left), edge((1.5, 6), (3, 7), "-|>", label: label-sm[`/ws/tui`], label-side: right), ) ] // ============================================================ // 3. REAL-TIME EVENT FLOW // ============================================================ #section[3 · Real-Time Event Flow] #align(center)[ #diagram( spacing: (3.5em, 2.2em), node-stroke: s-main, node-corner-radius: 4pt, // Trigger node((0, 0), [*User sends message*\ #label-xs[POST /api/messages]], fill: c-client), // API node((0, 1), [*API*\ #label-xs[validate · write to DB]], fill: c-api), // Two steps: DB write then NATS publish node((-1, 2.2), [*PostgreSQL*\ #label-xs[INSERT message row]], fill: c-infra), node((1, 2.2), [*NATS*\ #label-xs[publish `chat.events.\{community\}`]], fill: c-bus), // Fan out — SMs subscribe to NATS node((-1.5, 3.8), [*Web SM*\ #label-xs[receives via NATS sub]], fill: c-session), node((0, 3.8), [*TUI SM*\ #label-xs[receives via NATS sub]], fill: c-session), node((1.5, 3.8), [*Mobile SM*\ #label-xs[NATS sub]], fill: c-future, stroke: s-dim), // Client delivery — SMs fetch full data from API node((-1.5, 5), [*Fetch full message*\ #label-xs[GET /api/messages/:id]], fill: c-api), node((-1.5, 6.2), [*Datastar SSE*\ #label-xs[push HTML fragment]], fill: c-session), node((0, 6.2), [*SSE*\ #label-xs[push JSON payload]], fill: c-session), // Clients node((-1.5, 7.4), [*Browser*\ #label-xs[DOM patched live]], fill: none, stroke: none), node((0, 7.4), [*Terminal*\ #label-xs[message rendered]], fill: none, stroke: none), // Edges edge((0, 0), (0, 1), "-|>"), edge((0, 1), (-1, 2.2), "-|>", stroke: s-data, label: text(size: 7pt, fill: rgb("#7b1fa2"))[INSERT], label-side: left), edge((0, 1), (1, 2.2), "-|>", stroke: s-bus, label: text(size: 7pt, fill: rgb("#e65100"))[publish], label-side: right), edge((1, 2.2), (-1.5, 3.8), "-|>", stroke: s-bus), edge((1, 2.2), (0, 3.8), "-|>", stroke: s-bus), edge((1, 2.2), (1.5, 3.8), "-|>", stroke: s-dim), edge((-1.5, 3.8), (-1.5, 5), "-|>"), edge((0, 3.8), (0, 6.2), "-|>"), edge((-1.5, 5), (-1.5, 6.2), "-|>"), edge((-1.5, 6.2), (-1.5, 7.4), "-|>"), edge((0, 6.2), (0, 7.4), "-|>"), ) ] // ============================================================ // 4. DATA MODEL // ============================================================ #section[4 · Data Model] #align(center)[ #diagram( spacing: (4em, 2.5em), node-stroke: s-main, node-corner-radius: 3pt, // Core entities node((2.5, 1.0), [*communities*\ #label-xs[id · name · slug]], fill: rgb("#e1bee7")), node((0.3, 1.9), [*users*\ #label-xs[id · username · email\ display\_name · avatar]], fill: rgb("#bbdefb")), node((5.0, 0.7), [*channels*\ #label-xs[id · name · type\ public / private / voice]], fill: rgb("#c8e6c9")), // Junction node((2.6, 2.0), [*community\_members*\ #label-xs[role: owner / admin / member\ nickname · avatar override]], fill: rgb("#f0f4c3")), node((2.4, 0.1), [*channel\_members*\ #label-xs[joined\_at]], fill: rgb("#f0f4c3")), // Messages node((2.6, 4.1), [*messages*\ #label-xs[id · body\_md · parent\_id\ created\_at · edited\_at]], fill: rgb("#ffe0b2")), // Related node((0.4, 0.0), [*notifications*\ #label-xs[id · type · source\_id\ read: bool]], fill: rgb("#ffccbc")), node((5.2, 5.0), [*attachments*\ #label-xs[id · filename · storage\_key\ content\_type · size]], fill: rgb("#d1c4e9")), node((2.0, 6.3), [*reactions*\ #label-xs[emoji · user\_id\ PK: msg + user + emoji]], fill: rgb("#fff9c4")), node((4.1, 6.3), [*mentions*\ #label-xs[target\_type · target\_id]], fill: rgb("#ffccbc")), node((5.0, 2.7), [*webhooks*\ #label-xs[id · name · token\_hash\ channel\_id · avatar]], fill: rgb("#b2dfdb")), // Auth node((0.2, 3.9), [*sessions*\ #label-xs[token\_hash · expires\_at]], fill: rgb("#cfd8dc")), node((0.0, 8.0), [*api\_users*\ #label-xs[name · scopes]], fill: rgb("#cfd8dc")), node((2.6, 8.0), [*api\_tokens*\ #label-xs[token\_hash · scopes · expires]], fill: rgb("#cfd8dc")), // Relationships edge((2.5, 1.0), (5.0, 0.7), "-|>", label: label-sm[has], label-side: right), edge((2.5, 1.0), (2.6, 2.0), "-|>", label: label-sm[membership], label-side: left), edge((0.3, 1.9), (2.6, 2.0), "-|>"), edge((5.0, 0.7), (2.4, 0.1), "-|>"), edge((0.3, 1.9), (2.4, 0.1), "-|>"), edge((5.0, 0.7), (2.6, 4.1), "-|>"), edge((0.3, 1.9), (2.6, 4.1), "-|>", label: label-sm[author], label-side: left), edge((2.6, 4.1), (2.6, 4.1), "-|>", bend: 130deg, label: label-sm[thread], label-side: right), edge((2.6, 4.1), (5.2, 5.0), "-|>"), edge((2.6, 4.1), (2.0, 6.3), "-|>"), edge((2.6, 4.1), (4.1, 6.3), "-|>"), edge((0.3, 1.9), (0.4, 0.0), "-|>"), edge((0.3, 1.9), (2.0, 6.3), "-|>", bend: -10deg), edge((0.3, 1.9), (0.2, 3.9), "-|>", label: label-sm[owns], label-side: left), edge((0.0, 8.0), (2.6, 8.0), "-|>"), edge((5.0, 0.7), (5.0, 2.7), "-|>"), ) ] // ============================================================ // 5. PRESENCE & TYPING // ============================================================ #section[5 · Presence & Typing Indicators] #align(center)[ #diagram( spacing: (3.5em, 2em), node-stroke: s-main, node-corner-radius: 4pt, // Heartbeat flow node((-1, 0), [*Heartbeat (60s)*], stroke: none, fill: none), node((2.5, 0), [*Typing*], stroke: none, fill: none), // Heartbeat node((-1, 1), [*Client*\ #label-xs[POST /api/heartbeat]], fill: c-client), node((-1, 2), [*API*\ #label-xs[instant 200 response]], fill: c-api), node((-1, 3.2), [*PostgreSQL*\ #label-xs[UPDATE last\_seen\_at]], fill: c-infra), node((-1, 4.0), [*NATS*\ #label-xs[API publishes presence event]], fill: c-bus), node((-1, 5.2), [*Session Managers*\ #label-xs[receive via NATS sub]\ #label-xs[buffer → flush 1x/min per client]\ #label-xs[filter: relevant users only]\ #label-xs[diff: status changes only]], fill: c-session), node((-1, 6.8), [*Clients*\ #label-xs[batched presence snapshot]], fill: none, stroke: none), // Typing — SM publishes directly to NATS, not via API node((2.5, 1), [*Keypress in input*], fill: c-client), node((2.5, 2), [*Session Manager*\ #label-xs[receives via HTTP POST]], fill: c-session), node((2.5, 3.2), [*NATS*\ #label-xs[SM publishes typing event]\ #label-xs[no API round-trip]], fill: c-bus), node((2.5, 4.5), [*Other SMs*\ #label-xs[receive via NATS sub]\ #label-xs[filter: same channel/DM]\ #label-xs[deliver immediately]], fill: c-session), node((2.5, 5.8), [*Clients*\ #label-xs[show "user is typing..."]], fill: none, stroke: none), node((2.5, 6.8), [*Auto-expire*\ #label-xs[15s no keypress → hide]\ #label-xs[typing:stop on send or clear input]], fill: rgb("#fff3e0")), // Heartbeat edges edge((-1, 1), (-1, 2), "-|>"), edge((-1, 2), (-1, 3.2), "-|>", stroke: s-data), edge((-1, 3.2), (-1, 4.0), "-|>", stroke: s-bus), edge((-1, 4.0), (-1, 5.2), "-|>", stroke: s-bus), edge((-1, 5.2), (-1, 6.8), "-|>"), // Typing edges edge((2.5, 1), (2.5, 2), "-|>"), edge((2.5, 2), (2.5, 3.2), "-|>", stroke: s-bus), edge((2.5, 3.2), (2.5, 4.5), "-|>", stroke: s-bus), edge((2.5, 4.5), (2.5, 5.8), "-|>"), ) ] // ============================================================ // 6. CLI / TUI CLIENT // ============================================================ #section[6 · CLI / TUI Client] #align(center)[ #diagram( spacing: (3.5em, 2em), node-stroke: s-main, node-corner-radius: 4pt, // Entry node((1, 0), [*`ajet-chat`*\ #label-xs[single binary · babashka/bbin]], fill: c-client), // Branch node((0, 1.5), [*CLI Mode*\ #label-xs[`ajet-chat [args]`]\ #label-xs[one-shot · stateless]], fill: rgb("#e3f2fd")), node((2, 1.5), [*TUI Mode*\ #label-xs[`ajet-chat` (no args)]\ #label-xs[interactive · clojure-tui]], fill: rgb("#e8f5e9")), // CLI details node((-0.5, 3), [*Commands*\ #label-xs[send · read · channels\ notifications · status\ /help · /ban · /kick]], fill: rgb("#e3f2fd")), node((0, 4.5), [*API Client*\ #label-xs[HTTP → Auth GW → API]], fill: c-api), // TUI details node((2, 3), [*UI Layout*\ #label-xs[channel sidebar\ message list · input\ presence · typing]], fill: rgb("#e8f5e9")), node((3, 3), [*timg*\ #label-xs[inline images\ optional dep]], fill: rgb("#fff3e0")), node((2, 4.5), [*TUI SM Connection*\ #label-xs[SSE for live updates]], fill: c-session), // Shared node((1, 6), [*Shared Components*\ #label-xs[API client · auth/config (~/.config/ajet-chat/)\ message formatting · notification model]], fill: rgb("#f5f5f5"), width: 20em), // Edges edge((1, 0), (0, 1.5), "-|>", label: label-sm[args], label-side: left), edge((1, 0), (2, 1.5), "-|>", label: label-sm[no args], label-side: right), edge((0, 1.5), (-0.5, 3), "-|>"), edge((-0.5, 3), (0, 4.5), "-|>"), edge((2, 1.5), (2, 3), "-|>"), edge((2, 3), (2, 4.5), "-|>"), edge((0, 4.5), (1, 6), "-|>"), edge((2, 4.5), (1, 6), "-|>"), ) ] // ============================================================ // 7. DEPLOYMENT // ============================================================ #section[7 · Deployment] #align(center)[ #diagram( spacing: (3em, 2em), node-stroke: s-main, node-corner-radius: 4pt, // Dev node((-1, 0), [*Development*], stroke: none, fill: none), node((3, 0), [*Production*], stroke: none, fill: none), // Dev stack node((-1, 1), [*docker-compose.dev.yml*\ #label-xs[infrastructure only]], fill: c-edge), node((-2.5, 2.5), [*PostgreSQL*\ #label-xs[container]], fill: c-infra), node((-1, 2.5), [*NATS*\ #label-xs[container]], fill: c-bus), node((0.5, 2.5), [*MinIO*\ #label-xs[container]], fill: c-infra), node((-1, 4), [*Clojure REPLs*\ #label-xs[local JVM processes]\ #label-xs[nrepl + cider-nrepl]\ #label-xs[start! / stop! / reset!]], fill: c-api, width: 14em), // Prod stack node((3.5, 1), [*docker-compose.yml*\ #label-xs[full stack]], fill: c-edge), node((2.5, 2.5), [*nginx*], fill: c-edge), node((2.5, 3.5), [*Auth GW*\ #label-xs[uberjar]], fill: c-gateway), node((2, 4.5), [*API*\ #label-xs[uberjar]], fill: c-api), node((3, 4.5), [*Web SM*\ #label-xs[uberjar]], fill: c-session), node((4, 4.5), [*TUI SM*\ #label-xs[uberjar]], fill: c-session), node((2, 5.8), [*PostgreSQL*], fill: c-infra), node((3, 5.8), [*NATS*], fill: c-bus), node((4, 5.8), [*MinIO*], fill: c-infra), // Dev edges edge((-1, 1), (-2.5, 2.5), "-|>"), edge((-1, 1), (-1, 2.5), "-|>"), edge((-1, 1), (0.5, 2.5), "-|>"), edge((-1, 4), (-2.5, 2.5), "<-|>", stroke: s-data, label: label-sm[JDBC], label-side: left), edge((-1, 4), (-1, 2.5), "<-|>", stroke: s-bus, label: label-sm[pub/sub], label-side: left), edge((-1, 4), (0.5, 2.5), "<-|>", stroke: s-data, label: label-sm[S3], label-side: right), // Prod edges edge((3.5, 1), (2.5, 2.5), "-|>"), edge((2.5, 2.5), (2.5, 3.5), "-|>"), edge((2.5, 3.5), (2, 4.5), "-|>"), edge((2.5, 3.5), (3, 4.5), "-|>"), edge((2.5, 3.5), (4, 4.5), "-|>"), edge((2, 4.5), (2, 5.8), "-|>", stroke: s-data), edge((2, 4.5), (3, 5.8), "-|>", stroke: s-bus), edge((2, 4.5), (4, 5.8), "-|>", stroke: s-data), ) ]