init commit

This commit is contained in:
2026-02-17 00:23:25 -05:00
commit 79b6a5e225
25 changed files with 1648 additions and 0 deletions
+436
View File
@@ -0,0 +1,436 @@
#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 <cmd> [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),
)
]