WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

Merge main into firehose branch

Resolved conflicts from monorepo reorganization:
- Moved firehose implementation files to apps/appview
- Consolidated database dependencies in @atbb/db package
- Removed duplicate drizzle-orm and postgres dependencies from appview
- Added @skyware/jetstream dependency for Jetstream integration
- Updated lockfile with pnpm install

+3917 -94
+12
.envrc
··· 1 + #!/usr/bin/env bash 2 + 3 + export DIRENV_WARN_TIMEOUT=20s 4 + 5 + eval "$(devenv direnvrc)" 6 + 7 + # `use devenv` supports the same options as the `devenv shell` command. 8 + # 9 + # To silence all output, use `--quiet`. 10 + # 11 + # Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true 12 + use devenv
+19 -7
CLAUDE.md
··· 4 4 5 5 The master project plan with MVP phases and progress tracking lives at `docs/atproto-forum-plan.md`. 6 6 7 - ## Packages 7 + ## Apps & Packages 8 8 9 - | Package | Description | Port | 10 - |---------|-------------|------| 11 - | `@atbb/lexicon` | AT Proto lexicon definitions (YAML) + generated TypeScript types | — | 9 + ### Apps (`apps/`) 10 + 11 + Servers and applications that are deployed or run as services. 12 + 13 + | App | Description | Port | 14 + |-----|-------------|------| 12 15 | `@atbb/appview` | Hono JSON API server — indexes forum data, serves API | 3000 | 13 16 | `@atbb/web` | Hono JSX + HTMX server-rendered web UI — calls appview API | 3001 | 14 - | `@atbb/spike` | PDS read/write test script for validating AT Proto operations | — | 15 17 16 - **Dependency chain:** `@atbb/lexicon` builds first (generates types), then `@atbb/appview` and `@atbb/web` build in parallel. Turbo handles this via `^build`. 18 + ### Packages (`packages/`) 19 + 20 + Shared libraries, tools, and utilities consumed by apps or used standalone. 21 + 22 + | Package | Description | 23 + |---------|-------------| 24 + | `@atbb/db` | Drizzle ORM schema and connection factory for PostgreSQL | 25 + | `@atbb/lexicon` | AT Proto lexicon definitions (YAML) + generated TypeScript types | 26 + | `@atbb/spike` | PDS read/write test script for validating AT Proto operations | 27 + 28 + **Dependency chain:** `@atbb/lexicon` and `@atbb/db` build first, then `@atbb/appview` and `@atbb/web` build in parallel. Turbo handles this via `^build`. 17 29 18 30 ## Development 19 31 ··· 68 80 69 81 - **`@types/node` is required** as a devDependency in every package that uses `process.env` or other Node APIs. `tsx` doesn't need it at runtime, but `tsc` builds will fail without it. 70 82 - **Hono JSX `children`:** Use `PropsWithChildren<T>` from `hono/jsx` for components that accept children. Unlike React, Hono's `FC<T>` does not include `children` implicitly. 71 - - **HTMX attributes in JSX:** The `typed-htmx` package provides types for `hx-*` attributes. See `packages/web/src/global.d.ts` for the augmentation. 83 + - **HTMX attributes in JSX:** The `typed-htmx` package provides types for `hx-*` attributes. See `apps/web/src/global.d.ts` for the augmentation. 72 84 - **Glob expansion in npm scripts:** `@atproto/lex-cli` needs file paths, not globs. Use `bash -c 'shopt -s globstar && ...'` to expand `**/*.json` in npm scripts. 73 85 - **`.env` loading:** Dev and spike scripts use Node's `--env-file=../../.env` flag to load the root `.env` file. No `dotenv` dependency needed. 74 86
+11 -3
README.md
··· 26 26 27 27 User-generated content (posts, reactions, memberships) lives on each user's PDS. Forum metadata (categories, roles, mod actions) lives on a dedicated Forum Service Account. The AppView indexes both into a unified view. 28 28 29 - ## Packages 29 + ## Apps & Packages 30 30 31 31 This is a [Turborepo](https://turbo.build/) monorepo with [pnpm](https://pnpm.io/) workspaces. 32 32 33 + ### Apps (`apps/`) 34 + 35 + | App | Description | 36 + |-----|-------------| 37 + | [`apps/appview`](apps/appview) | Hono-based JSON API server | 38 + | [`apps/web`](apps/web) | Server-rendered web UI (Hono JSX + HTMX) | 39 + 40 + ### Packages (`packages/`) 41 + 33 42 | Package | Description | 34 43 |---------|-------------| 44 + | [`packages/db`](packages/db) | Drizzle ORM schema and connection factory for PostgreSQL | 35 45 | [`packages/lexicon`](packages/lexicon) | AT Proto lexicon schemas (YAML) and generated TypeScript types | 36 - | [`packages/appview`](packages/appview) | Hono-based JSON API server | 37 - | [`packages/web`](packages/web) | Server-rendered web UI (Hono JSX + HTMX) | 38 46 | [`packages/spike`](packages/spike) | PDS read/write test script | 39 47 40 48 ## Getting Started
+68
apps/appview/src/lib/__tests__/config.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + describe("loadConfig", () => { 4 + const originalEnv = { ...process.env }; 5 + 6 + beforeEach(() => { 7 + vi.resetModules(); 8 + }); 9 + 10 + afterEach(() => { 11 + process.env = { ...originalEnv }; 12 + }); 13 + 14 + async function loadConfig() { 15 + const mod = await import("../config.js"); 16 + return mod.loadConfig(); 17 + } 18 + 19 + it("returns default port 3000 when PORT is undefined", async () => { 20 + delete process.env.PORT; 21 + const config = await loadConfig(); 22 + expect(config.port).toBe(3000); 23 + }); 24 + 25 + it("parses PORT as an integer", async () => { 26 + process.env.PORT = "4000"; 27 + const config = await loadConfig(); 28 + expect(config.port).toBe(4000); 29 + expect(typeof config.port).toBe("number"); 30 + }); 31 + 32 + it("returns default PDS URL when PDS_URL is undefined", async () => { 33 + delete process.env.PDS_URL; 34 + const config = await loadConfig(); 35 + expect(config.pdsUrl).toBe("https://bsky.social"); 36 + }); 37 + 38 + it("uses provided environment variables", async () => { 39 + process.env.PORT = "5000"; 40 + process.env.FORUM_DID = "did:plc:test123"; 41 + process.env.PDS_URL = "https://my-pds.example.com"; 42 + process.env.DATABASE_URL = "postgres://localhost/testdb"; 43 + const config = await loadConfig(); 44 + expect(config.port).toBe(5000); 45 + expect(config.forumDid).toBe("did:plc:test123"); 46 + expect(config.pdsUrl).toBe("https://my-pds.example.com"); 47 + expect(config.databaseUrl).toBe("postgres://localhost/testdb"); 48 + }); 49 + 50 + it("returns empty string for forumDid when FORUM_DID is undefined", async () => { 51 + delete process.env.FORUM_DID; 52 + const config = await loadConfig(); 53 + expect(config.forumDid).toBe(""); 54 + }); 55 + 56 + it("returns empty string for databaseUrl when DATABASE_URL is undefined", async () => { 57 + delete process.env.DATABASE_URL; 58 + const config = await loadConfig(); 59 + expect(config.databaseUrl).toBe(""); 60 + }); 61 + 62 + it("returns NaN for port when PORT is empty string (?? does not catch empty strings)", async () => { 63 + process.env.PORT = ""; 64 + const config = await loadConfig(); 65 + // Documents a gap: ?? only catches null/undefined, not "" 66 + expect(config.port).toBeNaN(); 67 + }); 68 + });
+34
apps/appview/src/routes/__tests__/categories.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("GET /api/categories", () => { 8 + it("returns 200", async () => { 9 + const res = await app.request("/api/categories"); 10 + expect(res.status).toBe(200); 11 + }); 12 + 13 + it("returns an object with a categories array", async () => { 14 + const res = await app.request("/api/categories"); 15 + const body = await res.json(); 16 + expect(body).toHaveProperty("categories"); 17 + expect(Array.isArray(body.categories)).toBe(true); 18 + }); 19 + }); 20 + 21 + describe("GET /api/categories/:id/topics", () => { 22 + it("returns 200", async () => { 23 + const res = await app.request("/api/categories/123/topics"); 24 + expect(res.status).toBe(200); 25 + }); 26 + 27 + it("echoes the category id and returns a topics array", async () => { 28 + const res = await app.request("/api/categories/42/topics"); 29 + const body = await res.json(); 30 + expect(body).toHaveProperty("categoryId", "42"); 31 + expect(body).toHaveProperty("topics"); 32 + expect(Array.isArray(body.topics)).toBe(true); 33 + }); 34 + });
+23
apps/appview/src/routes/__tests__/forum.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("GET /api/forum", () => { 8 + it("returns 200", async () => { 9 + const res = await app.request("/api/forum"); 10 + expect(res.status).toBe(200); 11 + }); 12 + 13 + it("returns forum metadata with expected shape", async () => { 14 + const res = await app.request("/api/forum"); 15 + const body = await res.json(); 16 + expect(body).toHaveProperty("name"); 17 + expect(body).toHaveProperty("description"); 18 + expect(body).toHaveProperty("did"); 19 + expect(typeof body.name).toBe("string"); 20 + expect(typeof body.description).toBe("string"); 21 + expect(typeof body.did).toBe("string"); 22 + }); 23 + });
+28
apps/appview/src/routes/__tests__/health.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("GET /api/healthz", () => { 8 + it("returns 200 with ok status", async () => { 9 + const res = await app.request("/api/healthz"); 10 + expect(res.status).toBe(200); 11 + const body = await res.json(); 12 + expect(body).toEqual({ status: "ok", version: "0.1.0" }); 13 + }); 14 + 15 + it("returns application/json content type", async () => { 16 + const res = await app.request("/api/healthz"); 17 + expect(res.headers.get("content-type")).toContain("application/json"); 18 + }); 19 + }); 20 + 21 + describe("GET /api/healthz/ready", () => { 22 + it("returns 200 with ready status", async () => { 23 + const res = await app.request("/api/healthz/ready"); 24 + expect(res.status).toBe(200); 25 + const body = await res.json(); 26 + expect(body).toEqual({ status: "ready" }); 27 + }); 28 + });
+18
apps/appview/src/routes/__tests__/posts.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("POST /api/posts", () => { 8 + it("returns 501 not implemented", async () => { 9 + const res = await app.request("/api/posts", { method: "POST" }); 10 + expect(res.status).toBe(501); 11 + }); 12 + 13 + it("returns an error message", async () => { 14 + const res = await app.request("/api/posts", { method: "POST" }); 15 + const body = await res.json(); 16 + expect(body).toHaveProperty("error", "not implemented"); 17 + }); 18 + });
+25
apps/appview/src/routes/__tests__/routing.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("API routing", () => { 8 + it("returns 404 for unknown routes", async () => { 9 + const res = await app.request("/api/nonexistent"); 10 + expect(res.status).toBe(404); 11 + }); 12 + 13 + it("mounts all expected route prefixes", async () => { 14 + const routes = [ 15 + "/api/healthz", 16 + "/api/forum", 17 + "/api/categories", 18 + ]; 19 + 20 + for (const route of routes) { 21 + const res = await app.request(route); 22 + expect(res.status, `${route} should be reachable`).not.toBe(404); 23 + } 24 + }); 25 + });
+34
apps/appview/src/routes/__tests__/topics.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { apiRoutes } from "../index.js"; 4 + 5 + const app = new Hono().route("/api", apiRoutes); 6 + 7 + describe("GET /api/topics/:id", () => { 8 + it("returns 200", async () => { 9 + const res = await app.request("/api/topics/abc123"); 10 + expect(res.status).toBe(200); 11 + }); 12 + 13 + it("echoes the topic id and returns expected shape", async () => { 14 + const res = await app.request("/api/topics/abc123"); 15 + const body = await res.json(); 16 + expect(body).toHaveProperty("topicId", "abc123"); 17 + expect(body).toHaveProperty("post"); 18 + expect(body).toHaveProperty("replies"); 19 + expect(Array.isArray(body.replies)).toBe(true); 20 + }); 21 + }); 22 + 23 + describe("POST /api/topics", () => { 24 + it("returns 501 not implemented", async () => { 25 + const res = await app.request("/api/topics", { method: "POST" }); 26 + expect(res.status).toBe(501); 27 + }); 28 + 29 + it("returns an error message", async () => { 30 + const res = await app.request("/api/topics", { method: "POST" }); 31 + const body = await res.json(); 32 + expect(body).toHaveProperty("error", "not implemented"); 33 + }); 34 + });
+7
apps/appview/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "node", 6 + }, 7 + });
+74
apps/web/src/lib/__tests__/api.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("fetchApi", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + async function loadFetchApi() { 19 + const mod = await import("../api.js"); 20 + return mod.fetchApi; 21 + } 22 + 23 + it("calls the correct URL", async () => { 24 + mockFetch.mockResolvedValueOnce({ 25 + ok: true, 26 + json: () => Promise.resolve({ data: "test" }), 27 + }); 28 + 29 + const fetchApi = await loadFetchApi(); 30 + await fetchApi("/categories"); 31 + 32 + expect(mockFetch).toHaveBeenCalledOnce(); 33 + const calledUrl = mockFetch.mock.calls[0][0]; 34 + expect(calledUrl).toBe("http://localhost:3000/api/categories"); 35 + }); 36 + 37 + it("returns parsed JSON on success", async () => { 38 + const expected = { categories: [{ id: 1, name: "General" }] }; 39 + mockFetch.mockResolvedValueOnce({ 40 + ok: true, 41 + json: () => Promise.resolve(expected), 42 + }); 43 + 44 + const fetchApi = await loadFetchApi(); 45 + const result = await fetchApi("/categories"); 46 + expect(result).toEqual(expected); 47 + }); 48 + 49 + it("throws on non-ok response", async () => { 50 + mockFetch.mockResolvedValueOnce({ 51 + ok: false, 52 + status: 500, 53 + statusText: "Internal Server Error", 54 + }); 55 + 56 + const fetchApi = await loadFetchApi(); 57 + await expect(fetchApi("/fail")).rejects.toThrow( 58 + "AppView API error: 500 Internal Server Error" 59 + ); 60 + }); 61 + 62 + it("throws on 404 response", async () => { 63 + mockFetch.mockResolvedValueOnce({ 64 + ok: false, 65 + status: 404, 66 + statusText: "Not Found", 67 + }); 68 + 69 + const fetchApi = await loadFetchApi(); 70 + await expect(fetchApi("/missing")).rejects.toThrow( 71 + "AppView API error: 404 Not Found" 72 + ); 73 + }); 74 + });
+59
apps/web/src/lib/__tests__/config.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + describe("loadConfig", () => { 4 + const originalEnv = { ...process.env }; 5 + 6 + beforeEach(() => { 7 + vi.resetModules(); 8 + }); 9 + 10 + afterEach(() => { 11 + process.env = { ...originalEnv }; 12 + }); 13 + 14 + async function loadConfig() { 15 + const mod = await import("../config.js"); 16 + return mod.loadConfig(); 17 + } 18 + 19 + it("returns default port 3001 when PORT is undefined", async () => { 20 + delete process.env.PORT; 21 + const config = await loadConfig(); 22 + expect(config.port).toBe(3001); 23 + }); 24 + 25 + it("parses PORT as an integer", async () => { 26 + process.env.PORT = "8080"; 27 + const config = await loadConfig(); 28 + expect(config.port).toBe(8080); 29 + expect(typeof config.port).toBe("number"); 30 + }); 31 + 32 + it("returns default appview URL when APPVIEW_URL is undefined", async () => { 33 + delete process.env.APPVIEW_URL; 34 + const config = await loadConfig(); 35 + expect(config.appviewUrl).toBe("http://localhost:3000"); 36 + }); 37 + 38 + it("uses provided environment variables", async () => { 39 + process.env.PORT = "9000"; 40 + process.env.APPVIEW_URL = "https://api.atbb.space"; 41 + const config = await loadConfig(); 42 + expect(config.port).toBe(9000); 43 + expect(config.appviewUrl).toBe("https://api.atbb.space"); 44 + }); 45 + 46 + it("returns NaN for port when PORT is empty string (?? does not catch empty strings)", async () => { 47 + process.env.PORT = ""; 48 + const config = await loadConfig(); 49 + // Documents a gap: ?? only catches null/undefined, not "" 50 + expect(config.port).toBeNaN(); 51 + }); 52 + 53 + it("returns empty string for appviewUrl when APPVIEW_URL is empty string", async () => { 54 + process.env.APPVIEW_URL = ""; 55 + const config = await loadConfig(); 56 + // Documents a gap: ?? only catches null/undefined, not "" 57 + expect(config.appviewUrl).toBe(""); 58 + }); 59 + });
+7
apps/web/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "node", 6 + }, 7 + });
+6 -1
docs/atproto-forum-plan.md
··· 139 139 - [x] Audit existing `space.atbb.*` lexicons — **Result:** 2 existing (`forum.forum`, `post`), 5 new needed for MVP. No separate topic type; unified post model with reply refs. 140 140 - [x] Review prior Rust AppView — **Result:** Axum/SQLx scaffold with jetstream-oxide firehose, minimal DB schema. Reference for route structure and Docker setup; MVP will be Node/TS rewrite. 141 141 - [x] Define new lexicons in YAML: `forum.category`, `forum.role`, `membership`, `reaction`, `modAction` — **Result:** All 5 defined in `packages/lexicon/lexicons/`, with YAML→JSON→TypeScript build pipeline via `@atproto/lex-cli`. 142 - - [x] Set up project scaffolding: monorepo with `packages/lexicon`, `packages/appview`, `packages/web` — **Result:** Turborepo + pnpm workspaces, devenv for Nix toolchain. AppView (Hono JSON API, port 3000), Web (Hono JSX + HTMX, port 3001), plus `packages/spike` for PDS testing. 142 + - [x] Set up project scaffolding: monorepo with `packages/lexicon`, `apps/appview`, `apps/web` — **Result:** Turborepo + pnpm workspaces, devenv for Nix toolchain. AppView (Hono JSON API, port 3000), Web (Hono JSX + HTMX, port 3001), plus `packages/spike` for PDS testing. Apps live in `apps/`, shared libraries in `packages/`. 143 143 - [ ] Create Forum Service Account (generate DID, set up PDS or use existing hosting) 144 144 - [ ] Spike: write a test record to a PDS, read it back via AT Proto API — **Note:** Script written in `packages/spike`, ready to run once PDS credentials are configured in `.env`. 145 145 ··· 207 207 - **Phase C — Federated moderation:** Moderators on different AppView instances (or using third-party tools) can perform mod actions on the Forum DID via delegation, enabling distributed moderation teams. 208 208 - **Phase D — Extract and propose:** Package the delegation spec as a standalone AT Proto proposal with the forum as the reference use case. Contribute upstream. 209 209 210 + ### Mobile Apps (iOS & Android) 211 + 212 + React Native + Expo cross-platform apps consuming the same `/api/*` endpoints as the web UI. Phased rollout: read-only browse → write/interact → push notifications → offline support & app store release. Full plan in [`docs/mobile-apps-plan.md`](mobile-apps-plan.md). 213 + 210 214 ### Other Future Work 211 215 - Nested/threaded replies 212 216 - Full-text search (maybe Meilisearch) ··· 219 223 - Custom themes & branding 220 224 - Plugin/extension system 221 225 - Email notifications 226 + - Push notifications (mobile + web) 222 227 - RSS feeds per category/topic
+312
docs/mobile-apps-plan.md
··· 1 + # atBB Mobile Apps — Plan 2 + 3 + This document outlines the strategy for building mobile applications for atBB. The existing architecture — a clean JSON API (appview) separate from the server-rendered web UI — makes this straightforward. 4 + 5 + --- 6 + 7 + ## Why Mobile Apps? 8 + 9 + The web UI (`@atbb/web`) will be responsive and mobile-friendly, but dedicated apps offer: 10 + 11 + - **Push notifications** for replies, mentions, and moderation events 12 + - **Native performance** — smooth scrolling through long threads, instant navigation 13 + - **Offline reading** — cache threads and categories for subway/airplane use 14 + - **Deep OS integration** — share sheets, AT Proto URI handling, biometric auth 15 + - **Better compose experience** — native keyboard handling, image picker, draft persistence 16 + 17 + The web app remains the primary interface. Mobile apps are a complement, not a replacement. 18 + 19 + --- 20 + 21 + ## Architecture Fit 22 + 23 + The existing system already separates concerns in a way that supports mobile clients: 24 + 25 + ``` 26 + ┌─────────────┐ 27 + │ Forum UI │──────┐ 28 + │ (Web App) │ │ 29 + └─────────────┘ │ ┌──────────────┐ ┌─────────────────┐ 30 + ├────▶│ AppView │────▶│ Firehose / │ 31 + ┌─────────────┐ │ │ (JSON API) │◀────│ User PDS nodes │ 32 + │ Mobile Apps │──────┘ └──────────────┘ └─────────────────┘ 33 + │ (iOS/Andrd) │ 34 + └─────────────┘ 35 + ``` 36 + 37 + Mobile apps consume the **same `/api/*` endpoints** as the web UI. No new backend is needed — just the existing appview. 38 + 39 + ### What the appview already provides 40 + 41 + | Endpoint | Purpose | Mobile use | 42 + |---|---|---| 43 + | `GET /api/forum` | Forum metadata | App title, description, branding | 44 + | `GET /api/categories` | Category list | Home screen / tab bar | 45 + | `GET /api/categories/:id/topics` | Topic list (paginated) | Category view with pull-to-refresh | 46 + | `GET /api/topics/:id` | Thread (OP + replies) | Thread view | 47 + | `POST /api/topics` | Create topic | Compose screen | 48 + | `POST /api/posts` | Create reply | Reply sheet | 49 + 50 + ### What needs to be added to the appview for mobile 51 + 52 + | Endpoint / Feature | Purpose | 53 + |---|---| 54 + | `GET /api/users/:did` | User profile / post history | 55 + | `GET /api/notifications` | Notification feed (replies to your posts, mentions, mod actions) | 56 + | `POST /api/reactions` | Add reaction to a post (requires `reactions` table + `space.atbb.reaction` lexicon) | 57 + | `DELETE /api/reactions/:id` | Remove reaction from a post | 58 + | `POST /api/devices` | Register push notification token (APNs / FCM) | 59 + | `DELETE /api/devices/:id` | Unregister push token | 60 + | Pagination headers / cursors | Consistent cursor-based pagination across all list endpoints | 61 + | `ETag` / `Last-Modified` headers | Conditional requests for efficient caching | 62 + 63 + These additions benefit the web UI too — they aren't mobile-only concerns. 64 + 65 + --- 66 + 67 + ## Technology Choice: React Native + Expo 68 + 69 + **Recommendation:** React Native with Expo for cross-platform iOS and Android from a single codebase. 70 + 71 + ### Why React Native + Expo 72 + 73 + - **Single codebase** for iOS and Android — critical for a small team / solo developer 74 + - **TypeScript** — same language as the rest of the monorepo; can share types from `@atbb/lexicon` 75 + - **Expo** simplifies builds, OTA updates, push notifications, and app store submissions 76 + - **Mature ecosystem** — navigation (React Navigation / Expo Router), state management, networking 77 + - **AT Proto libraries work** — `@atproto/api` runs in React Native with minor polyfills 78 + - **AGPL-3.0 compatible** — React Native's MIT license is compatible with the project license 79 + 80 + ### Why not other options 81 + 82 + | Option | Reason to skip | 83 + |---|---| 84 + | Flutter | Dart — different language from the rest of the stack, can't share types | 85 + | Native (Swift/Kotlin) | Two codebases to maintain, slower iteration for a small team | 86 + | PWA only | iOS Web Push requires add-to-home-screen with constrained UX, no app store presence, weaker offline | 87 + | Capacitor/Ionic | WebView wrapper — won't feel native, performance ceiling | 88 + 89 + ### Monorepo integration 90 + 91 + Add a new package to the existing workspace: 92 + 93 + ``` 94 + packages/ 95 + lexicon/ # shared types (already exists) 96 + appview/ # JSON API (already exists) 97 + web/ # server-rendered UI (already exists) 98 + mobile/ # NEW — React Native + Expo app 99 + ``` 100 + 101 + The mobile package imports `@atbb/lexicon` for type safety against AT Protocol records at dev/typecheck time (ensuring the mobile app stays in sync with lexicon changes). However, the actual app builds via Expo's Metro bundler (`expo start`, `eas build`), not via `pnpm build` — Turborepo handles the `lexicon` → `appview`/`web` build chain, but mobile has its own separate build tooling. 102 + 103 + --- 104 + 105 + ## Mobile App Structure 106 + 107 + ### Screens 108 + 109 + | Screen | Description | API | 110 + |---|---|---| 111 + | **Login** | AT Proto OAuth flow via in-app browser (exchanges tokens with user's PDS) | `@atproto/oauth-client` | 112 + | **Home** | Category list, forum branding | `GET /api/categories` | 113 + | **Category** | Topic list with pull-to-refresh, infinite scroll | `GET /api/categories/:id/topics` | 114 + | **Topic/Thread** | OP + flat replies, pagination | `GET /api/topics/:id` | 115 + | **Compose** | New topic form (select category, write post) | `POST /api/topics` | 116 + | **Reply** | Reply sheet (bottom sheet or modal) | `POST /api/posts` | 117 + | **Notifications** | Reply/mention/mod action feed | `GET /api/notifications` | 118 + | **Profile** | User info, post history | `GET /api/users/:did` | 119 + | **Settings** | Push notification prefs, theme, logout | Local + `/api/devices` | 120 + 121 + ### Navigation 122 + 123 + ``` 124 + Tab Bar 125 + ├── Home (categories → topics → thread) 126 + ├── Notifications 127 + └── Profile / Settings 128 + ``` 129 + 130 + Use Expo Router (file-based routing) or React Navigation with a bottom tab + stack pattern. 131 + 132 + ### Key Libraries 133 + 134 + | Concern | Library | 135 + |---|---| 136 + | Navigation | Expo Router or React Navigation | 137 + | HTTP client | Standard `fetch` or `ky` (lightweight) | 138 + | State / cache | TanStack Query (React Query) — handles caching, pagination, background refetch | 139 + | Push notifications | `expo-notifications` + server-side APNs/FCM | 140 + | Secure storage | `expo-secure-store` (for auth tokens) | 141 + | AT Proto OAuth | `@atproto/oauth-client` (client-side OAuth + DPoP) + `expo-auth-session` (in-app browser) | 142 + | Offline storage | SQLite via `expo-sqlite` (cache threads for offline reading) | 143 + 144 + --- 145 + 146 + ## Authentication on Mobile 147 + 148 + AT Proto OAuth on mobile follows the standard OAuth 2.0 + PKCE + DPoP flow for native apps: 149 + 150 + 1. User enters their handle or PDS URL 151 + 2. App resolves the user's PDS and authorization server from the user's DID document 152 + 3. App generates a DPoP key pair (stored in secure enclave/keystore) and creates a PKCE challenge 153 + 4. App opens an in-app browser (ASWebAuthenticationSession on iOS, Custom Tab on Android) to the authorization URL 154 + 5. User authenticates on their PDS 155 + 6. PDS redirects back to the app via a custom URI scheme (`atbb://oauth/callback`) or universal link 156 + 7. **App exchanges the authorization code directly with the user's PDS authorization server** (not via the appview) to obtain access/refresh tokens 157 + 8. Tokens stored in `expo-secure-store` (keychain on iOS, keystore on Android) 158 + 9. Subsequent API calls to the appview include a DPoP-bound bearer token 159 + 10. **The appview validates tokens against the user's DID document** — it doesn't broker authentication 160 + 161 + This preserves AT Proto's decentralized model: users authenticate with their own PDS, then present credentials to the appview. The mobile app needs to implement AT Proto OAuth client logic directly (the `@atproto/oauth-client` library can help, though mobile support is still maturing). 162 + 163 + ### DPoP Key Management on Mobile 164 + 165 + AT Proto uses DPoP (Demonstrating Proof of Possession) to bind access tokens to a specific client key, preventing token theft/replay attacks. On mobile, this requires: 166 + 167 + - **Secure key storage:** The DPoP private key must be stored in platform secure storage — iOS Keychain (accessed via Secure Enclave on supported devices) or Android Keystore. Use `expo-secure-store` or platform-specific crypto APIs. 168 + - **Key lifecycle:** Generate a new DPoP key pair on first login. The key should persist across app sessions but be revoked/regenerated on logout or token refresh failure. 169 + - **Proof generation:** For each API request, generate a DPoP proof (signed JWT) using the private key. The `@atproto/oauth-client` library handles this, but mobile-specific integration with secure storage may require custom bindings. 170 + 171 + This is a mobile-specific concern that doesn't exist in the web UI (where DPoP keys can be ephemeral or stored in localStorage for less-critical use cases). 172 + 173 + --- 174 + 175 + ## Push Notifications 176 + 177 + ### Architecture 178 + 179 + ``` 180 + User posts a reply 181 + 182 + 183 + Firehose event 184 + 185 + 186 + AppView indexes reply 187 + 188 + 189 + Notification service checks: 190 + "Who should be notified?" 191 + 192 + 193 + Sends push via APNs (iOS) 194 + and/or FCM (Android) 195 + 196 + 197 + Mobile device shows notification 198 + ``` 199 + 200 + ### Implementation 201 + 202 + - Mobile app registers its push token with `POST /api/devices` on login 203 + - Appview maintains a `devices` table: `(id, user_did, platform, push_token, created_at)` — **this is a purely local/appview-managed table** (not backed by an AT Proto record), unlike the `(did, rkey, cid, indexed_at)` pattern used for AT Proto record tables 204 + - When the indexer processes a new post that is a reply, it checks if the parent post's author or thread participants have registered devices 205 + - A lightweight push service (can be part of the appview or a separate worker) sends the notification payload 206 + - Start simple: notify on direct replies only. Expand to mentions, mod actions, thread subscriptions later 207 + 208 + ### Notification types (phased) 209 + 210 + | Phase | Notification | 211 + |---|---| 212 + | Initial | Direct reply to your post | 213 + | Later | @mention in a post | 214 + | Later | Mod action on your post (locked, deleted) | 215 + | Later | New topic in a subscribed category | 216 + | Later | Thread subscription (get notified on any reply in a thread) | 217 + 218 + --- 219 + 220 + ## Offline Support 221 + 222 + Use a layered caching strategy: 223 + 224 + 1. **HTTP cache** — TanStack Query caches API responses in memory with configurable stale times 225 + 2. **Persistent cache** — TanStack Query's `persistQueryClient` with AsyncStorage or SQLite for across-app-restart caching 226 + 3. **Explicit offline mode** — "Save thread for offline" downloads thread data to SQLite; viewable without network 227 + 4. **Optimistic writes** — compose a reply offline, queue it, send when back online 228 + 229 + MVP mobile app only needs layer 1 (in-memory cache). Layers 2-4 come later. 230 + 231 + --- 232 + 233 + ## Implementation Phases 234 + 235 + ### Mobile Phase 0: Scaffold (after web MVP Phase 4+) 236 + 237 + Prerequisites: Appview API is stable with read endpoints working, AT Proto OAuth is implemented. 238 + 239 + - [ ] Add `packages/mobile` with Expo + TypeScript template 240 + - [ ] Configure workspace — `@atbb/lexicon` as dependency for shared types 241 + - [ ] Set up Expo Router navigation structure (tabs + stacks) 242 + - [ ] Implement API client layer using `fetch` + TanStack Query 243 + - [ ] Create basic UI shell: tab bar, placeholder screens 244 + 245 + ### Mobile Phase 1: Read-Only Browse 246 + 247 + - [ ] Login screen — AT Proto OAuth client (`@atproto/oauth-client` + `expo-auth-session`) with direct PDS token exchange 248 + - [ ] Home screen — category list from `GET /api/categories` 249 + - [ ] Category screen — topic list with pull-to-refresh and infinite scroll 250 + - [ ] Thread screen — OP + flat replies with pagination 251 + - [ ] Basic theming (light/dark mode following system preference) 252 + - [ ] Loading states, error states, empty states 253 + 254 + ### Mobile Phase 2: Write & Interact 255 + 256 + - [ ] Compose screen — create new topic (select category, write text) 257 + - [ ] Reply sheet — reply to a post (bottom sheet UX) 258 + - [ ] Reactions — tap to react on posts 259 + - [ ] Profile screen — view your own posts and membership info 260 + - [ ] Pull-to-refresh and background data sync 261 + 262 + ### Mobile Phase 3: Notifications & Polish 263 + 264 + - [ ] Push notification registration (`expo-notifications` + APNs/FCM) 265 + - [ ] Notification feed screen 266 + - [ ] Appview: `GET /api/notifications` endpoint + push delivery worker 267 + - [ ] Appview: `POST /api/devices` and `DELETE /api/devices/:id` endpoints 268 + - [ ] Deep linking — tap notification to open relevant thread 269 + - [ ] Universal links / custom URI scheme for `at://` URIs 270 + 271 + ### Mobile Phase 4: Offline & Release 272 + 273 + - [ ] Persistent query cache (survive app restarts) 274 + - [ ] Offline thread reading (SQLite cache) 275 + - [ ] Optimistic reply queueing 276 + - [ ] App store assets (icon, screenshots, descriptions) 277 + - [ ] TestFlight (iOS) and internal testing track (Android) release 278 + - [ ] App Store and Google Play submission 279 + 280 + --- 281 + 282 + ## API Stability & Compatibility 283 + 284 + Mobile clients can't be force-updated instantly, so API stability matters: 285 + 286 + - **Additive changes only** — new fields are always optional, never remove existing fields 287 + - **Client version header** — mobile app can send `X-ATBB-Client-Version: 1.2.0`; appview can respond with upgrade-required if the client is too old 288 + - **API versioning** (e.g., `/api/v1/*`) can be introduced later when there's an actual breaking change to warrant it — deferring until post-v1 avoids unnecessary complexity while the API is still evolving 289 + 290 + --- 291 + 292 + ## Estimated Effort 293 + 294 + | Phase | Scope | Notes | 295 + |---|---|---| 296 + | Phase 0 | Scaffold + navigation shell | Straightforward Expo setup | 297 + | Phase 1 | Read-only browsing + auth | Bulk of the mobile work — screens, auth flow, caching | 298 + | Phase 2 | Write path + interactions | Compose UX, reply sheets, reactions | 299 + | Phase 3 | Push notifications | Requires appview additions (notification service, device registration) | 300 + | Phase 4 | Offline + app store release | Polish, testing, store submission process | 301 + 302 + Each phase can be developed and shipped independently. Phase 1 alone is a useful read-only companion app. 303 + 304 + --- 305 + 306 + ## Open Questions 307 + 308 + 1. **Expo vs bare React Native?** Start with Expo managed workflow for speed. Eject to bare only if a native module requires it (unlikely for a forum app). 309 + 2. **Code sharing between web and mobile?** The web (server-rendered hypermedia with Hono JSX + HTMX) and mobile (client-side React Native SPA) are fundamentally different paradigms. Component sharing is unlikely to be practical regardless of future web tech choices. Best to share types from `@atbb/lexicon` and API contracts, not UI components. 310 + 3. **Moderation UI on mobile?** Admins/mods may want to moderate from their phone. This could be a separate "admin" tab that appears based on role, or deferred to web-only initially. 311 + 4. **Multiple forum support?** The app could support connecting to multiple atBB instances (different appview URLs). This aligns with the decentralized nature of AT Protocol but adds complexity. Defer to post-v1. 312 + 5. **AT Proto OAuth on mobile maturity?** The OAuth spec for AT Protocol is still evolving. Verify the state of native app support (PKCE, custom URI schemes) and the `@atproto/oauth-client` library's mobile compatibility before starting implementation. DPoP key management specifics covered in Authentication section above.
+22 -22
docs/plans/2026-02-06-database-schema-design.md
··· 13 13 ### Task 1: Install Drizzle dependencies 14 14 15 15 **Files:** 16 - - Modify: `packages/appview/package.json` 16 + - Modify: `apps/appview/package.json` 17 17 18 18 **Step 1: Add runtime dependencies** 19 19 ··· 46 46 **Step 4: Commit** 47 47 48 48 ```bash 49 - git add packages/appview/package.json pnpm-lock.yaml 49 + git add apps/appview/package.json pnpm-lock.yaml 50 50 git commit -m "feat(appview): add drizzle-orm and postgres dependencies" 51 51 ``` 52 52 ··· 55 55 ### Task 2: Define the database schema 56 56 57 57 **Files:** 58 - - Create: `packages/appview/src/db/schema.ts` 58 + - Create: `apps/appview/src/db/schema.ts` 59 59 60 60 **Step 1: Create the schema file** 61 61 62 - Create `packages/appview/src/db/schema.ts` with the full schema: 62 + Create `apps/appview/src/db/schema.ts` with the full schema: 63 63 64 64 ```typescript 65 65 import { ··· 223 223 **Step 3: Commit** 224 224 225 225 ```bash 226 - git add packages/appview/src/db/schema.ts 226 + git add apps/appview/src/db/schema.ts 227 227 git commit -m "feat(appview): define database schema for all 6 tables" 228 228 ``` 229 229 ··· 232 232 ### Task 3: Create the database connection module 233 233 234 234 **Files:** 235 - - Create: `packages/appview/src/db/index.ts` 236 - - Modify: `packages/appview/src/lib/config.ts` 235 + - Create: `apps/appview/src/db/index.ts` 236 + - Modify: `apps/appview/src/lib/config.ts` 237 237 238 238 **Step 1: Add DATABASE_URL to config** 239 239 240 - Modify `packages/appview/src/lib/config.ts` to add `databaseUrl`: 240 + Modify `apps/appview/src/lib/config.ts` to add `databaseUrl`: 241 241 242 242 ```typescript 243 243 export interface AppConfig { ··· 259 259 260 260 **Step 2: Create the database connection module** 261 261 262 - Create `packages/appview/src/db/index.ts`: 262 + Create `apps/appview/src/db/index.ts`: 263 263 264 264 ```typescript 265 265 import { drizzle } from "drizzle-orm/postgres-js"; ··· 287 287 **Step 4: Commit** 288 288 289 289 ```bash 290 - git add packages/appview/src/db/index.ts packages/appview/src/lib/config.ts 290 + git add apps/appview/src/db/index.ts apps/appview/src/lib/config.ts 291 291 git commit -m "feat(appview): add database connection module and DATABASE_URL config" 292 292 ``` 293 293 ··· 296 296 ### Task 4: Configure drizzle-kit and generate migrations 297 297 298 298 **Files:** 299 - - Create: `packages/appview/drizzle.config.ts` 299 + - Create: `apps/appview/drizzle.config.ts` 300 300 301 301 **Step 1: Create drizzle-kit config** 302 302 303 - Create `packages/appview/drizzle.config.ts`: 303 + Create `apps/appview/drizzle.config.ts`: 304 304 305 305 ```typescript 306 306 import { defineConfig } from "drizzle-kit"; ··· 314 314 315 315 **Step 2: Add migration scripts to package.json** 316 316 317 - Add to `packages/appview/package.json` scripts: 317 + Add to `apps/appview/package.json` scripts: 318 318 319 319 ```json 320 320 { ··· 331 331 pnpm --filter @atbb/appview db:generate 332 332 ``` 333 333 334 - Expected: a migration SQL file appears in `packages/appview/drizzle/` with CREATE TABLE statements for all 6 tables. 334 + Expected: a migration SQL file appears in `apps/appview/drizzle/` with CREATE TABLE statements for all 6 tables. 335 335 336 336 **Step 4: Inspect the generated SQL** 337 337 338 - Read the generated `.sql` file in `packages/appview/drizzle/` and verify it contains: 338 + Read the generated `.sql` file in `apps/appview/drizzle/` and verify it contains: 339 339 - 6 CREATE TABLE statements (forums, categories, users, memberships, posts, mod_actions) 340 340 - All UNIQUE indexes on (did, rkey) 341 341 - All additional indexes (posts.forum_uri, posts.root_post_id, memberships.did) ··· 344 344 **Step 5: Commit** 345 345 346 346 ```bash 347 - git add packages/appview/drizzle.config.ts packages/appview/drizzle/ packages/appview/package.json 347 + git add apps/appview/drizzle.config.ts apps/appview/drizzle/ apps/appview/package.json 348 348 git commit -m "feat(appview): add drizzle-kit config and generate initial migration" 349 349 ``` 350 350 ··· 446 446 447 447 | Action | File | 448 448 |--------|------| 449 - | Modify | `packages/appview/package.json` (deps + scripts) | 450 - | Create | `packages/appview/src/db/schema.ts` | 451 - | Create | `packages/appview/src/db/index.ts` | 452 - | Modify | `packages/appview/src/lib/config.ts` | 453 - | Create | `packages/appview/drizzle.config.ts` | 454 - | Create | `packages/appview/drizzle/*.sql` (generated) | 449 + | Modify | `apps/appview/package.json` (deps + scripts) | 450 + | Create | `apps/appview/src/db/schema.ts` | 451 + | Create | `apps/appview/src/db/index.ts` | 452 + | Modify | `apps/appview/src/lib/config.ts` | 453 + | Create | `apps/appview/drizzle.config.ts` | 454 + | Create | `apps/appview/drizzle/*.sql` (generated) | 455 455 | Modify | `.env.example` | 456 456 | Create | `docs/plans/2026-02-06-database-schema-design.md` |
+468
docs/research/phpbb-research.md
··· 1 + # phpBB Research: Features, Complaints & Lessons for atBB 2 + 3 + *Research conducted 2026-02-07* 4 + 5 + --- 6 + 7 + ## Table of Contents 8 + 9 + 1. [phpBB Overview & History](#phpbb-overview--history) 10 + 2. [Complete Feature Inventory](#complete-feature-inventory) 11 + 3. [Common Complaints & Pain Points](#common-complaints--pain-points) 12 + 4. [Why Traditional Forums Declined](#why-traditional-forums-declined) 13 + 5. [Lessons from Modern Competitors](#lessons-from-modern-competitors) 14 + 6. [Gap Analysis: atBB vs phpBB](#gap-analysis-atbb-vs-phpbb) 15 + 7. [Recommendations for atBB](#recommendations-for-atbb) 16 + 17 + --- 18 + 19 + ## phpBB Overview & History 20 + 21 + phpBB (PHP Bulletin Board) is the most widely deployed open-source forum software in history, first released in 2000. It's written in PHP and available under the GPL. 22 + 23 + ### Version History 24 + 25 + | Version | Codename | Released | Key Changes | 26 + |---------|----------|----------|-------------| 27 + | 1.x | — | ~2000 | Original release | 28 + | 2.x | — | ~2002–2008 | Made phpBB mainstream | 29 + | 3.0 | Olympus | Dec 2007 | Complete rewrite, prosilver theme, security audit | 30 + | 3.1 | Ascraeus | Oct 2014 | Symfony adoption, extension system, AJAX | 31 + | 3.2 | Rhea | Jan 2017 | PHP 7 support, emoji, Font Awesome, new installer | 32 + | 3.3 | Proteus | Jan 2020 | PHP 7.4/8.x, extended emoji, MySQL 8 (current stable) | 33 + | 4.0 | Triton | Alpha Sep 2025 | Symfony 6.4, PHP 8.1+, @mentions, Cloudflare Turnstile | 34 + 35 + ### Architecture 36 + 37 + - **Backend:** PHP on Symfony (since 3.1), custom template engine 38 + - **Database:** Supports MySQL/MariaDB, PostgreSQL, SQLite, MSSQL, Oracle 39 + - **Frontend:** Server-rendered HTML with prosilver theme (HTML5/CSS3), minimal JS 40 + - **Extension system:** Self-contained extensions installed via Admin Control Panel 41 + - **Search:** Multiple backends — native full-text index, MySQL FULLTEXT, PostgreSQL FULLTEXT, Sphinx 42 + 43 + --- 44 + 45 + ## Complete Feature Inventory 46 + 47 + ### 1. Forum Structure & Organization 48 + 49 + | Feature | Details | atBB Status | 50 + |---------|---------|-------------| 51 + | **Hierarchical categories** | Unlimited nesting of categories containing forums | Partial — `forum.category` is flat (single level) | 52 + | **Unlimited subforums** | Forums can contain subforums to any depth | Missing — no subforum concept | 53 + | **Password-protected forums** | Individual forums can require a password | Missing | 54 + | **Forum-specific styles** | Different visual themes per forum | Missing | 55 + | **Forum links** | A "forum" entry that links to an external URL | Missing | 56 + | **Forum rules display** | Per-forum or global rules shown at top | Missing | 57 + | **Topic pruning** | Auto-delete old/inactive topics by configurable criteria | Missing | 58 + | **Active topics display** | Most active topics shown at top of forum | Missing | 59 + 60 + ### 2. Topic & Post Features 61 + 62 + | Feature | Details | atBB Status | 63 + |---------|---------|-------------| 64 + | **Topic types: Normal** | Standard topic | Implemented (post without reply ref) | 65 + | **Topic types: Sticky** | Pinned to top of first page | Partial — `pin` mod action exists but no display logic | 66 + | **Topic types: Announcement** | Pinned above stickies, shown on all pages | Missing | 67 + | **Topic types: Global** | Shown at top of every forum on the board | Missing | 68 + | **Time-limited sticky/announcement** | Auto-revert to normal after N days | Missing | 69 + | **Topic subscriptions** | Email/notification on new replies | Missing | 70 + | **Forum subscriptions** | Email/notification on new topics in forum | Missing | 71 + | **Topic bookmarks** | Users save topics for quick access | Missing | 72 + | **Post ordering** | Sort by date, alphabetical, reply count | Missing | 73 + | **"My posts" indicator** | Mark topics where user has posted | Missing | 74 + | **Printer-friendly view** | Clean print layout for topics | Missing | 75 + | **Email a topic** | Send topic link to friend | Missing | 76 + | **Unread tracking** | Track which topics/posts user has read, even across sessions | Missing | 77 + | **Topic split** | Moderator splits a topic into two separate topics | Missing | 78 + | **Topic merge** | Moderator merges two topics into one | Missing | 79 + | **Topic move** | Move topic to different forum (with shadow link) | Missing | 80 + | **Topic lock** | Prevent further replies | Partial — `lock` mod action exists | 81 + | **Post lock** | Lock individual posts from editing | Missing | 82 + | **Topic copy** | Duplicate a topic to another forum | Missing | 83 + 84 + ### 3. Post Composition 85 + 86 + | Feature | Details | atBB Status | 87 + |---------|---------|-------------| 88 + | **BBCode formatting** | Bold, italic, lists, links, images, etc. | Missing — plain text only (300 grapheme limit) | 89 + | **Rich text / Markdown** | Formatted text in posts | Missing (noted as future work) | 90 + | **Smilies / Emoticons** | Inline emoji/emoticon insertion | Missing | 91 + | **Post preview** | Preview post before submitting | Missing | 92 + | **File attachments** | Upload files attached to posts | Missing (noted as future work) | 93 + | **Inline attachments** | Place attachments within post body with BBCode | Missing | 94 + | **Polls** | Create polls with multiple options, voting, time limits | Missing | 95 + | **Topic icons** | Visual icon next to topic title | Missing | 96 + | **Drafts** | Save unfinished posts for later | Missing | 97 + | **Post editing** | Edit your own posts after submission | Architecturally feasible (AT Proto supports updates via `putRecord` — CID changes, firehose emits update event) | 98 + | **Post edit history** | Track edit reasons and history | Missing | 99 + | **Word censoring** | Auto-replace banned words | Missing | 100 + | **Custom BBCodes** | Admin-defined formatting codes | Missing | 101 + | **@Mentions** | Mention and notify other users (phpBB 4.0 feature) | Missing | 102 + | **Direct media playback** | Play video/audio in post body (phpBB 4.0) | Missing | 103 + 104 + ### 4. User Profiles & Identity 105 + 106 + | Feature | Details | atBB Status | 107 + |---------|---------|-------------| 108 + | **User registration** | Account creation flow | Planned — AT Proto OAuth | 109 + | **Social login** | Google, Facebook, etc. | N/A — AT Proto identity replaces this | 110 + | **Avatars** | Gallery, uploaded, remote URL | Missing — could pull from AT Proto profile | 111 + | **Signatures** | Text/images appended to every post | Missing | 112 + | **Custom profile fields** | Admin-defined extra fields (location, website, etc.) | Missing — AT Proto profiles have some | 113 + | **User ranks** | Title based on post count or special assignment | Missing | 114 + | **User badges** | Visual indicators (verified, special roles) | Missing | 115 + | **Post count display** | Show post count next to username | Missing | 116 + | **Join date display** | Show when user joined | Partial — `membership.joinedAt` exists | 117 + | **User online status** | Show if user is currently online | Missing | 118 + | **User profile page** | Dedicated page showing user info, recent posts | Missing (noted as future work) | 119 + | **Friends & Foes list** | Add users to friends/ignore list | Missing | 120 + | **User post history** | View all posts by a user | Missing (noted as future work) | 121 + 122 + ### 5. Private Messaging 123 + 124 + | Feature | Details | atBB Status | 125 + |---------|---------|-------------| 126 + | **Direct messages** | Private messages between users | Missing entirely | 127 + | **Group messaging** | PM multiple users or groups | Missing | 128 + | **BCC support** | Blind carbon copy on PMs | Missing | 129 + | **PM attachments** | Attach files to PMs | Missing | 130 + | **PM drafts** | Save PM drafts | Missing | 131 + | **PM export** | Export/archive PMs | Missing | 132 + | **PM reply tracking** | Chronological PM thread history | Missing | 133 + | **PM notifications** | Notify on new PMs | Missing | 134 + 135 + **Note:** Private messaging may not be in scope for atBB since AT Proto has its own direct messaging infrastructure (Bluesky DMs via `chat.bsky.*` lexicons). This could be an intentional omission — users already have a DM channel through their AT Proto identity. 136 + 137 + ### 6. Moderation Tools 138 + 139 + | Feature | Details | atBB Status | 140 + |---------|---------|-------------| 141 + | **Ban by username** | Ban specific user | Partial — `ban` mod action exists | 142 + | **Ban by IP** | Block IP address | Missing — not applicable (AT Proto is DID-based) | 143 + | **Ban by email** | Block email address | Missing — not applicable | 144 + | **Time-limited bans** | Temporary bans with auto-expiry | Partial — `expiresAt` field exists on modAction | 145 + | **Warning system** | Issue warnings before bans | Missing | 146 + | **Warning auto-escalation** | Auto-ban after N warnings | Missing | 147 + | **Moderation queue** | Pre-approval for new users' posts | Missing | 148 + | **Post approval workflow** | Require mod approval before publish | Missing | 149 + | **Post reporting** | Users flag posts for mod review | Missing | 150 + | **Report reasons** | Configurable list of report reasons | Missing | 151 + | **Moderator notes** | Private notes on users visible to mods | Missing | 152 + | **Topic split/merge/move** | Reorganize discussions | Missing | 153 + | **Shadow topics** | Leave redirect link when moving topics | Missing | 154 + | **Edit/delete any post** | Moderator override on post editing | Partial — `delete` mod action hides from index | 155 + | **Change post author** | Reassign post ownership | Missing — not possible with AT Proto | 156 + | **Moderator log** | Audit trail of all mod actions | Missing | 157 + | **Admin log** | Audit trail of admin actions | Missing | 158 + | **User action log** | Track user behavior (IP logs, etc.) | Missing | 159 + 160 + ### 7. Permission System 161 + 162 + | Feature | Details | atBB Status | 163 + |---------|---------|-------------| 164 + | **Granular ACL** | YES/NO/NEVER per-permission per-user/group | Simplified — role hierarchy only | 165 + | **Permission types** | f_* (forum), m_* (mod), u_* (user), a_* (admin) | Simplified — `forum.role` with permission tokens | 166 + | **Role-based permissions** | Pre-defined permission bundles | Partial — role definitions exist | 167 + | **Per-forum permissions** | Different permissions per forum | Missing | 168 + | **Per-user overrides** | Override group permissions for individuals | Missing | 169 + | **Group-based permissions** | Assign permissions to groups, users inherit | Simplified — single role per membership | 170 + | **"Newly registered" group** | Restricted permissions for new users | Missing | 171 + | **Global vs local permissions** | Board-wide vs per-forum scoping | Missing | 172 + 173 + **Note:** phpBB's permission system is arguably over-engineered and a frequent source of admin confusion. atBB's simpler model (role hierarchy: Owner → Admin → Moderator → Member → Guest) is actually a feature, not a bug. The key missing piece is **per-category permissions** — the ability to restrict certain categories to certain roles. 174 + 175 + ### 8. Search 176 + 177 + | Feature | Details | atBB Status | 178 + |---------|---------|-------------| 179 + | **Full-text search** | Search post content | Missing (noted as future work — Meilisearch) | 180 + | **Multiple search backends** | Native, MySQL FULLTEXT, PostgreSQL, Sphinx | Missing | 181 + | **Search by author** | Find posts by specific user | Missing | 182 + | **Search by forum** | Scope search to specific forum/category | Missing | 183 + | **Search by date range** | Filter results by post date | Missing | 184 + | **Unanswered topics** | Find topics with no replies | Missing | 185 + | **Active topics** | Find topics with recent activity | Missing | 186 + | **"New posts since last visit"** | Show unread content | Missing | 187 + 188 + ### 9. Notifications & Feeds 189 + 190 + | Feature | Details | atBB Status | 191 + |---------|---------|-------------| 192 + | **Email notifications** | Notify on replies, PMs, etc. | Missing (noted as future work) | 193 + | **Jabber/XMPP notifications** | IM-based alerts | Missing | 194 + | **ATOM feeds** | Board-wide, per-forum, per-topic feeds | Missing (noted as future work — RSS) | 195 + | **In-app notifications** | On-site notification system | Missing | 196 + | **Digest emails** | Periodic summary emails | Missing | 197 + 198 + ### 10. Administration 199 + 200 + | Feature | Details | atBB Status | 201 + |---------|---------|-------------| 202 + | **Admin Control Panel** | Comprehensive web-based admin UI | Missing — admin panel is Phase 4 | 203 + | **Board statistics** | User count, post count, activity graphs | Missing | 204 + | **Database backup/restore** | Built-in DB management | Missing | 205 + | **Extension management** | Install/enable/disable extensions from UI | Missing | 206 + | **Style/theme management** | Upload and switch themes | Missing | 207 + | **Language packs** | Multi-language support | Missing | 208 + | **User management** | Search, edit, ban, delete users | Missing | 209 + | **Group management** | Create/edit groups, assign members | Partial — roles exist | 210 + | **Mass email** | Send email to all users or groups | Missing | 211 + | **Forum pruning** | Auto-cleanup old content | Missing | 212 + | **Cron/scheduled tasks** | Background maintenance jobs | Missing | 213 + | **Board configuration** | Extensive settings (registration, posting, etc.) | Missing | 214 + 215 + ### 11. Anti-Spam & Security 216 + 217 + | Feature | Details | atBB Status | 218 + |---------|---------|-------------| 219 + | **CAPTCHA** | Multiple CAPTCHA options (visual, reCAPTCHA) | N/A — AT Proto OAuth handles registration | 220 + | **Cloudflare Turnstile** | Modern anti-bot (phpBB 4.0) | N/A | 221 + | **Two-factor authentication** | 2FA for admins/mods | N/A — delegated to AT Proto identity | 222 + | **IP logging** | Track IPs per post | N/A — AT Proto is DID-based | 223 + | **Flood control** | Rate limiting on posts | Missing but important | 224 + | **New user restrictions** | Limited permissions for new accounts | Missing — could use Discourse-style trust levels | 225 + | **Word censoring** | Auto-filter content | Missing | 226 + | **Post approval queue** | Pre-moderate new user content | Missing | 227 + 228 + --- 229 + 230 + ## Common Complaints & Pain Points 231 + 232 + These are the most frequent criticisms of phpBB that atBB should learn from and avoid: 233 + 234 + ### 1. Spam Bots (Critical) 235 + phpBB is notorious for spam. Within days of installing a phpBB forum, admins report hundreds of bot registrations. The default anti-spam measures are insufficient. **atBB advantage:** AT Proto OAuth means users must have a real AT Proto identity (a PDS account), which is a significant barrier to spam bots. This is one of atBB's biggest inherent strengths. 236 + 237 + ### 2. Outdated User Interface 238 + phpBB's default prosilver theme looks dated. The Admin Control Panel is considered user-unfriendly. Modern users expect mobile-first, clean design. **atBB lesson:** The HTMX + server-rendered approach is good, but invest in modern CSS and responsive design from the start. Don't let the UI become an afterthought. 239 + 240 + ### 3. Difficult Extension/Theme Customization 241 + phpBB's old MOD system required editing core files, making upgrades painful. Even the newer extension system is considered harder to work with than competitors. **atBB lesson:** If an extension/plugin system is ever built, design it as a first-class API from day one. The AT Proto lexicon system already provides some natural extensibility. 242 + 243 + ### 4. Overly Complex Permission System 244 + phpBB's YES/NO/NEVER ACL system with four permission types (forum, mod, user, admin), per-group and per-user overrides, global vs local scopes — is powerful but confusing. Admins frequently misconfigure permissions. **atBB advantage:** The simpler role hierarchy is actually better UX. Don't over-complicate it. Per-category permissions (Phase 2+) should be the ceiling of complexity. 245 + 246 + ### 5. Slow Development & Modernization 247 + phpBB is developed entirely by volunteers. Major versions take years. phpBB 4.0 has been in development since ~2016 and is still in alpha as of 2025. **atBB lesson:** Ship MVP fast, iterate. The AT Proto foundation means the core protocol handles hard problems (identity, data ownership) and atBB can focus on forum UX. 248 + 249 + ### 6. Poor Mobile Experience 250 + phpBB's mobile experience, while improved with prosilver's responsive updates, still feels like a desktop forum crammed into a phone. **atBB lesson:** HTMX enables progressive enhancement. Design mobile-first from the beginning. 251 + 252 + ### 7. Search is Weak 253 + phpBB's native search indexes words, not phrases. Full-text search is basic. Large forums struggle with search performance. **atBB lesson:** Plan for Meilisearch or similar from the start (already noted as future work). Make search a core feature, not an afterthought. 254 + 255 + ### 8. No Real-Time Features 256 + phpBB is entirely request-response. No live updates, no real-time notifications, no "someone is typing" indicators. **atBB lesson:** The AT Proto firehose subscription already provides a real-time data stream. Use this to power live topic updates via HTMX SSE or WebSocket integration. 257 + 258 + --- 259 + 260 + ## Why Traditional Forums Declined 261 + 262 + Understanding why phpBB-style forums lost market share is critical for atBB's positioning: 263 + 264 + ### The Migration Pattern 265 + 1. **~2005–2010:** Social media (Facebook, Twitter) drew casual discussion away from forums 266 + 2. **~2010–2015:** Reddit centralized niche communities under one roof with a single account 267 + 3. **~2015–present:** Discord replaced forums for real-time community interaction 268 + 4. **~2020–present:** Growing backlash against centralized platforms, renewed interest in self-hosted/decentralized alternatives 269 + 270 + ### Why People Left Forums 271 + - **Friction:** Creating new accounts per forum, managing credentials, email notifications 272 + - **Discovery:** No way to find new communities from within the forum 273 + - **Single login:** Reddit/Discord offer one account for all communities 274 + - **Real-time:** Discord offers instant chat; forums feel slow 275 + - **Mobile:** Traditional forums were desktop-first 276 + 277 + ### Why People Want Forums Back 278 + - **Searchability:** Discord conversations are ephemeral and unsearchable by outsiders 279 + - **Ownership:** Reddit's API changes (2023) and platform enshittification 280 + - **Structure:** Forums organize knowledge better than chat or feeds 281 + - **Permanence:** Forum threads remain useful for years; Discord messages disappear 282 + - **Decentralization:** Growing desire to own your community and data 283 + 284 + ### atBB's Unique Position 285 + atBB directly addresses the core reasons forums declined: 286 + - **Single identity:** AT Proto DID = one identity across all forums (like Reddit/Discord) 287 + - **Data ownership:** Posts live on your PDS, not the forum's database 288 + - **Discovery:** Federation enables "common forums" features (like Discord's mutual servers) 289 + - **Portability:** Leave a forum, keep your content 290 + - **Decentralization:** Self-hosted, no central authority 291 + 292 + This is a genuinely compelling value proposition that phpBB never had. 293 + 294 + --- 295 + 296 + ## Lessons from Modern Competitors 297 + 298 + ### Discourse: What They Got Right 299 + - **Trust levels:** Automated progressive permissions (0=New → 4=Leader) reduce mod burden and scale community self-governance. New users are sandboxed; proven users earn privileges automatically. 300 + - **Gamification:** Built-in badges, points, leaderboards drive engagement 301 + - **Real-time:** Live updates, notifications, typing indicators 302 + - **Infinite scroll:** Modern UX pattern vs. pagination 303 + - **Mobile-first:** Responsive by default 304 + - **Markdown:** Modern text formatting instead of BBCode 305 + - **One-click setup:** Docker-based deployment 306 + 307 + ### Flarum: What They Got Right 308 + - **Two-pane interface:** Browse discussions without leaving current thread 309 + - **Lightweight:** Fast, minimal resource usage 310 + - **Modern stack:** Laravel + Mithril.js 311 + - **Tags instead of categories:** More flexible organization 312 + - **Infinite scroll with memory:** Remembers scroll position 313 + 314 + ### XenForo: What They Got Right 315 + - **Social features:** User status updates, activity feed 316 + - **Trophy/achievement system:** Gamification built into core 317 + - **Resource manager:** File/download sharing per forum 318 + - **Built-in media gallery:** Image/video sharing 319 + - **Robust search:** Elasticsearch integration 320 + 321 + --- 322 + 323 + ## Gap Analysis: atBB vs phpBB 324 + 325 + ### Features atBB Has That phpBB Doesn't 326 + 327 + | Feature | Description | 328 + |---------|-------------| 329 + | **Decentralized identity** | AT Proto DID — portable, user-owned identity | 330 + | **Data ownership** | Posts live on user's PDS, not forum's database | 331 + | **Portability** | Content persists even if you leave a forum | 332 + | **Federation** | Multiple forums can share identity namespace | 333 + | **Cross-forum discovery** | "Common forums" feature possible via membership records | 334 + | **Spam resistance** | AT Proto identity is a natural spam barrier | 335 + | **Protocol-level reactions** | Reactions are first-class AT Proto records | 336 + | **Real-time data stream** | Firehose subscription for live updates | 337 + 338 + ### Critical Features atBB is Missing (Priority Order) 339 + 340 + #### P0 — Essential for MVP Parity 341 + These are features without which the forum would feel fundamentally broken: 342 + 343 + 1. **Rich text / Markdown in posts** — Plain text only is not viable. Users expect at minimum bold, italic, links, code blocks, and lists. Markdown is the modern standard. Note: The current `maxGraphemes: 300` limit in `post.yaml` is closer to a tweet than a forum post — this constraint may need revisiting before implementing rich text (phpBB posts typically support thousands of characters). 344 + 345 + 2. **Unread tracking** — phpBB tracks read/unread state per-user across sessions. Without this, users can't efficiently follow conversations. This is the #1 quality-of-life feature for forum users. 346 + 347 + 3. **Topic subscription / notifications** — Users must be notified when someone replies to their topic or a topic they're watching. Without this, the forum is "post and forget." 348 + 349 + 4. **User profile pages** — Display name, avatar (from AT Proto profile), post count, join date, recent posts. The building block for community identity. 350 + 351 + 5. **Search** — Meilisearch integration. Forums are fundamentally knowledge bases; without search, old content is lost. 352 + 353 + #### P1 — Important for Competitive Parity 354 + These make the forum feel complete and usable for serious communities: 355 + 356 + 6. **Topic types: Sticky/Announcement** — Admins need to pin important topics. The `pin` mod action exists but needs display logic and proper announcement/global support. 357 + 358 + 7. **Post reporting** — Users need to flag rule-breaking content for mod review. This is the feedback loop that makes moderation scale. 359 + 360 + 8. **Moderation queue / post approval** — New users' posts can require mod approval. Critical for preventing spam/abuse in growing communities. 361 + 362 + 9. **Warning system** — Graduated enforcement before banning. Warnings → temporary ban → permanent ban. 363 + 364 + 10. **Moderation log / audit trail** — Record of all mod actions with timestamps and reasons. Essential for accountability and mod team coordination. 365 + 366 + 11. **Polls** — Simple voting mechanism within topics. High engagement, low implementation cost. 367 + 368 + 12. **Drafts** — Save unfinished posts. Prevents lost work, improves UX. 369 + 370 + 13. **Topic split/merge/move** — Core moderator workflow for organizing discussions. Without these, moderators can only lock or delete. 371 + 372 + #### P2 — Nice to Have / Differentiators 373 + These would make atBB stand out: 374 + 375 + 14. **Trust levels (Discourse-style)** — Automated progressive permissions based on participation. Reduces mod burden, encourages good behavior. 376 + 377 + 15. **ATOM/RSS feeds** — Per-forum, per-topic feeds. Low cost, high value for power users and integration. 378 + 379 + 16. **Gamification** — Post count ranks, badges, achievements. Drives engagement. 380 + 381 + 17. **File attachments / images** — Upload and embed media in posts. Already noted as future work. 382 + 383 + 18. **@Mentions** — Notify specific users in posts. phpBB 4.0 is adding this — it's becoming table stakes. 384 + 385 + 19. **Real-time updates** — Use the firehose to push new replies to open topic pages via HTMX SSE. Would be a significant differentiator over traditional forums. 386 + 387 + 20. **Subforum / nested categories** — Currently categories are flat. Some communities need hierarchy. 388 + 389 + #### P3 — Future Considerations 390 + 391 + 21. **Private messaging** — May be handled by AT Proto's native DM system instead 392 + 22. **Nested/threaded replies** — Already noted as future work 393 + 23. **Custom themes/branding** — Already noted as future work 394 + 24. **Plugin/extension system** — Already noted as future work 395 + 25. **Per-category permissions** — Restrict categories to certain roles 396 + 397 + --- 398 + 399 + ## Recommendations for atBB 400 + 401 + ### 1. Lean Into Your Strengths 402 + atBB's decentralized identity and data ownership model solves the fundamental problems that killed traditional forums (friction, lock-in, centralization). Don't bury this — make it the primary selling point. The "bring your identity, own your posts" story is compelling. 403 + 404 + ### 2. Avoid phpBB's Complexity Trap 405 + phpBB's permission system is a cautionary tale. The YES/NO/NEVER ACL with four permission types is powerful but confusing. atBB's role hierarchy (Owner → Admin → Moderator → Member → Guest) is the right level of simplicity for MVP. Add per-category permissions later if needed, but resist the urge to build a full ACL system. 406 + 407 + ### 3. Learn from Discourse's Trust Levels 408 + Discourse's automated trust system (where users earn permissions through participation) is one of the best innovations in forum software. Consider implementing a simplified version: 409 + - **Level 0 (New):** Just joined, posts may require approval 410 + - **Level 1 (Basic):** Has read some topics, can post freely 411 + - **Level 2 (Member):** Active participant, can flag posts 412 + - **Level 3 (Regular):** Trusted member, can recategorize topics, edits are instant 413 + - This could integrate with the AT Proto reputation model — a user's reputation across the ATmosphere could bootstrap their trust level. 414 + 415 + ### 4. Markdown, Not BBCode 416 + BBCode is a relic. Markdown is the universal formatting language of the modern web (GitHub, Reddit, Discourse all use it). Implement Markdown with a live preview. Consider a common Markdown library that works both server-side (for rendering) and client-side (for preview). 417 + 418 + ### 5. Real-Time is Your Secret Weapon 419 + You already have the AT Proto firehose. Use it to push real-time updates to open pages via HTMX Server-Sent Events (SSE). When someone replies to a topic you're viewing, the reply should appear without a page refresh. This would be a genuine differentiator over every traditional forum including phpBB. 420 + 421 + ### 6. Don't Neglect Search 422 + Implement Meilisearch (already planned) early, not late. Forums are knowledge bases. The value of a forum compounds over time as content accumulates — but only if that content is findable. 423 + 424 + ### 7. Mobile-First Design 425 + Design the HTMX web UI mobile-first. The majority of web traffic is mobile. phpBB's biggest UX failure is feeling like a desktop application on phones. 426 + 427 + ### 8. Unread Tracking is Non-Negotiable 428 + Implement per-user read state tracking from the beginning. This is the single most important UX feature for forums. Without it, users have no way to efficiently follow conversations across multiple topics and categories. phpBB's read tracking system (storing last-read timestamps per topic per user) has been refined over 20+ years — study it. 429 + 430 + ### 9. Moderation Must Scale 431 + Build moderation features that can scale with the community: 432 + - Post reporting from day one 433 + - Mod action audit log from day one 434 + - Warning system before permanent bans 435 + - Post approval queue for new users 436 + - Consider how AT Proto's decentralized nature affects moderation — users can always post to their PDS even if banned, but the AppView simply won't index their content. This is actually a cleaner model than phpBB's. 437 + 438 + ### 10. The "Forum Revival" Opportunity 439 + There is genuine renewed interest in forum-style communities driven by: 440 + - Discord's poor searchability and content ephemerality 441 + - Reddit's hostile API changes and platform enshittification 442 + - Growing desire for data ownership and decentralization 443 + 444 + atBB is uniquely positioned at the intersection of "forums done right" and "decentralized social." The combination of structured long-form discussion (forums), user-owned data (AT Proto), and portable identity (DIDs) is a compelling story that neither phpBB nor Discourse can tell. 445 + 446 + --- 447 + 448 + ## Sources 449 + 450 + - [phpBB Official Site](https://www.phpbb.com/) 451 + - [phpBB Features](https://www.phpbb.com/about/features/) 452 + - [phpBB History](https://www.phpbb.com/about/history/) 453 + - [phpBB Wikipedia](https://en.wikipedia.org/wiki/PhpBB) 454 + - [phpBB Permission System Docs](https://area51.phpbb.com/docs/dev/3.3.x/extensions/permission_system.html) 455 + - [phpBB Admin Guide](https://www.phpbb.com/support/docs/en/3.2/ug/adminguide/) 456 + - [phpBB 4.0 Alpha Announcement](https://blog.phpbb.com/2025/09/27/phpbb-4-0-0-a1-the-first-alpha-release-is-here/) 457 + - [phpBB Community: "phpBB is a very bad choice"](https://www.phpbb.com/community/viewtopic.php?t=2155257) 458 + - [phpBB Community: "What is the Future of phpBB?"](https://www.phpbb.com/community/viewtopic.php?t=2607651) 459 + - [HN: Why did phpBB forums go out of fashion?](https://news.ycombinator.com/item?id=33726098) 460 + - [HN: Are forum platforms dead?](https://news.ycombinator.com/item?id=32171283) 461 + - [Discourse vs Flarum vs phpBB Comparison](https://www.accuwebhosting.com/blog/discourse-vs-flarum-vs-phpbb/) 462 + - [Discourse Trust Levels](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) 463 + - [Discourse Gamification Plugin](https://meta.discourse.org/t/discourse-gamification/225916) 464 + - [phpBB Moderation Guide](https://www.phpbb.com/support/docs/en/3.0/ug/moderatorguide/moderator_modtools/) 465 + - [phpBB Search Indexing](https://www.phpbb.com/support/docs/en/3.0/ug/adminguide/maintenance_search/) 466 + - [phpBB ATOM Feeds FAQ](https://www.phpbb.com/support/docs/en/3.0/kb/article/faq-phpbb-atom-feeds/) 467 + - [LowEndTalk: How did phpBB go from biggest to obscurity?](https://lowendtalk.com/discussion/208407/how-did-phpbb-go-from-the-biggest-forum-platform-on-the-block-to-relative-obscurity) 468 + - [Quora: Pros and cons of phpBB](https://www.quora.com/What-are-the-pros-and-cons-of-using-phpBB-for-web-forums)
+623
docs/research/realtime-architecture.md
··· 1 + # Real-Time Architecture: Firehose → SSE → HTMX 2 + 3 + *Deep-dive research conducted 2026-02-07* 4 + 5 + --- 6 + 7 + ## The Opportunity 8 + 9 + Every traditional forum (phpBB, Discourse, Flarum) works on a request-response model. You load a topic page. It's a snapshot. If someone replies while you're reading, you don't see it until you manually refresh. Discourse has real-time features via WebSockets, but it's a bolt-on — a separate infrastructure layer they built on top of their standard Rails app. 10 + 11 + atBB is different. The AT Protocol *already has* a real-time event stream (the firehose/Jetstream). The AppView *already needs* to subscribe to it for indexing. The web UI *already uses* HTMX, which has declarative SSE support. The entire pipeline exists — it just needs to be connected. 12 + 13 + **The data flow:** 14 + ``` 15 + User writes post AT Proto Jetstream atBB Browser 16 + to their PDS ───▶ Relay ───▶ (JSON WS) ───▶ AppView ───▶ (HTMX SSE) 17 + indexes + 18 + broadcasts 19 + ``` 20 + 21 + End-to-end latency: ~1–2 seconds from PDS write to browser update. That's fast enough to feel "live" without feeling like chat. 22 + 23 + --- 24 + 25 + ## Architecture Overview 26 + 27 + ### Three Layers 28 + 29 + ``` 30 + ┌─────────────────────────────────────────────────────┐ 31 + │ Browser (HTMX) │ 32 + │ │ 33 + │ ┌────────────┐ ┌──────────┐ ┌─────────────────┐ │ 34 + │ │ Topic View │ │ Category │ │ Notification │ │ 35 + │ │ sse-swap= │ │ View │ │ Badge │ │ 36 + │ │ "newReply" │ │ sse-swap=│ │ sse-swap= │ │ 37 + │ │ │ │ "newTopic│ │ "notification" │ │ 38 + │ └─────┬──────┘ └────┬─────┘ └───────┬─────────┘ │ 39 + │ │ │ │ │ 40 + │ └──────────────┴────────────────┘ │ 41 + │ │ SSE │ 42 + └───────────────────────┼──────────────────────────────┘ 43 + 44 + ┌───────────────────────┼──────────────────────────────┐ 45 + │ @atbb/web (Hono) │ 46 + │ │ │ 47 + │ GET /sse/thread/:id ──────┐ │ 48 + │ GET /sse/category/:id ────┤ SSE │ 49 + │ GET /sse/global ──────────┤ Endpoints │ 50 + │ │ │ 51 + │ ┌─────────────────────────┘ │ 52 + │ │ Subscribe to AppView event bus │ 53 + │ │ Render HTML fragments │ 54 + │ │ Stream as SSE events │ 55 + └────────────┼─────────────────────────────────────────┘ 56 + 57 + ┌────────────┼─────────────────────────────────────────┐ 58 + │ │ @atbb/appview (Hono) │ 59 + │ │ │ 60 + │ ┌─────────▼──────────┐ ┌────────────────────────┐ │ 61 + │ │ Event Bus │ │ Jetstream Consumer │ │ 62 + │ │ (in-process) │◀──│ │ │ 63 + │ │ │ │ space.atbb.* │ │ 64 + │ │ topic:abc → │ │ filter + index │ │ 65 + │ │ [subscriber1] │ │ │ │ 66 + │ │ [subscriber2] │ │ Cursor persistence │ │ 67 + │ │ category:xyz → │ │ Reconnection logic │ │ 68 + │ │ [subscriber3] │ └────────────┬───────────┘ │ 69 + │ └────────────────────┘ │ │ 70 + │ │ │ 71 + │ ┌─────────────────────────────────────▼───────────┐ │ 72 + │ │ PostgreSQL │ │ 73 + │ │ posts | categories | users | memberships │ │ 74 + │ └─────────────────────────────────────────────────┘ │ 75 + └──────────────────────────────────────────────────────┘ 76 + 77 + │ WebSocket (JSON) 78 + 79 + ┌──────────────────────────────────────────────────────┐ 80 + │ Jetstream │ 81 + │ wss://jetstream2.us-east.bsky.network/subscribe │ 82 + │ ?wantedCollections=space.atbb.* │ 83 + └──────────────────────────────────────────────────────┘ 84 + ``` 85 + 86 + ### Why SSE Over WebSocket for the Browser Connection 87 + 88 + | Factor | SSE | WebSocket | 89 + |--------|-----|-----------| 90 + | Direction | Server → Client only | Bidirectional | 91 + | Forum fit | Perfect — users read far more than they write | Overkill | 92 + | User writes | Standard HTMX POST/PUT (already works) | Would need `ws-send` | 93 + | Infrastructure | Works through all proxies, CDNs, load balancers | Needs sticky sessions, special proxy config | 94 + | Reconnection | Browser `EventSource` auto-reconnects natively | Extension handles it | 95 + | Graceful degradation | If SSE breaks, forum still works as normal HTTP | Same | 96 + | HTMX integration | `sse-swap` maps events to DOM targets declaratively | OOB swap by ID only | 97 + | HTTP/2 concern | Uses one connection per stream (H/2 multiplexes) | Separate TCP connection | 98 + 99 + **Forum interactions are fundamentally asymmetric.** Users spend 95% of their time reading. SSE handles the high-volume server→client push (new replies, presence, typing indicators). Standard HTMX POST handles the low-volume client→server actions (submitting replies, reacting). WebSocket's bidirectionality is wasted here. 100 + 101 + --- 102 + 103 + ## Layer 1: Jetstream Consumer (AppView) 104 + 105 + ### Connection Setup 106 + 107 + Use `@skyware/jetstream` for MVP — it provides typed JSON events with cursor management. The full `@atproto/sync` firehose (CBOR + signatures) is available for post-MVP hardening. 108 + 109 + ```typescript 110 + // packages/appview/src/firehose/consumer.ts 111 + 112 + import { Jetstream } from "@skyware/jetstream"; 113 + import type { EventEmitter } from "node:events"; 114 + 115 + interface FirehoseConsumer { 116 + start(): void; 117 + stop(): void; 118 + events: EventEmitter; // broadcast bus 119 + } 120 + 121 + function createFirehoseConsumer(db: Database): FirehoseConsumer { 122 + const events = new EventEmitter(); 123 + 124 + const jetstream = new Jetstream({ 125 + endpoint: "wss://jetstream2.us-east.bsky.network/subscribe", 126 + wantedCollections: ["space.atbb.*"], 127 + cursor: loadCursorFromDb(db), // microsecond timestamp 128 + }); 129 + 130 + // Index new posts and broadcast for SSE 131 + jetstream.onCreate("space.atbb.post", async (event) => { 132 + const { did, commit, time_us } = event; 133 + const post = await indexPost(db, did, commit.rkey, commit.cid, commit.record); 134 + saveCursor(db, time_us); 135 + 136 + // Determine broadcast channel 137 + if (post.rootPostId) { 138 + // It's a reply — broadcast to the thread channel 139 + events.emit(`topic:${post.rootPostId}`, { type: "newReply", post }); 140 + } else { 141 + // It's a new topic — broadcast to the category channel 142 + // NOTE: Current schema has `forumUri` not `categoryId`. To route 143 + // "new topic in category X" events, need to either: (a) resolve 144 + // category from forum metadata, or (b) add categoryUri to posts table. 145 + // For now, broadcast to forum-level channel: 146 + events.emit(`forum:${post.forumUri}`, { type: "newTopic", post }); 147 + } 148 + 149 + // Always broadcast to global (for notification badges, etc.) 150 + events.emit("global", { type: "newPost", post }); 151 + }); 152 + 153 + jetstream.onDelete("space.atbb.post", async (event) => { 154 + const { did, commit, time_us } = event; 155 + const post = await softDeletePost(db, did, commit.rkey); 156 + saveCursor(db, time_us); 157 + 158 + if (post) { 159 + events.emit(`topic:${post.rootPostId}`, { type: "postDeleted", post }); 160 + } 161 + }); 162 + 163 + // Index other record types similarly... 164 + jetstream.onCreate("space.atbb.forum.category", async (event) => { 165 + await indexCategory(db, event.did, event.commit.rkey, event.commit.record); 166 + saveCursor(db, event.time_us); 167 + events.emit("global", { type: "categoryUpdate" }); 168 + }); 169 + 170 + jetstream.onCreate("space.atbb.reaction", async (event) => { 171 + const reaction = await indexReaction(db, event.did, event.commit); 172 + saveCursor(db, event.time_us); 173 + events.emit(`topic:${reaction.topicId}`, { type: "newReaction", reaction }); 174 + }); 175 + 176 + return { 177 + start: () => jetstream.start(), 178 + stop: () => jetstream.close(), 179 + events, 180 + }; 181 + } 182 + ``` 183 + 184 + ### Jetstream Filtering 185 + 186 + Jetstream supports NSID prefix wildcards: 187 + 188 + ``` 189 + ?wantedCollections=space.atbb.* 190 + ``` 191 + 192 + This catches `space.atbb.post`, `space.atbb.forum.forum`, `space.atbb.forum.category`, `space.atbb.membership`, `space.atbb.reaction`, `space.atbb.modAction` — everything in the atBB namespace. 193 + 194 + **Note:** Wildcard syntax is supported as long as the prefix (`space.atbb`) passes NSID validation, which it does. Jetstream allows up to 100 collection filters per connection. If wildcard filtering proves problematic in practice, the seven collections can be enumerated explicitly. 195 + 196 + ### Cursor Management 197 + 198 + Jetstream events have a `time_us` field (Unix microseconds). Persist this as a cursor: 199 + 200 + ```typescript 201 + // Save every 100 events (not every event — that would hammer the DB) 202 + let eventsSinceSave = 0; 203 + function saveCursor(db: Database, cursor: number) { 204 + eventsSinceSave++; 205 + if (eventsSinceSave >= 100) { 206 + db.execute("UPDATE firehose_state SET cursor = $1", [cursor]); 207 + eventsSinceSave = 0; 208 + } 209 + } 210 + 211 + // On reconnect, rewind 5 seconds for safety (process events idempotently) 212 + function loadCursorFromDb(db: Database): number | undefined { 213 + const row = db.queryOne("SELECT cursor FROM firehose_state"); 214 + if (!row?.cursor) return undefined; 215 + return row.cursor - 5_000_000; // 5 seconds in microseconds 216 + } 217 + ``` 218 + 219 + **Backfill window:** Jetstream retains ~24 hours of events. If the AppView is offline longer, fall back to `com.atproto.sync.getRepo` for known DIDs. 220 + 221 + --- 222 + 223 + ## Layer 2: Event Bus → SSE Endpoints (Web Package) 224 + 225 + The web package subscribes to the AppView's event bus and renders HTML fragments streamed to the browser. 226 + 227 + ### Option A: Internal Event Bus (Single-Process) 228 + 229 + If appview and web run in the same process (or web calls an appview SSE endpoint): 230 + 231 + ```typescript 232 + // packages/appview/src/routes/events.ts 233 + 234 + import { Hono } from "hono"; 235 + import { streamSSE } from "hono/streaming"; 236 + 237 + const app = new Hono(); 238 + 239 + // SSE endpoint for a specific thread 240 + app.get("/api/events/topic/:id", async (c) => { 241 + const topicId = c.req.param("id"); 242 + 243 + return streamSSE(c, async (stream) => { 244 + // Send initial connection confirmation 245 + await stream.writeSSE({ event: "connected", data: "ok" }); 246 + 247 + // Heartbeat to prevent proxy timeouts 248 + const heartbeat = setInterval(async () => { 249 + await stream.writeSSE({ event: "heartbeat", data: "" }); 250 + }, 30_000); 251 + 252 + // Subscribe to topic events 253 + const handler = async (event: ForumEvent) => { 254 + // Hono's streamSSE accepts JSX directly in the data field 255 + await stream.writeSSE({ 256 + event: event.type, // "newReply", "newReaction", "postDeleted" 257 + data: renderEventComponent(event), // Returns JSX element 258 + }); 259 + }; 260 + 261 + firehoseConsumer.events.on(`topic:${topicId}`, handler); 262 + 263 + // Cleanup on disconnect 264 + stream.onAbort(() => { 265 + clearInterval(heartbeat); 266 + firehoseConsumer.events.off(`topic:${topicId}`, handler); 267 + }); 268 + }); 269 + }); 270 + 271 + // SSE endpoint for a category (new topics) 272 + app.get("/api/events/category/:id", async (c) => { 273 + const categoryId = c.req.param("id"); 274 + 275 + return streamSSE(c, async (stream) => { 276 + const heartbeat = setInterval(async () => { 277 + await stream.writeSSE({ event: "heartbeat", data: "" }); 278 + }, 30_000); 279 + 280 + const handler = async (event: ForumEvent) => { 281 + await stream.writeSSE({ 282 + event: event.type, 283 + data: renderEventComponent(event) 284 + }); 285 + }; 286 + 287 + firehoseConsumer.events.on(`category:${categoryId}`, handler); 288 + 289 + stream.onAbort(() => { 290 + clearInterval(heartbeat); 291 + firehoseConsumer.events.off(`category:${categoryId}`, handler); 292 + }); 293 + }); 294 + }); 295 + ``` 296 + 297 + ### Option B: AppView Exposes SSE, Web Proxies It 298 + 299 + If appview and web are separate processes, the web package can either: 300 + 1. Proxy the SSE stream from appview directly 301 + 2. Consume appview SSE internally and re-emit with HTML rendering 302 + 303 + Option 1 is simpler — the appview SSE endpoint returns HTML fragments, and the web package's JSX templates include `sse-connect` pointing at the appview. 304 + 305 + ### HTML Fragment Rendering 306 + 307 + The key insight: SSE events carry **pre-rendered HTML fragments**, not JSON. This is what makes HTMX SSE zero custom client-side JS. 308 + 309 + ```typescript 310 + // packages/web/src/components/ReplyCard.tsx 311 + 312 + import type { FC } from "hono/jsx"; 313 + 314 + interface ReplyCardProps { 315 + author: string; 316 + authorDid: string; 317 + text: string; 318 + createdAt: string; 319 + replyCount?: number; 320 + } 321 + 322 + export const ReplyCard: FC<ReplyCardProps> = (props) => ( 323 + <article class="reply" id={`reply-${props.authorDid}-${props.createdAt}`}> 324 + <header class="reply-meta"> 325 + <a href={`/user/${props.authorDid}`} class="reply-author"> 326 + {props.author} 327 + </a> 328 + <time datetime={props.createdAt}> 329 + {new Date(props.createdAt).toLocaleString()} 330 + </time> 331 + </header> 332 + <div class="reply-body"> 333 + {props.text} 334 + </div> 335 + </article> 336 + ); 337 + 338 + // Render JSX component for SSE 339 + function renderReplyComponent(post: IndexedPost) { 340 + // Hono's streamSSE accepts JSX directly — no manual string conversion needed 341 + return <ReplyCard 342 + author={post.authorHandle} 343 + authorDid={post.authorDid} 344 + text={post.text} 345 + createdAt={post.createdAt} 346 + />; 347 + // Alternative if string needed: component.toString() 348 + } 349 + ``` 350 + 351 + --- 352 + 353 + ## Layer 3: HTMX SSE in the Browser 354 + 355 + ### Topic View (Thread Page) 356 + 357 + ```tsx 358 + // packages/web/src/routes/topic.tsx 359 + 360 + export const TopicView: FC<TopicViewProps> = ({ topic, replies }) => ( 361 + <BaseLayout title={topic.title}> 362 + {/* SSE connection scoped to this thread */} 363 + <div hx-ext="sse" sse-connect={`/api/events/topic/${topic.id}`}> 364 + 365 + {/* Thread header */} 366 + <article class="topic-op"> 367 + <h1>{topic.title}</h1> 368 + <div class="post-body">{topic.text}</div> 369 + <div class="post-meta"> 370 + by <a href={`/user/${topic.authorDid}`}>{topic.author}</a> 371 + {" · "} 372 + <time datetime={topic.createdAt}>{topic.createdAt}</time> 373 + </div> 374 + </article> 375 + 376 + {/* Reply list — new replies streamed in at the bottom */} 377 + <section id="replies"> 378 + {replies.map((reply) => ( 379 + <ReplyCard {...reply} /> 380 + ))} 381 + 382 + {/* This target receives new replies via SSE */} 383 + <div sse-swap="newReply" hx-swap="beforebegin"></div> 384 + </section> 385 + 386 + {/* Reaction updates swap into specific post elements via OOB */} 387 + {/* (The SSE "newReaction" event sends OOB-targeted HTML) */} 388 + 389 + {/* Reply count badge — updated in real-time */} 390 + <span id="reply-count" sse-swap="replyCount"> 391 + {replies.length} replies 392 + </span> 393 + 394 + {/* Typing indicator */} 395 + <div id="typing-indicator" sse-swap="typing"></div> 396 + 397 + </div> 398 + 399 + {/* Reply form — standard HTMX POST (not SSE) */} 400 + <form hx-post={`/api/topics/${topic.id}/reply`} 401 + hx-swap="none" 402 + hx-on::after-request="this.reset()"> 403 + <textarea name="text" placeholder="Write a reply..." 404 + required minlength="1" maxlength="3000"></textarea> 405 + <button type="submit">Reply</button> 406 + </form> 407 + </BaseLayout> 408 + ); 409 + ``` 410 + 411 + ### Category View (Topic List) 412 + 413 + ```tsx 414 + export const CategoryView: FC<CategoryViewProps> = ({ category, topics }) => ( 415 + <BaseLayout title={category.name}> 416 + <div hx-ext="sse" sse-connect={`/api/events/category/${category.id}`}> 417 + 418 + <h1>{category.name}</h1> 419 + <p>{category.description}</p> 420 + 421 + <table class="topic-list"> 422 + <thead> 423 + <tr> 424 + <th>Topic</th> 425 + <th>Author</th> 426 + <th>Replies</th> 427 + <th>Last Post</th> 428 + </tr> 429 + </thead> 430 + <tbody id="topic-list-body" 431 + sse-swap="newTopic" 432 + hx-swap="afterbegin"> 433 + {topics.map((topic) => <TopicRow {...topic} />)} 434 + </tbody> 435 + </table> 436 + 437 + </div> 438 + </BaseLayout> 439 + ); 440 + ``` 441 + 442 + ### Global Notification Badge 443 + 444 + This could be placed in the base layout so it works on every page: 445 + 446 + ```tsx 447 + // packages/web/src/layouts/base.tsx 448 + 449 + export const BaseLayout: FC<BaseLayoutProps> = (props) => ( 450 + <html> 451 + <head> 452 + <script src="https://unpkg.com/htmx.org@2.0.4" /> 453 + <script src="https://unpkg.com/htmx-ext-sse@2.2.3/sse.js" /> 454 + </head> 455 + <body hx-boost="true"> 456 + <header hx-ext="sse" sse-connect="/api/events/global"> 457 + <nav> 458 + <a href="/">atBB Forum</a> 459 + {/* Notification badge — updated live */} 460 + <span id="notification-badge" sse-swap="notification"></span> 461 + </nav> 462 + </header> 463 + <main> 464 + {props.children} 465 + </main> 466 + </body> 467 + </html> 468 + ); 469 + ``` 470 + 471 + --- 472 + 473 + ## What This Enables (Concrete Features) 474 + 475 + ### Tier 1: Easy Wins (MVP-compatible) 476 + 477 + | Feature | SSE Event | Behavior | 478 + |---------|-----------|----------| 479 + | **Live replies** | `newReply` | New reply appears at bottom of thread without refresh | 480 + | **Live new topics** | `newTopic` | New topic appears at top of category view | 481 + | **Reply count** | `replyCount` | Reply count badge updates in real-time | 482 + | **Post deletion** | `postDeleted` | Deleted post fades out or shows "[deleted]" | 483 + | **Mod actions** | `modAction` | Locked/pinned status updates live | 484 + 485 + ### Tier 2: Enhanced UX (Post-MVP) 486 + 487 + | Feature | SSE Event | Behavior | 488 + |---------|-----------|----------| 489 + | **Typing indicator** | `typing` | "User X is typing..." shown below reply list | 490 + | **Online presence** | `presence` | "12 users viewing this topic" | 491 + | **Reaction animations** | `newReaction` | Like/upvote count increments with animation | 492 + | **Unread badges** | `notification` | Global nav shows unread count for subscribed topics | 493 + | **Topic bumping** | `topicBumped` | Topic list reorders when a topic gets a new reply | 494 + 495 + ### Tier 3: Differentiators (Future) 496 + 497 + | Feature | Description | 498 + |---------|-------------| 499 + | **Cross-forum activity feed** | Since AT Proto identities span forums, show activity from all forums a user participates in | 500 + | **Live moderation dashboard** | Stream mod queue events in real-time | 501 + | **"Someone replied to your post" toasts** | Non-intrusive notification popups | 502 + 503 + --- 504 + 505 + ## Scaling Considerations 506 + 507 + ### Single Instance (MVP) 508 + 509 + For MVP, an in-process `EventEmitter` is sufficient: 510 + 511 + ``` 512 + Jetstream → AppView process (index + EventEmitter) → SSE streams to browsers 513 + ``` 514 + 515 + No external infrastructure needed. The EventEmitter holds subscriber lists in memory. 516 + 517 + **Capacity:** A single Node.js process can comfortably hold 1,000+ SSE connections. For a self-hosted forum, this is more than enough. 518 + 519 + ### Multi-Instance (Production) 520 + 521 + If atBB needs horizontal scaling: 522 + 523 + ``` 524 + Jetstream → AppView Instance 1 ──┐ 525 + ├── Redis Pub/Sub ──┬── Web Instance 1 → SSE 526 + Jetstream → AppView Instance 2 ──┘ └── Web Instance 2 → SSE 527 + ``` 528 + 529 + - Only one AppView instance should consume Jetstream (use leader election or a single indexer process) 530 + - That instance publishes events to Redis Pub/Sub 531 + - All web instances subscribe to Redis and stream to their connected clients 532 + - **Alternative:** Use PostgreSQL `LISTEN/NOTIFY` instead of Redis — one fewer dependency 533 + 534 + ### HTTP/2 Requirement 535 + 536 + SSE connections hold an HTTP connection open. Under HTTP/1.1, browsers limit to 6 connections per domain — a user with multiple tabs could exhaust this. **HTTP/2 multiplexes all streams over a single TCP connection**, eliminating this issue. 537 + 538 + **Action:** Ensure the deployment setup (Docker Compose with nginx/Caddy) uses HTTP/2. Caddy enables HTTP/2 by default. 539 + 540 + ### Proxy Configuration 541 + 542 + SSE requires long-lived connections. Configure reverse proxy timeouts: 543 + 544 + ```nginx 545 + # nginx — for SSE routes only 546 + location /api/events/ { 547 + proxy_pass http://appview:3000; 548 + proxy_http_version 1.1; 549 + proxy_set_header Connection ""; 550 + proxy_buffering off; 551 + proxy_cache off; 552 + proxy_read_timeout 86400s; # 24 hours 553 + } 554 + ``` 555 + 556 + Or with Caddy (no special config needed — it handles streaming correctly by default). 557 + 558 + --- 559 + 560 + ## Implementation Roadmap 561 + 562 + ### Phase 1 (Part of AppView Core milestone) 563 + 1. **Add Jetstream consumer** — `@skyware/jetstream` with `space.atbb.*` filter 564 + 2. **Add in-process EventEmitter** — broadcast indexed events by channel 565 + 3. **Add SSE endpoint** — `/api/events/topic/:id` using Hono's `streamSSE` 566 + 4. **Persist cursor** — store `time_us` in PostgreSQL, reload on restart 567 + 568 + ### Phase 2 (Part of Web UI milestone) 569 + 5. **Add `htmx-ext-sse`** — include SSE extension alongside HTMX in base layout 570 + 6. **Add `sse-connect` to topic view** — connect to thread-specific SSE stream 571 + 7. **Render reply HTML fragments** — reuse existing JSX components for SSE payloads 572 + 8. **Add `sse-connect` to category view** — connect to category-specific SSE stream 573 + 574 + ### Phase 3 (Post-MVP polish) 575 + 9. **Typing indicators** — debounced POST from textarea keyup, broadcast via SSE 576 + 10. **Presence tracking** — track SSE connections per topic, broadcast count 577 + 11. **Notification badges** — global SSE stream for subscribed topic updates 578 + 12. **Graceful degradation** — ensure forum works perfectly without SSE (progressive enhancement) 579 + 580 + ### Dependencies 581 + 582 + | Step | Depends On | New Packages | 583 + |------|-----------|--------------| 584 + | Jetstream consumer | Phase 1 AppView Core | `@skyware/jetstream`, `ws` | 585 + | EventEmitter | Jetstream consumer | None (Node.js built-in) | 586 + | SSE endpoint | EventEmitter + Hono | None (`hono/streaming` built-in) | 587 + | HTMX SSE extension | Web UI + SSE endpoint | `htmx-ext-sse` (CDN) | 588 + 589 + --- 590 + 591 + ## Why This Matters 592 + 593 + No other forum software has this architecture. Here's the comparison: 594 + 595 + | Forum | Real-Time Approach | 596 + |-------|-------------------| 597 + | **phpBB** | None. Pure request-response. Must refresh to see new replies. | 598 + | **Discourse** | Custom WebSocket implementation via MessageBus gem. Bolt-on architecture, requires Redis, sticky sessions. | 599 + | **Flarum** | Pusher.com integration (third-party SaaS). Adds external dependency and cost. | 600 + | **NodeBB** | Socket.io (WebSocket). Heavy client-side JS framework. | 601 + | **atBB** | Protocol-native firehose → in-process event bus → SSE → HTMX declarative swap. Zero *custom* client-side JS (HTMX itself is ~14KB). The real-time stream is architecturally intrinsic, not bolted on. | 602 + 603 + The AT Protocol firehose means atBB doesn't *add* real-time — it *is* real-time. The firehose consumer that indexes posts is the same component that powers live updates. There's no separate infrastructure, no Redis, no WebSocket server, no client-side framework. Just HTML attributes. 604 + 605 + This is atBB's strongest architectural differentiator and should be a first-class feature from day one. 606 + 607 + --- 608 + 609 + ## Sources 610 + 611 + - [HTMX SSE Extension Docs](https://htmx.org/extensions/sse/) 612 + - [htmx-ext-sse npm package](https://www.npmjs.com/package/htmx-ext-sse) 613 + - [HTMX WebSocket vs SSE comparison](https://htmx.org/extensions/ws/) 614 + - [Hono SSE Streaming API](https://hono.dev/docs/helpers/streaming) 615 + - [Bluesky Jetstream GitHub](https://github.com/bluesky-social/jetstream) 616 + - [Jetstream: Shrinking the Firehose by >99%](https://jazco.dev/2024/09/24/jetstream/) 617 + - [@skyware/jetstream](https://www.npmjs.com/package/@skyware/jetstream) 618 + - [@atproto/sync](https://www.npmjs.com/package/@atproto/sync) 619 + - [@atproto/tap (backfill tool)](https://docs.bsky.app/blog/introducing-tap) 620 + - [Bluesky Firehose Guide](https://docs.bsky.app/docs/advanced-guides/firehose) 621 + - [benc-uk/htmx-go-chat (SSE chat example)](https://github.com/benc-uk/htmx-go-chat) 622 + - [Live Website Updates with Go, SSE, and HTMX](https://threedots.tech/post/live-website-updates-go-sse-htmx/) 623 + - [SSE vs WebSockets](https://www.smashingmagazine.com/2018/02/sse-websockets-data-flow-http2/)
+259
docs/test-coverage-analysis.md
··· 1 + # Test Coverage Analysis 2 + 3 + ## Current State: No Tests Exist 4 + 5 + The monorepo has **zero test infrastructure**. No testing framework is installed, no test scripts exist in any `package.json`, and `turbo.json` has no `test` task. There are approximately **530 lines of source code** across 4 packages with 0% test coverage. 6 + 7 + --- 8 + 9 + ## Recommended Test Framework Setup 10 + 11 + **Vitest** is the best fit for this project: 12 + - Native ESM support (the repo uses `"type": "module"` everywhere) 13 + - Built-in TypeScript support via `tsx`/`esbuild` (no separate ts-jest config) 14 + - Workspace-aware — can share a root config while per-package configs override as needed 15 + - Fast, with watch mode out of the box 16 + 17 + ### Infrastructure needed 18 + 19 + 1. Install `vitest` as a root devDependency 20 + 2. Add a root `vitest.workspace.ts` pointing at each package 21 + 3. Add `"test": "vitest run"` scripts to each package's `package.json` 22 + 4. Add a `"test"` task to `turbo.json` (with `dependsOn: ["^build"]` since appview/web depend on lexicon types) 23 + 5. Add `pnpm test` as a root script 24 + 25 + --- 26 + 27 + ## Package-by-Package Gaps and Recommendations 28 + 29 + ### 1. `@atbb/appview` — API Server (highest priority) 30 + 31 + This is the most complex package (260 LOC) and where most business logic will land as stubs are implemented. It has the database schema, config loading, AT Protocol agent creation, and all API routes. 32 + 33 + #### a) Route handler tests (high value) 34 + 35 + **Files:** `src/routes/health.ts`, `src/routes/forum.ts`, `src/routes/categories.ts`, `src/routes/topics.ts`, `src/routes/posts.ts` 36 + 37 + **What to test:** 38 + - `GET /api/healthz` returns `200` with `{ status: "ok", version: "0.1.0" }` 39 + - `GET /api/healthz/ready` returns `200` with `{ status: "ready" }` 40 + - `GET /api/forum` returns `200` with the expected forum shape 41 + - `GET /api/categories` returns `200` with `{ categories: [] }` 42 + - `GET /api/categories/:id/topics` returns `200` and echoes the `id` param 43 + - `POST /api/topics` returns `501` (not implemented) 44 + - `POST /api/posts` returns `501` (not implemented) 45 + 46 + **How:** Use Hono's built-in `app.request()` test helper — no HTTP server needed: 47 + ```ts 48 + import { describe, it, expect } from "vitest"; 49 + import { apiRoutes } from "../src/routes/index.js"; 50 + import { Hono } from "hono"; 51 + 52 + const app = new Hono().route("/api", apiRoutes); 53 + 54 + describe("GET /api/healthz", () => { 55 + it("returns ok status", async () => { 56 + const res = await app.request("/api/healthz"); 57 + expect(res.status).toBe(200); 58 + expect(await res.json()).toEqual({ status: "ok", version: "0.1.0" }); 59 + }); 60 + }); 61 + ``` 62 + 63 + **Why it matters:** As stubs are replaced with real implementations, having route-level tests in place catches regressions in response shape, status codes, and content-type headers. These tests are cheap to write now and will grow in value. 64 + 65 + #### b) Config loading tests (medium value) 66 + 67 + **File:** `src/lib/config.ts` 68 + 69 + **What to test:** 70 + - Returns correct defaults when env vars are absent (`PORT` defaults to `3000`, `PDS_URL` defaults to `https://bsky.social`) 71 + - Parses `PORT` as an integer (not a string) 72 + - Returns provided env var values when set 73 + - Handles `PORT` set to a non-numeric string (currently `parseInt` would return `NaN` — should this throw?) 74 + 75 + **Why it matters:** Config loading is the root of most "it works on my machine" bugs. The current implementation silently accepts empty strings for `forumDid` and `databaseUrl`, which will cause hard-to-debug runtime failures. Tests would document this behavior and motivate adding validation. 76 + 77 + #### c) Database schema tests (medium value) 78 + 79 + **File:** `src/db/schema.ts` 80 + 81 + **What to test:** 82 + - Schema definitions export the expected table names 83 + - Column types match expectations (e.g., `posts.deleted` defaults to `false`) 84 + - Foreign key references are correct (`posts.did` → `users.did`, `posts.rootPostId` → `posts.id`, etc.) 85 + - Index names are correct and unique constraints are in place 86 + 87 + **How:** These can be pure unit tests against the Drizzle schema objects — no database connection needed. Drizzle table objects expose `._.columns` and other metadata you can assert against. 88 + 89 + **Why it matters:** Schema is the foundation. If someone accidentally removes an index or changes a foreign key, these tests catch it before it hits a migration. 90 + 91 + #### d) Database integration tests (high value, but requires infrastructure) 92 + 93 + **What to test:** 94 + - Insert/select/update/delete for each table 95 + - Foreign key constraints are enforced (e.g., inserting a post with a non-existent `did` fails) 96 + - Unique index violations behave as expected 97 + - The `createDb()` factory produces a working Drizzle client 98 + 99 + **How:** Use a test PostgreSQL instance. Options: 100 + - **Testcontainers** (Docker-based, spins up a real Postgres per test suite) 101 + - **pg-mem** (in-memory Postgres emulator, faster but not 100% compatible) 102 + - A shared test database with transaction rollback between tests 103 + 104 + **Why it matters:** This is where the most subtle bugs live — constraint violations, bad joins, missing indexes. As the appview stubs are fleshed out with real queries, these tests become critical. 105 + 106 + #### e) AT Protocol agent factory test (low value now, higher later) 107 + 108 + **File:** `src/lib/atproto.ts` 109 + 110 + Currently just `new AtpAgent({ service: config.pdsUrl })` — not much to test. But as authentication and record-writing logic is added, this module should have tests verifying: 111 + - Agent is created with the correct service URL 112 + - Authentication errors are handled gracefully 113 + - Record write/read operations produce expected AT URI formats 114 + 115 + --- 116 + 117 + ### 2. `@atbb/web` — Server-Rendered Web UI 118 + 119 + #### a) API client tests (high value) 120 + 121 + **File:** `src/lib/api.ts` 122 + 123 + **What to test:** 124 + - `fetchApi("/categories")` calls the correct URL (`${appviewUrl}/api/categories`) 125 + - Throws an `Error` with status code and status text on non-2xx responses 126 + - Returns parsed JSON on success 127 + - Handles network failures (fetch throws) 128 + 129 + **How:** Mock `global.fetch` with `vi.fn()` or use `msw` (Mock Service Worker): 130 + ```ts 131 + import { describe, it, expect, vi, beforeEach } from "vitest"; 132 + 133 + // Mock fetch globally 134 + const mockFetch = vi.fn(); 135 + vi.stubGlobal("fetch", mockFetch); 136 + 137 + describe("fetchApi", () => { 138 + it("throws on non-ok response", async () => { 139 + mockFetch.mockResolvedValueOnce({ 140 + ok: false, status: 500, statusText: "Internal Server Error", 141 + }); 142 + const { fetchApi } = await import("../src/lib/api.js"); 143 + await expect(fetchApi("/test")).rejects.toThrow("AppView API error: 500"); 144 + }); 145 + }); 146 + ``` 147 + 148 + **Why it matters:** `fetchApi` is the single point of contact between the web UI and the appview. Error handling here determines whether users see useful error messages or blank pages. 149 + 150 + #### b) JSX component / layout tests (medium value) 151 + 152 + **File:** `src/layouts/base.tsx`, `src/routes/home.tsx` 153 + 154 + **What to test:** 155 + - `BaseLayout` renders valid HTML with the provided title 156 + - `BaseLayout` uses the default title "atBB Forum" when none is provided 157 + - `BaseLayout` includes the HTMX script tag 158 + - Home route returns `200` with `text/html` content type 159 + - Home page includes "Welcome to atBB" heading 160 + 161 + **How:** Use Hono's `app.request()` and assert against the HTML string, or use a lightweight HTML parser. Hono JSX components can be tested by rendering them and checking the output string. 162 + 163 + #### c) Config loading tests (low-medium value) 164 + 165 + **File:** `src/lib/config.ts` 166 + 167 + Same pattern as the appview config tests — verify defaults, parsing, and presence of required values. 168 + 169 + --- 170 + 171 + ### 3. `@atbb/lexicon` — Lexicon Definitions 172 + 173 + #### a) YAML-to-JSON build script tests (medium value) 174 + 175 + **File:** `scripts/build.ts` 176 + 177 + **What to test:** 178 + - Each YAML file in `lexicons/` produces valid JSON 179 + - Output JSON matches the expected Lexicon schema structure (has `lexicon`, `id`, `defs` fields) 180 + - No duplicate lexicon IDs across files 181 + - The `id` field in each lexicon matches its file path (e.g., `space/atbb/post.yaml` has `id: "space.atbb.post"`) 182 + 183 + **How:** Rather than testing the build script directly (it's I/O-heavy), write validation tests that run against the YAML source files: 184 + ```ts 185 + import { parse } from "yaml"; 186 + import { readFileSync } from "fs"; 187 + import { glob } from "glob"; 188 + 189 + describe("lexicon definitions", () => { 190 + const files = glob.sync("**/*.yaml", { cwd: "lexicons" }); 191 + 192 + it.each(files)("%s has a valid lexicon structure", (file) => { 193 + const content = readFileSync(`lexicons/${file}`, "utf-8"); 194 + const parsed = parse(content); 195 + expect(parsed).toHaveProperty("lexicon", 1); 196 + expect(parsed).toHaveProperty("id"); 197 + expect(parsed).toHaveProperty("defs"); 198 + }); 199 + }); 200 + ``` 201 + 202 + **Why it matters:** Lexicon definitions are the API contract for the entire AT Protocol integration. A malformed lexicon causes downstream build failures in type generation and runtime validation errors. Catching issues at the YAML level is far cheaper than debugging them at the API level. 203 + 204 + #### b) Schema contract tests (high value) 205 + 206 + **What to test:** 207 + - `space.atbb.post` has `text` as a required string field 208 + - `space.atbb.post` has optional `reply` with `root` and `parent` refs 209 + - `space.atbb.forum.forum` uses `key: literal:self` 210 + - `space.atbb.forum.category` uses `key: tid` 211 + - All `strongRef` usages have both `uri` and `cid` fields 212 + - `knownValues` are used (not `enum`) for extensible fields like `modAction.action` 213 + 214 + **Why it matters:** These are the **contract tests** of the system. If a lexicon field is accidentally renamed or a required field becomes optional, it breaks interoperability with any PDS that stores atBB records. These tests protect the public API surface. 215 + 216 + --- 217 + 218 + ### 4. `@atbb/spike` — PDS Integration Script 219 + 220 + The spike is a manual integration test. It doesn't need unit tests itself, but: 221 + 222 + #### Extractable test utilities (medium value) 223 + 224 + The spike contains reusable patterns for: 225 + - Authenticating with a PDS 226 + - Creating/reading/deleting AT Protocol records 227 + - Generating TIDs 228 + 229 + These should be extracted into a shared test utility module (e.g., `packages/test-utils/`) that integration tests across the monorepo can use. 230 + 231 + --- 232 + 233 + ## Priority Matrix 234 + 235 + | Priority | Area | Package | Effort | Impact | 236 + |----------|------|---------|--------|--------| 237 + | **P0** | Test infrastructure setup (vitest, turbo task, CI) | root | Low | Unblocks everything | 238 + | **P0** | Appview route handler tests | appview | Low | Catches regressions as stubs are implemented | 239 + | **P1** | Web API client tests (`fetchApi`) | web | Low | Validates the only web→appview boundary | 240 + | **P1** | Lexicon schema contract tests | lexicon | Low | Protects the AT Protocol API surface | 241 + | **P1** | Config loading tests (both packages) | appview, web | Low | Documents defaults, catches parse bugs | 242 + | **P2** | Database schema unit tests | appview | Medium | Catches accidental schema changes | 243 + | **P2** | JSX layout/component tests | web | Medium | Ensures correct HTML output | 244 + | **P2** | Lexicon build script validation | lexicon | Low | Catches YAML/JSON conversion issues | 245 + | **P3** | Database integration tests | appview | High | Requires Postgres test infra (Docker/testcontainers) | 246 + | **P3** | AT Protocol integration tests | appview | High | Requires PDS test instance or mock | 247 + | **P3** | Extract spike utilities into shared test-utils | spike | Medium | Enables reuse across integration tests | 248 + 249 + --- 250 + 251 + ## Suggested Implementation Order 252 + 253 + 1. **Set up vitest** at the root + per-package, add `test` task to turbo.json 254 + 2. **Appview route tests** — quick wins since Hono has a built-in test helper and the routes are simple right now 255 + 3. **Lexicon contract tests** — validate YAML schema structure to protect the AT Protocol API 256 + 4. **Web `fetchApi` tests** — mock fetch, verify URL construction and error handling 257 + 5. **Config tests** for both packages — small but catches real bugs 258 + 6. **Database schema tests** — assert on Drizzle metadata objects 259 + 7. **Database integration tests** — add testcontainers or similar once there are real queries to test
+555
docs/theming-plan.md
··· 1 + # Theming System — Design Plan 2 + 3 + **Status:** Future (post-MVP) 4 + **Package:** `@atbb/web` 5 + **Goal:** Admin-customizable forum themes with a neobrutal default, inspired by phpBB's theme flexibility. 6 + 7 + --- 8 + 9 + ## Design Principles 10 + 11 + 1. **Admin control without code.** Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed. 12 + 2. **Theme-as-data.** Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable. 13 + 3. **Progressive enhancement.** The base HTML is semantic and readable with no styles at all (it already is). Themes layer on top via CSS custom properties. If CSS fails to load, the forum is still usable. 14 + 4. **No build step per theme.** Themes are applied at runtime through CSS custom properties and a server-rendered `<style>` block. No per-theme CSS compilation. 15 + 5. **Neobrutal default.** The built-in theme uses a neobrutal aesthetic: bold borders, solid shadows, high contrast, punchy accent colors, chunky type. 16 + 17 + --- 18 + 19 + ## Architecture 20 + 21 + ### How Themes Work 22 + 23 + ``` 24 + Forum DID PDS Web Server (Hono) 25 + ┌─────────────────────────────┐ ┌───────────────────────────────────┐ 26 + │ space.atbb.forum.theme │ │ │ 27 + │ (multiple records) │──cache─▶│ Theme resolution per request: │ 28 + │ │ │ 1. User pref (membership record) │ 29 + │ space.atbb.forum.themePolicy│ │ 2. Color scheme (cookie/header) │ 30 + │ (singleton) { │──cache─▶│ 3. Forum default (themePolicy) │ 31 + │ availableThemes │ │ 4. Hardcoded fallback │ 32 + │ defaultLightTheme │ │ │ │ 33 + │ defaultDarkTheme │ │ ▼ │ 34 + │ allowUserChoice │ │ <style>:root { --tokens }</style>│ 35 + │ } │ │ + /static/theme.css │ 36 + └─────────────────────────────┘ └───────────────────────────────────┘ 37 + ``` 38 + 39 + 1. **Theme records** live on the Forum DID's PDS as `space.atbb.forum.theme` records. A forum can have many saved themes. 40 + 2. **Theme policy** is a separate singleton record (`space.atbb.forum.themePolicy`) that controls which themes are available to users, which are the defaults for light/dark mode, and whether users can choose their own. 41 + 3. **On each request**, the web server resolves which theme to render (see [Theme Resolution](#theme-resolution) below) and injects the winning theme's CSS custom properties into a `<style>` block in `<head>`. 42 + 4. **A single base stylesheet** (`theme.css`) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look. 43 + 5. **Optional per-theme CSS overrides** can extend the base for structural changes (e.g., sidebar layout vs. top-nav), stored as a `cssOverrides` string in the theme record. 44 + 45 + ### Theme Layers 46 + 47 + ``` 48 + Layer 0: Reset / Normalize (minimal, ships with @atbb/web) 49 + Layer 1: Base component styles (theme.css — uses only custom properties) 50 + Layer 2: Design tokens (<style>:root { --color-bg: ... }) 51 + Layer 3: Per-theme CSS overrides (optional structural tweaks) 52 + ``` 53 + 54 + --- 55 + 56 + ## Design Token Schema 57 + 58 + Themes are defined as a flat set of design tokens. The token names map 1:1 to CSS custom properties. 59 + 60 + ### Color Tokens 61 + 62 + | Token | Description | Neobrutal Default | 63 + |-------|-------------|-------------------| 64 + | `color-bg` | Page background | `#f5f0e8` (warm off-white) | 65 + | `color-surface` | Card/panel background | `#ffffff` | 66 + | `color-text` | Primary text | `#1a1a1a` | 67 + | `color-text-muted` | Secondary/meta text | `#555555` | 68 + | `color-primary` | Primary accent (links, buttons) | `#ff5c00` (bold orange) | 69 + | `color-primary-hover` | Primary accent hover state | `#e04f00` | 70 + | `color-secondary` | Secondary accent | `#3a86ff` (vivid blue) | 71 + | `color-border` | Border color | `#1a1a1a` (black) | 72 + | `color-shadow` | Box-shadow color | `#1a1a1a` | 73 + | `color-success` | Success/positive | `#2ec44a` | 74 + | `color-warning` | Warning | `#ffbe0b` | 75 + | `color-danger` | Danger/destructive | `#ff006e` | 76 + | `color-code-bg` | Code block background | `#1a1a1a` | 77 + | `color-code-text` | Code block text | `#f5f0e8` | 78 + 79 + ### Typography Tokens 80 + 81 + | Token | Description | Neobrutal Default | 82 + |-------|-------------|-------------------| 83 + | `font-body` | Body font stack | `'Space Grotesk', system-ui, sans-serif` | 84 + | `font-heading` | Heading font stack | `'Space Grotesk', system-ui, sans-serif` | 85 + | `font-mono` | Monospace font stack | `'JetBrains Mono', ui-monospace, monospace` | 86 + | `font-size-base` | Base font size | `16px` | 87 + | `font-size-sm` | Small text | `14px` | 88 + | `font-size-lg` | Large text | `20px` | 89 + | `font-size-xl` | XL text (headings) | `28px` | 90 + | `font-size-2xl` | 2XL text (page titles) | `36px` | 91 + | `font-weight-normal` | Normal weight | `400` | 92 + | `font-weight-bold` | Bold weight | `700` | 93 + | `line-height-body` | Body line height | `1.6` | 94 + | `line-height-heading` | Heading line height | `1.2` | 95 + 96 + ### Spacing & Layout Tokens 97 + 98 + | Token | Description | Neobrutal Default | 99 + |-------|-------------|-------------------| 100 + | `space-xs` | Extra-small spacing | `4px` | 101 + | `space-sm` | Small spacing | `8px` | 102 + | `space-md` | Medium spacing | `16px` | 103 + | `space-lg` | Large spacing | `24px` | 104 + | `space-xl` | Extra-large spacing | `40px` | 105 + | `radius` | Border radius | `0px` (sharp corners — neobrutal) | 106 + | `border-width` | Default border width | `3px` (chunky — neobrutal) | 107 + | `shadow-offset` | Box-shadow offset | `4px` (solid offset shadows) | 108 + | `content-width` | Max content width | `960px` | 109 + 110 + ### Component-Level Tokens 111 + 112 + | Token | Description | Neobrutal Default | 113 + |-------|-------------|-------------------| 114 + | `button-radius` | Button border radius | `0px` | 115 + | `button-shadow` | Button box-shadow | `4px 4px 0 var(--color-shadow)` | 116 + | `card-radius` | Card border radius | `0px` | 117 + | `card-shadow` | Card box-shadow | `6px 6px 0 var(--color-shadow)` | 118 + | `input-radius` | Input border radius | `0px` | 119 + | `input-border` | Input border | `3px solid var(--color-border)` | 120 + | `nav-height` | Navigation bar height | `64px` | 121 + 122 + --- 123 + 124 + ## Neobrutal Default Theme — Design Direction 125 + 126 + The neobrutal aesthetic is characterized by: 127 + 128 + - **Thick black borders** on cards, buttons, and inputs (3px+) 129 + - **Solid offset box-shadows** instead of soft/blurred shadows (e.g., `4px 4px 0 #1a1a1a`) 130 + - **Sharp corners** (border-radius: 0) or very slight rounding 131 + - **High contrast** color palette — dark text on light backgrounds, bold accent colors 132 + - **Punchy accent colors** — saturated oranges, blues, pinks (not pastels) 133 + - **Chunky, confident typography** — geometric sans-serifs, generous sizing 134 + - **Flat color fills** — no gradients, no translucency 135 + - **Deliberate "roughness"** — the UI looks intentionally bold and unpolished, like a zine or poster 136 + 137 + ### Visual Reference Points 138 + 139 + - [Gumroad's redesign](https://gumroad.com) — the canonical neobrutal web product 140 + - [Figma neobrutal UI kits](https://www.figma.com/community/tag/neobrutalism) — community references 141 + - The general energy: "Web Brutalism meets a friendly color palette" 142 + 143 + ### Layout Sketch 144 + 145 + ``` 146 + ┌─────────────────────────────────────────────────────┐ 147 + │ ██ atBB Forum Name [Login] │ <- thick bottom border 148 + ├─────────────────────────────────────────────────────┤ 149 + │ │ 150 + │ ┌─────────────────────────────────────────────┐ │ 151 + │ │ 📁 Category Name 12 topics│ │ <- solid shadow card 152 + │ │ Description of the category... │ │ 153 + │ └──┬──┬───────────────────────────────────────┘ │ 154 + │ └──┘ (offset shadow) │ 155 + │ │ 156 + │ ┌─────────────────────────────────────────────┐ │ 157 + │ │ 📁 Another Category 8 topics│ │ 158 + │ │ Another description here... │ │ 159 + │ └──┬──┬───────────────────────────────────────┘ │ 160 + │ └──┘ │ 161 + │ │ 162 + │ ┌──────────────────┐ │ 163 + │ │ [+ New Topic] │ <- bold button w/ shadow │ 164 + │ └──┬──┬────────────┘ │ 165 + │ └──┘ │ 166 + │ │ 167 + ├─────────────────────────────────────────────────────┤ 168 + │ Powered by atBB on the ATmosphere │ 169 + └─────────────────────────────────────────────────────┘ 170 + ``` 171 + 172 + --- 173 + 174 + ## Lexicon Changes 175 + 176 + ### New: `space.atbb.forum.theme` 177 + 178 + A new record type on the Forum DID for storing theme configuration. 179 + 180 + ```yaml 181 + lexiconId: space.atbb.forum.theme 182 + key: tid # Multiple themes per forum 183 + fields: 184 + name: string (required) # "Neobrutal Default", "Dark Mode", etc. 185 + colorScheme: 186 + type: string (required) 187 + knownValues: ["light", "dark"] # Which mode this theme targets (extensible) 188 + tokens: map<string, string> # Design token key-value pairs 189 + cssOverrides: string (optional)# Raw CSS for structural overrides 190 + fontUrls: array<string> (opt) # HTTPS URLs for Google Fonts or self-hosted fonts 191 + createdAt: datetime 192 + updatedAt: datetime 193 + ``` 194 + 195 + **Record ownership:** Forum DID (same as `forum.forum`, `forum.category`). When implemented, `forum.theme` must be added to the canonical ownership list in CLAUDE.md. 196 + 197 + **Why `tid` key?** Forums can have many saved themes (like phpBB's theme gallery). The admin curates which ones are available via the theme policy below. 198 + 199 + **Why `colorScheme` instead of `active`?** A single `active` boolean is too limiting. Forums need separate defaults for light and dark mode, plus a curated list of user-selectable themes. The `colorScheme` field tags each theme so the resolution logic knows which mode it serves. The theme policy handles the rest. 200 + 201 + ### New: `space.atbb.forum.themePolicy` 202 + 203 + A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating `strongRef`s to the forum record. 204 + 205 + ```yaml 206 + lexiconId: space.atbb.forum.themePolicy 207 + key: literal:self # Singleton — one per forum 208 + 209 + # Named def for theme references 210 + defs: 211 + themeRef: 212 + type: object 213 + required: [theme] 214 + properties: 215 + theme: 216 + type: ref 217 + ref: com.atproto.repo.strongRef # CID integrity check for theme records 218 + 219 + fields: 220 + availableThemes: # Themes admins have enabled for users 221 + type: array 222 + items: 223 + type: ref 224 + ref: '#themeRef' 225 + defaultLightTheme: # Default light-mode theme 226 + type: ref 227 + ref: '#themeRef' 228 + defaultDarkTheme: # Default dark-mode theme 229 + type: ref 230 + ref: '#themeRef' 231 + allowUserChoice: # Can users pick their own theme? 232 + type: boolean 233 + default: true 234 + updatedAt: datetime 235 + ``` 236 + 237 + **Record ownership:** Forum DID. 238 + 239 + The admin's saved themes may outnumber the available list — `availableThemes` is the curated subset exposed to users. Both `defaultLightTheme` and `defaultDarkTheme` must be members of `availableThemes`. 240 + 241 + ### Extended: `space.atbb.membership` 242 + 243 + Add an optional theme preference to the existing user membership record: 244 + 245 + ```yaml 246 + # New optional field on membership 247 + preferredTheme: 248 + type: ref (optional) 249 + ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record 250 + # Null = follow forum defaults 251 + ``` 252 + 253 + This lives on the **user's PDS** (they own their membership record), so theme preference is portable — leave a forum and rejoin, your preference is still there. Uses `strongRef` for CID integrity — if the theme record is updated, stale preferences are detected. 254 + 255 + --- 256 + 257 + ## Admin Theme Editor 258 + 259 + The admin panel gets a theme management section: 260 + 261 + ### Theme List View 262 + - Shows all saved themes with preview thumbnails and `colorScheme` badges (light/dark) 263 + - Create / duplicate / delete themes 264 + - **Availability toggles** — check themes on/off to control `themePolicy.availableThemes` 265 + - **Default assignment** — dropdown to pick the default light theme and default dark theme (must be from the available list) 266 + - **User choice kill-switch** — toggle for `themePolicy.allowUserChoice`. When off, all users see the forum defaults regardless of their membership preference. 267 + 268 + ### Theme Editor View 269 + - **Live preview panel** — shows a sample forum page with current token values 270 + - **Token editor** — grouped by category (colors, typography, spacing, components) 271 + - Color tokens: color picker inputs 272 + - Typography tokens: font selector + size sliders 273 + - Spacing tokens: numeric inputs with preview 274 + - Component tokens: composite editors (shadow builder, border builder) 275 + - **Color scheme selector** — pick whether this theme targets `light` or `dark` mode 276 + - **CSS overrides** — code editor (CodeMirror or similar) for advanced users 277 + - **Font management** — add Google Fonts URLs or upload self-hosted fonts 278 + - **Import/Export** — download theme as JSON, upload to share between forums 279 + - **Preset gallery** — start from built-in presets (Neobrutal, Clean, Dark, Classic BB) 280 + 281 + ### Implementation Notes 282 + - The editor itself is an HTMX-driven form. Token changes POST to the server, which returns an updated `<style>` block for the preview panel via an `hx-swap`. 283 + - No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow. 284 + - Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized. 285 + - Theme policy changes (defaults, available list, allowUserChoice) write to the `space.atbb.forum.themePolicy` singleton on the Forum DID's PDS. 286 + 287 + --- 288 + 289 + ## AppView API Endpoints 290 + 291 + Theme data flows through the AppView like all other forum data. New endpoints (AppView REST, not XRPC — consistent with existing `/api/forum`, `/api/categories` patterns): 292 + 293 + ### Read Endpoints 294 + 295 + | Endpoint | Auth | Description | 296 + |----------|------|-------------| 297 + | `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. | 298 + | `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. | 299 + | `GET /api/theme-policy` | Public | Get the forum's theme policy (available themes, defaults for light/dark, allowUserChoice). | 300 + 301 + ### Write Endpoints 302 + 303 + | Endpoint | Auth | Description | 304 + |----------|------|-------------| 305 + | `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. | 306 + | `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. | 307 + | `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. | 308 + | `PUT /api/theme-policy` | Admin | Update the `themePolicy` singleton (available list, defaults, allowUserChoice). | 309 + | `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. | 310 + 311 + ### Caching 312 + 313 + The web server caches resolved theme data aggressively since themes change rarely: 314 + 315 + - **Theme tokens:** Cached in-memory on the web server, keyed by AT-URI **and resolved color scheme** (light/dark). Cache key must include color scheme to prevent serving a cached light response to dark-mode users. Use `Vary: Cookie` or equivalent for HTTP caching. 316 + - **Theme policy:** Cached alongside forum metadata. Same invalidation path. 317 + - **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request). 318 + 319 + --- 320 + 321 + ## Built-in Preset Themes 322 + 323 + Ship with a small set of presets that admins can use as starting points. Each preset ships with both a light and dark variant so forums have sensible defaults for both modes out of the box. 324 + 325 + | Preset | Color Scheme | Description | 326 + |--------|-------------|-------------| 327 + | **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners | 328 + | **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows | 329 + | **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette | 330 + | **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing | 331 + | **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 332 + 333 + Each preset is a complete set of token values. Admins pick one as a starting point, then customize from there. A fresh forum defaults to Neobrutal Light + Neobrutal Dark with user choice enabled. 334 + 335 + --- 336 + 337 + ## CSS Architecture 338 + 339 + ### File Structure (future) 340 + 341 + ``` 342 + packages/web/src/ 343 + styles/ 344 + reset.css # Minimal normalize/reset 345 + theme.css # All component styles using var(--token) references 346 + presets/ 347 + neobrutal-light.json # Token values for neobrutal light preset 348 + neobrutal-dark.json # Token values for neobrutal dark preset 349 + clean-light.json # Token values for clean light preset 350 + clean-dark.json # Token values for clean dark preset 351 + classic.json # Token values for classic BB preset 352 + ``` 353 + 354 + ### Base Stylesheet Approach 355 + 356 + `theme.css` is written once and never changes per-theme. It references custom properties exclusively: 357 + 358 + ```css 359 + /* Example — not final */ 360 + body { 361 + font-family: var(--font-body); 362 + font-size: var(--font-size-base); 363 + line-height: var(--line-height-body); 364 + color: var(--color-text); 365 + background: var(--color-bg); 366 + } 367 + 368 + .card { 369 + background: var(--color-surface); 370 + border: var(--border-width) solid var(--color-border); 371 + border-radius: var(--card-radius); 372 + box-shadow: var(--card-shadow); 373 + padding: var(--space-md); 374 + } 375 + 376 + .btn-primary { 377 + background: var(--color-primary); 378 + color: var(--color-surface); 379 + border: var(--border-width) solid var(--color-border); 380 + border-radius: var(--button-radius); 381 + box-shadow: var(--button-shadow); 382 + font-weight: var(--font-weight-bold); 383 + padding: var(--space-sm) var(--space-md); 384 + } 385 + 386 + .btn-primary:hover { 387 + background: var(--color-primary-hover); 388 + transform: translate(2px, 2px); 389 + box-shadow: 2px 2px 0 var(--color-shadow); 390 + } 391 + ``` 392 + 393 + ### Server-Side Token Injection 394 + 395 + `BaseLayout` renders the active theme's tokens as a `<style>` block: 396 + 397 + ```tsx 398 + // Pseudocode — future implementation 399 + const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => { 400 + // Theme resolved server-side before render (see Theme Resolution section) 401 + const theme = props.resolvedTheme; 402 + const policy = props.themePolicy; 403 + 404 + return ( 405 + <html lang="en"> 406 + <head> 407 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 408 + <title>{props.title ?? "atBB Forum"}</title> 409 + <style>{`:root { ${tokensToCss(theme.tokens)} }`}</style> 410 + <link rel="stylesheet" href="/static/reset.css" /> 411 + <link rel="stylesheet" href="/static/theme.css" /> 412 + {theme.fontUrls?.map(url => ( 413 + <link rel="stylesheet" href={url} /> 414 + ))} 415 + {theme.cssOverrides && ( 416 + <style>{theme.cssOverrides}</style> 417 + )} 418 + <script src="https://unpkg.com/htmx.org@2.0.4" /> 419 + </head> 420 + <body> 421 + <header> 422 + {/* ... nav ... */} 423 + <button onclick="toggleColorScheme()">Light/Dark</button> 424 + {policy.allowUserChoice && props.user && ( 425 + <ThemePicker 426 + themes={props.availableThemes} 427 + current={props.user.preferredTheme} 428 + /> 429 + )} 430 + </header> 431 + <main>{props.children}</main> 432 + </body> 433 + </html> 434 + ); 435 + }; 436 + ``` 437 + 438 + --- 439 + 440 + ## Theme Resolution 441 + 442 + When the web server handles a request, it resolves which theme to render using a waterfall: 443 + 444 + ``` 445 + 1. User preference 446 + Is the user logged in? 447 + AND has a preferredTheme set on their membership record? 448 + AND does the forum's themePolicy.allowUserChoice == true? 449 + AND is preferredTheme.uri still in themePolicy.availableThemes? 450 + AND does preferredTheme.cid match current theme record (integrity check)? 451 + → Use their preferred theme. 452 + 453 + 2. Color scheme default 454 + Read color scheme preference: 455 + a. Cookie: atbb-color-scheme=light|dark 456 + b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 457 + c. Default: light 458 + 459 + → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly 460 + (with CID integrity check via strongRef). 461 + 462 + 3. Hardcoded fallback 463 + If no theme policy exists or the resolved theme can't be loaded: 464 + → Use the built-in neobrutal token values (no PDS needed, works offline). 465 + ``` 466 + 467 + This is entirely **server-side** — no client JS framework needed. The web server resolves the theme before rendering and bakes the correct tokens into the HTML response. 468 + 469 + ### Light/Dark Toggle 470 + 471 + The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle: 472 + 473 + ```html 474 + <!-- In the site header/footer --> 475 + <button onclick="toggleColorScheme()">Light/Dark</button> 476 + 477 + <script> 478 + function toggleColorScheme() { 479 + const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light'; 480 + const next = current === 'light' ? 'dark' : 'light'; 481 + document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`; 482 + location.reload(); 483 + } 484 + </script> 485 + ``` 486 + 487 + The `location.reload()` is intentional — it keeps the "server renders everything" model clean. The server re-renders the page with the other default theme's tokens. Could be enhanced later with HTMX to swap just the `<style>` block without a full reload. 488 + 489 + ### User Theme Picker 490 + 491 + For logged-in users (when `themePolicy.allowUserChoice` is true): 492 + 493 + - A dropdown or modal in the user's settings area (or a compact picker in the site header) 494 + - Shows the admin's curated `availableThemes` list with names and color swatch previews 495 + - Selecting one writes `preferredTheme` to the user's membership record on their PDS 496 + - An **"Auto (follow forum default)"** option clears the preference, falling back to the light/dark defaults 497 + - Implemented as a standard HTMX form — `hx-patch` to the API, swap a success indicator 498 + 499 + --- 500 + 501 + ## Implementation Phases 502 + 503 + This work is post-MVP. Suggested ordering: 504 + 505 + ### Theme Phase 1: Foundation 506 + - Add `reset.css` and `theme.css` with custom property references 507 + - Hardcode neobrutal light tokens in `BaseLayout` as the default 508 + - Style all existing views (homepage, category, topic, compose, admin) 509 + - Add static file serving (`/static/` route via Hono `serveStatic`) 510 + - No admin editor, no dynamic themes — just ship a good-looking default 511 + 512 + ### Theme Phase 2: Light/Dark + Token System 513 + - Define `space.atbb.forum.theme` lexicon (with `colorScheme` field using `knownValues`) 514 + - Define `space.atbb.forum.themePolicy` lexicon as separate singleton (with `themeRef` strongRef wrapper) 515 + - Build `tokensToCss()` utility 516 + - Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic) 517 + - Load theme policy + resolved theme from Forum DID's PDS (with caching) 518 + - Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall 519 + - Add light/dark toggle (cookie-based, vanilla JS, ~6 lines) 520 + - Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback 521 + - AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` (REST, not XRPC) 522 + 523 + ### Theme Phase 3: Admin Theme Management 524 + - **CSS sanitization (mandatory gate):** Server-side sanitization for `cssOverrides` and freeform `tokens` map before rendering. Raw CSS in `<style>` tags is a real exfiltration vector via `url()`, `@import`, `@font-face`. Must sanitize before this phase ships. 525 + - Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` (AppView REST, not XRPC) 526 + - Theme list management UI (create, duplicate, delete, availability toggles) 527 + - Default light/dark assignment dropdowns 528 + - `allowUserChoice` kill-switch toggle 529 + - Token editor with grouped controls + live preview via HTMX 530 + - Import/export (JSON download/upload) 531 + - Database additions: `themes` table following `(did, rkey, cid, indexed_at)` pattern 532 + 533 + ### Theme Phase 4: User Choice 534 + - Add `preferredTheme` field to `space.atbb.membership` lexicon 535 + - User endpoint: `PATCH /api/membership/theme` (AppView REST, not XRPC) 536 + - Theme picker UI for logged-in users (dropdown in settings or site header) 537 + - "Auto (follow forum default)" option to clear preference 538 + - Database additions: `preferred_theme_uri` column on `memberships` table (nullable) 539 + - AppView indexes `preferredTheme` from membership records for fast lookup 540 + 541 + ### Theme Phase 5: Polish 542 + - CSS override editor for advanced admins (with sanitization) 543 + - Preset gallery expansion 544 + - Theme sharing between forums (export includes metadata for discovery) 545 + - HTMX-based theme swap without full page reload (stretch) 546 + 547 + --- 548 + 549 + ## Open Questions 550 + 551 + 1. **Font loading strategy.** Google Fonts is easy but has privacy implications for self-hosters. Should we bundle a few default fonts, allow self-hosted uploads, or both? **Security note:** `fontUrls` must be constrained to HTTPS and consider an allowlist (Google Fonts, self-hosted paths) — arbitrary font URLs leak user IPs to third parties. 552 + 2. **Theme record size limits.** AT Proto records have size limits. If `cssOverrides` gets large, might need to store it as a blob reference instead of inline. 553 + 3. **Build-time vs. runtime tokens.** The plan above is fully runtime (no CSS build per theme). This is simpler but means we can't use tools like Tailwind for the base styles. Is that acceptable? 554 + 4. **Theme migration on updates.** When atBB ships new tokens in a release (e.g., a new component token), existing saved themes won't have values for them. Need a merge strategy — probably fall back to the preset's value for any missing token. 555 + 5. **Stale user preferences.** If an admin removes a theme from `availableThemes` while users have it as their `preferredTheme`, the resolution waterfall handles this gracefully (falls through to the color scheme default). But should we also notify affected users or silently degrade?
+4 -2
package.json
··· 6 6 "build": "turbo run build", 7 7 "dev": "turbo run dev", 8 8 "lint": "turbo run lint", 9 - "clean": "turbo run clean" 9 + "clean": "turbo run clean", 10 + "test": "turbo run test" 10 11 }, 11 12 "devDependencies": { 12 13 "turbo": "^2.4.0", 13 - "typescript": "^5.7.0" 14 + "typescript": "^5.7.0", 15 + "vitest": "^4.0.18" 14 16 } 15 17 }
+1 -1
packages/appview/drizzle.config.ts apps/appview/drizzle.config.ts
··· 1 1 import { defineConfig } from "drizzle-kit"; 2 2 3 3 export default defineConfig({ 4 - schema: "./src/db/schema.ts", 4 + schema: "../../packages/db/src/schema.ts", 5 5 out: "./drizzle", 6 6 dialect: "postgresql", 7 7 dbCredentials: {
packages/appview/drizzle/0000_lovely_roland_deschain.sql apps/appview/drizzle/0000_lovely_roland_deschain.sql
packages/appview/drizzle/0001_daily_power_pack.sql apps/appview/drizzle/0001_daily_power_pack.sql
packages/appview/drizzle/meta/0000_snapshot.json apps/appview/drizzle/meta/0000_snapshot.json
packages/appview/drizzle/meta/0001_snapshot.json apps/appview/drizzle/meta/0001_snapshot.json
packages/appview/drizzle/meta/_journal.json apps/appview/drizzle/meta/_journal.json
+5 -4
packages/appview/package.json apps/appview/package.json
··· 8 8 "dev": "tsx watch --env-file=../../.env src/index.ts", 9 9 "start": "node dist/index.js", 10 10 "lint": "tsc --noEmit", 11 + "test": "vitest run", 11 12 "clean": "rm -rf dist", 12 13 "db:generate": "drizzle-kit generate", 13 14 "db:migrate": "drizzle-kit migrate" 14 15 }, 15 16 "dependencies": { 17 + "@atbb/db": "workspace:*", 16 18 "@atbb/lexicon": "workspace:*", 17 19 "@atproto/api": "^0.15.0", 18 20 "@atproto/common-web": "^0.4.0", 19 21 "@hono/node-server": "^1.14.0", 20 22 "@skyware/jetstream": "^0.2.5", 21 - "drizzle-orm": "^0.45.1", 22 - "hono": "^4.7.0", 23 - "postgres": "^3.4.8" 23 + "hono": "^4.7.0" 24 24 }, 25 25 "devDependencies": { 26 26 "@types/node": "^22.0.0", 27 27 "drizzle-kit": "^0.31.8", 28 28 "tsx": "^4.0.0", 29 - "typescript": "^5.7.0" 29 + "typescript": "^5.7.0", 30 + "vitest": "^3.1.0" 30 31 } 31 32 }
packages/appview/src/db/index.ts packages/db/src/index.ts
packages/appview/src/db/schema.ts packages/db/src/schema.ts
packages/appview/src/index.ts apps/appview/src/index.ts
packages/appview/src/lib/atproto.ts apps/appview/src/lib/atproto.ts
packages/appview/src/lib/config.ts apps/appview/src/lib/config.ts
packages/appview/src/lib/firehose.ts apps/appview/src/lib/firehose.ts
packages/appview/src/lib/indexer.ts apps/appview/src/lib/indexer.ts
packages/appview/src/routes/categories.ts apps/appview/src/routes/categories.ts
packages/appview/src/routes/forum.ts apps/appview/src/routes/forum.ts
packages/appview/src/routes/health.ts apps/appview/src/routes/health.ts
packages/appview/src/routes/index.ts apps/appview/src/routes/index.ts
packages/appview/src/routes/posts.ts apps/appview/src/routes/posts.ts
packages/appview/src/routes/topics.ts apps/appview/src/routes/topics.ts
packages/appview/tsconfig.json apps/appview/tsconfig.json
+32
packages/db/package.json
··· 1 + { 2 + "name": "@atbb/db", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "main": "./dist/index.js", 7 + "types": "./dist/index.d.ts", 8 + "exports": { 9 + ".": { 10 + "types": "./dist/index.d.ts", 11 + "default": "./dist/index.js" 12 + }, 13 + "./schema": { 14 + "types": "./dist/schema.d.ts", 15 + "default": "./dist/schema.js" 16 + } 17 + }, 18 + "scripts": { 19 + "build": "tsc", 20 + "lint": "tsc --noEmit", 21 + "clean": "rm -rf dist", 22 + "test": "vitest run" 23 + }, 24 + "dependencies": { 25 + "drizzle-orm": "^0.45.1", 26 + "postgres": "^3.4.8" 27 + }, 28 + "devDependencies": { 29 + "@types/node": "^22.0.0", 30 + "typescript": "^5.7.0" 31 + } 32 + }
+178
packages/db/src/__tests__/schema.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { getTableName, getTableColumns } from "drizzle-orm"; 3 + import { 4 + forums, 5 + categories, 6 + users, 7 + memberships, 8 + posts, 9 + modActions, 10 + } from "../schema.js"; 11 + 12 + describe("database schema", () => { 13 + describe("forums table", () => { 14 + it("has the correct table name", () => { 15 + expect(getTableName(forums)).toBe("forums"); 16 + }); 17 + 18 + it("has expected columns", () => { 19 + const cols = getTableColumns(forums); 20 + expect(cols).toHaveProperty("id"); 21 + expect(cols).toHaveProperty("did"); 22 + expect(cols).toHaveProperty("rkey"); 23 + expect(cols).toHaveProperty("cid"); 24 + expect(cols).toHaveProperty("name"); 25 + expect(cols).toHaveProperty("description"); 26 + expect(cols).toHaveProperty("indexedAt"); 27 + }); 28 + 29 + it("has did and rkey as not-null", () => { 30 + const cols = getTableColumns(forums); 31 + expect(cols.did.notNull).toBe(true); 32 + expect(cols.rkey.notNull).toBe(true); 33 + }); 34 + }); 35 + 36 + describe("categories table", () => { 37 + it("has the correct table name", () => { 38 + expect(getTableName(categories)).toBe("categories"); 39 + }); 40 + 41 + it("has expected columns", () => { 42 + const cols = getTableColumns(categories); 43 + expect(cols).toHaveProperty("id"); 44 + expect(cols).toHaveProperty("did"); 45 + expect(cols).toHaveProperty("rkey"); 46 + expect(cols).toHaveProperty("cid"); 47 + expect(cols).toHaveProperty("name"); 48 + expect(cols).toHaveProperty("description"); 49 + expect(cols).toHaveProperty("slug"); 50 + expect(cols).toHaveProperty("sortOrder"); 51 + expect(cols).toHaveProperty("forumId"); 52 + expect(cols).toHaveProperty("createdAt"); 53 + expect(cols).toHaveProperty("indexedAt"); 54 + }); 55 + }); 56 + 57 + describe("users table", () => { 58 + it("has the correct table name", () => { 59 + expect(getTableName(users)).toBe("users"); 60 + }); 61 + 62 + it("uses did as primary key", () => { 63 + const cols = getTableColumns(users); 64 + expect(cols.did.primary).toBe(true); 65 + }); 66 + 67 + it("has handle as optional", () => { 68 + const cols = getTableColumns(users); 69 + expect(cols.handle.notNull).toBe(false); 70 + }); 71 + }); 72 + 73 + describe("memberships table", () => { 74 + it("has the correct table name", () => { 75 + expect(getTableName(memberships)).toBe("memberships"); 76 + }); 77 + 78 + it("has expected columns", () => { 79 + const cols = getTableColumns(memberships); 80 + expect(cols).toHaveProperty("id"); 81 + expect(cols).toHaveProperty("did"); 82 + expect(cols).toHaveProperty("rkey"); 83 + expect(cols).toHaveProperty("cid"); 84 + expect(cols).toHaveProperty("forumId"); 85 + expect(cols).toHaveProperty("forumUri"); 86 + expect(cols).toHaveProperty("role"); 87 + expect(cols).toHaveProperty("roleUri"); 88 + expect(cols).toHaveProperty("joinedAt"); 89 + expect(cols).toHaveProperty("createdAt"); 90 + expect(cols).toHaveProperty("indexedAt"); 91 + }); 92 + 93 + it("has did and forumUri as not-null", () => { 94 + const cols = getTableColumns(memberships); 95 + expect(cols.did.notNull).toBe(true); 96 + expect(cols.forumUri.notNull).toBe(true); 97 + }); 98 + }); 99 + 100 + describe("posts table", () => { 101 + it("has the correct table name", () => { 102 + expect(getTableName(posts)).toBe("posts"); 103 + }); 104 + 105 + it("has expected columns for the unified post model", () => { 106 + const cols = getTableColumns(posts); 107 + expect(cols).toHaveProperty("id"); 108 + expect(cols).toHaveProperty("did"); 109 + expect(cols).toHaveProperty("rkey"); 110 + expect(cols).toHaveProperty("cid"); 111 + expect(cols).toHaveProperty("text"); 112 + expect(cols).toHaveProperty("forumUri"); 113 + expect(cols).toHaveProperty("rootPostId"); 114 + expect(cols).toHaveProperty("parentPostId"); 115 + expect(cols).toHaveProperty("rootUri"); 116 + expect(cols).toHaveProperty("parentUri"); 117 + expect(cols).toHaveProperty("createdAt"); 118 + expect(cols).toHaveProperty("indexedAt"); 119 + expect(cols).toHaveProperty("deleted"); 120 + }); 121 + 122 + it("has text as not-null", () => { 123 + const cols = getTableColumns(posts); 124 + expect(cols.text.notNull).toBe(true); 125 + }); 126 + 127 + it("has deleted defaulting to false", () => { 128 + const cols = getTableColumns(posts); 129 + expect(cols.deleted.notNull).toBe(true); 130 + expect(cols.deleted.hasDefault).toBe(true); 131 + }); 132 + 133 + it("has rootPostId and parentPostId as nullable (topics have no parent)", () => { 134 + const cols = getTableColumns(posts); 135 + expect(cols.rootPostId.notNull).toBe(false); 136 + expect(cols.parentPostId.notNull).toBe(false); 137 + }); 138 + }); 139 + 140 + describe("modActions table", () => { 141 + it("has the correct table name", () => { 142 + expect(getTableName(modActions)).toBe("mod_actions"); 143 + }); 144 + 145 + it("has expected columns", () => { 146 + const cols = getTableColumns(modActions); 147 + expect(cols).toHaveProperty("id"); 148 + expect(cols).toHaveProperty("did"); 149 + expect(cols).toHaveProperty("rkey"); 150 + expect(cols).toHaveProperty("cid"); 151 + expect(cols).toHaveProperty("action"); 152 + expect(cols).toHaveProperty("subjectDid"); 153 + expect(cols).toHaveProperty("subjectPostUri"); 154 + expect(cols).toHaveProperty("forumId"); 155 + expect(cols).toHaveProperty("reason"); 156 + expect(cols).toHaveProperty("createdBy"); 157 + expect(cols).toHaveProperty("expiresAt"); 158 + expect(cols).toHaveProperty("createdAt"); 159 + expect(cols).toHaveProperty("indexedAt"); 160 + }); 161 + 162 + it("has action and createdBy as not-null", () => { 163 + const cols = getTableColumns(modActions); 164 + expect(cols.action.notNull).toBe(true); 165 + expect(cols.createdBy.notNull).toBe(true); 166 + }); 167 + }); 168 + 169 + describe("all tables export correctly", () => { 170 + it("exports six tables", () => { 171 + const tables = [forums, categories, users, memberships, posts, modActions]; 172 + expect(tables).toHaveLength(6); 173 + tables.forEach((table) => { 174 + expect(table).toBeDefined(); 175 + }); 176 + }); 177 + }); 178 + });
+8
packages/db/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*.ts"] 8 + }
+7
packages/db/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "node", 6 + }, 7 + });
+97
packages/lexicon/__tests__/lexicons.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { readFileSync } from "node:fs"; 3 + import { join } from "node:path"; 4 + import { parse as parseYaml } from "yaml"; 5 + import { globSync } from "glob"; 6 + 7 + const LEXICONS_DIR = join(import.meta.dirname, "..", "lexicons"); 8 + const yamlFiles = globSync("**/*.yaml", { cwd: LEXICONS_DIR }); 9 + 10 + describe("lexicon definitions", () => { 11 + it("finds at least one lexicon file", () => { 12 + expect(yamlFiles.length).toBeGreaterThan(0); 13 + }); 14 + 15 + describe.each(yamlFiles)("%s", (file) => { 16 + const content = readFileSync(join(LEXICONS_DIR, file), "utf-8"); 17 + const parsed = parseYaml(content); 18 + 19 + it("has lexicon version 1", () => { 20 + expect(parsed).toHaveProperty("lexicon", 1); 21 + }); 22 + 23 + it("has an id field", () => { 24 + expect(parsed).toHaveProperty("id"); 25 + expect(typeof parsed.id).toBe("string"); 26 + }); 27 + 28 + it("has a defs object", () => { 29 + expect(parsed).toHaveProperty("defs"); 30 + expect(typeof parsed.defs).toBe("object"); 31 + }); 32 + 33 + it("has an id that matches the file path", () => { 34 + // space/atbb/post.yaml -> space.atbb.post 35 + const expectedId = file.replace(/\.yaml$/, "").replace(/\//g, "."); 36 + expect(parsed.id).toBe(expectedId); 37 + }); 38 + }); 39 + }); 40 + 41 + describe("lexicon uniqueness", () => { 42 + it("has no duplicate lexicon ids", () => { 43 + const ids = yamlFiles.map((file) => { 44 + const content = readFileSync(join(LEXICONS_DIR, file), "utf-8"); 45 + return parseYaml(content).id; 46 + }); 47 + const unique = new Set(ids); 48 + expect(unique.size).toBe(ids.length); 49 + }); 50 + }); 51 + 52 + describe("record key conventions", () => { 53 + const recordLexicons = yamlFiles 54 + .map((file) => { 55 + const content = readFileSync(join(LEXICONS_DIR, file), "utf-8"); 56 + return { file, parsed: parseYaml(content) }; 57 + }) 58 + .filter(({ parsed }) => parsed.defs?.main?.type === "record"); 59 + 60 + it.each(recordLexicons.map(({ file, parsed }) => [file, parsed]))( 61 + "%s has a valid record key type", 62 + (_file, parsed) => { 63 + const key = parsed.defs.main.key; 64 + expect(["tid", "literal:self"]).toContain(key); 65 + } 66 + ); 67 + }); 68 + 69 + describe("extensible fields use knownValues", () => { 70 + it("modAction.action uses knownValues, not enum", () => { 71 + const content = readFileSync( 72 + join(LEXICONS_DIR, "space/atbb/modAction.yaml"), 73 + "utf-8" 74 + ); 75 + const parsed = parseYaml(content); 76 + const actionField = 77 + parsed.defs.main.record?.properties?.action; 78 + expect(actionField).toBeDefined(); 79 + expect(actionField).not.toHaveProperty("enum"); 80 + expect(actionField).toHaveProperty("knownValues"); 81 + }); 82 + }); 83 + 84 + describe("strongRef definitions", () => { 85 + it("strongRef has uri and cid fields", () => { 86 + const content = readFileSync( 87 + join(LEXICONS_DIR, "com/atproto/repo/strongRef.yaml"), 88 + "utf-8" 89 + ); 90 + const parsed = parseYaml(content); 91 + const mainDef = parsed.defs.main; 92 + expect(mainDef.properties).toHaveProperty("uri"); 93 + expect(mainDef.properties).toHaveProperty("cid"); 94 + expect(mainDef.required).toContain("uri"); 95 + expect(mainDef.required).toContain("cid"); 96 + }); 97 + });
+4 -6
packages/lexicon/package.json
··· 14 14 "build": "pnpm run build:json && pnpm run build:types", 15 15 "build:json": "tsx scripts/build.ts", 16 16 "build:types": "bash -c 'shopt -s globstar && lex gen-api --yes ./dist/types ./dist/json/**/*.json'", 17 + "test": "vitest run", 17 18 "clean": "rm -rf dist" 18 19 }, 19 20 "devDependencies": { 20 21 "@atproto/lex-cli": "^0.5.0", 22 + "@types/node": "^22.0.0", 21 23 "glob": "^11.0.0", 22 24 "tsx": "^4.0.0", 25 + "typescript": "^5.7.0", 26 + "vitest": "^3.1.0", 23 27 "yaml": "^2.7.0" 24 - }, 25 - "dependencies": { 26 - "@atproto/api": "^0.15.0", 27 - "@atproto/lexicon": "^0.6.1", 28 - "@atproto/xrpc": "^0.7.7", 29 - "multiformats": "^13.4.2" 30 28 } 31 29 }
+7
packages/lexicon/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "node", 6 + }, 7 + });
+1
packages/web/package.json apps/web/package.json
··· 8 8 "dev": "tsx watch --env-file=../../.env src/index.ts", 9 9 "start": "node dist/index.js", 10 10 "lint": "tsc --noEmit", 11 + "test": "vitest run", 11 12 "clean": "rm -rf dist" 12 13 }, 13 14 "dependencies": {
packages/web/src/global.d.ts apps/web/src/global.d.ts
packages/web/src/index.ts apps/web/src/index.ts
packages/web/src/layouts/base.tsx apps/web/src/layouts/base.tsx
packages/web/src/lib/api.ts apps/web/src/lib/api.ts
packages/web/src/lib/config.ts apps/web/src/lib/config.ts
packages/web/src/routes/home.tsx apps/web/src/routes/home.tsx
packages/web/src/routes/index.ts apps/web/src/routes/index.ts
packages/web/tsconfig.json apps/web/tsconfig.json
+897 -48
pnpm-lock.yaml
··· 14 14 typescript: 15 15 specifier: ^5.7.0 16 16 version: 5.9.3 17 + vitest: 18 + specifier: ^4.0.18 19 + version: 4.0.18(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 17 20 18 - packages/appview: 21 + apps/appview: 19 22 dependencies: 23 + '@atbb/db': 24 + specifier: workspace:* 25 + version: link:../../packages/db 20 26 '@atbb/lexicon': 21 27 specifier: workspace:* 22 - version: link:../lexicon 28 + version: link:../../packages/lexicon 23 29 '@atproto/api': 24 30 specifier: ^0.15.0 25 31 version: 0.15.27 ··· 32 38 '@skyware/jetstream': 33 39 specifier: ^0.2.5 34 40 version: 0.2.5 35 - drizzle-orm: 36 - specifier: ^0.45.1 37 - version: 0.45.1(postgres@3.4.8) 38 41 hono: 39 42 specifier: ^4.7.0 40 43 version: 4.11.8 41 - postgres: 42 - specifier: ^3.4.8 43 - version: 3.4.8 44 44 devDependencies: 45 45 '@types/node': 46 46 specifier: ^22.0.0 ··· 54 54 typescript: 55 55 specifier: ^5.7.0 56 56 version: 5.9.3 57 + vitest: 58 + specifier: ^3.1.0 59 + version: 3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 60 + 61 + apps/web: 62 + dependencies: 63 + '@hono/node-server': 64 + specifier: ^1.14.0 65 + version: 1.19.9(hono@4.11.8) 66 + hono: 67 + specifier: ^4.7.0 68 + version: 4.11.8 69 + devDependencies: 70 + '@types/node': 71 + specifier: ^22.0.0 72 + version: 22.19.9 73 + tsx: 74 + specifier: ^4.0.0 75 + version: 4.21.0 76 + typed-htmx: 77 + specifier: ^0.3.0 78 + version: 0.3.1 79 + typescript: 80 + specifier: ^5.7.0 81 + version: 5.9.3 82 + 83 + packages/db: 84 + dependencies: 85 + drizzle-orm: 86 + specifier: ^0.45.1 87 + version: 0.45.1(postgres@3.4.8) 88 + postgres: 89 + specifier: ^3.4.8 90 + version: 3.4.8 91 + devDependencies: 92 + '@types/node': 93 + specifier: ^22.0.0 94 + version: 22.19.9 95 + typescript: 96 + specifier: ^5.7.0 97 + version: 5.9.3 57 98 58 99 packages/lexicon: 59 - dependencies: 60 - '@atproto/api': 61 - specifier: ^0.15.0 62 - version: 0.15.27 63 - '@atproto/lexicon': 64 - specifier: ^0.6.1 65 - version: 0.6.1 66 - '@atproto/xrpc': 67 - specifier: ^0.7.7 68 - version: 0.7.7 69 - multiformats: 70 - specifier: ^13.4.2 71 - version: 13.4.2 72 100 devDependencies: 73 101 '@atproto/lex-cli': 74 102 specifier: ^0.5.0 75 103 version: 0.5.7 104 + '@types/node': 105 + specifier: ^22.0.0 106 + version: 22.19.9 76 107 glob: 77 108 specifier: ^11.0.0 78 109 version: 11.1.0 79 110 tsx: 80 111 specifier: ^4.0.0 81 112 version: 4.21.0 113 + typescript: 114 + specifier: ^5.7.0 115 + version: 5.9.3 116 + vitest: 117 + specifier: ^3.1.0 118 + version: 3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 82 119 yaml: 83 120 specifier: ^2.7.0 84 121 version: 2.8.2 ··· 101 138 tsx: 102 139 specifier: ^4.0.0 103 140 version: 4.21.0 104 - typescript: 105 - specifier: ^5.7.0 106 - version: 5.9.3 107 - 108 - packages/web: 109 - dependencies: 110 - '@hono/node-server': 111 - specifier: ^1.14.0 112 - version: 1.19.9(hono@4.11.8) 113 - hono: 114 - specifier: ^4.7.0 115 - version: 4.11.8 116 - devDependencies: 117 - '@types/node': 118 - specifier: ^22.0.0 119 - version: 22.19.9 120 - tsx: 121 - specifier: ^4.0.0 122 - version: 4.21.0 123 - typed-htmx: 124 - specifier: ^0.3.0 125 - version: 0.3.1 126 141 typescript: 127 142 specifier: ^5.7.0 128 143 version: 5.9.3 ··· 649 664 resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} 650 665 engines: {node: '>=18'} 651 666 667 + '@jridgewell/sourcemap-codec@1.5.5': 668 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 669 + 652 670 '@nodelib/fs.scandir@2.1.5': 653 671 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 654 672 engines: {node: '>= 8'} ··· 661 679 resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 662 680 engines: {node: '>= 8'} 663 681 682 + '@rollup/rollup-android-arm-eabi@4.57.1': 683 + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} 684 + cpu: [arm] 685 + os: [android] 686 + 687 + '@rollup/rollup-android-arm64@4.57.1': 688 + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} 689 + cpu: [arm64] 690 + os: [android] 691 + 692 + '@rollup/rollup-darwin-arm64@4.57.1': 693 + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} 694 + cpu: [arm64] 695 + os: [darwin] 696 + 697 + '@rollup/rollup-darwin-x64@4.57.1': 698 + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} 699 + cpu: [x64] 700 + os: [darwin] 701 + 702 + '@rollup/rollup-freebsd-arm64@4.57.1': 703 + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} 704 + cpu: [arm64] 705 + os: [freebsd] 706 + 707 + '@rollup/rollup-freebsd-x64@4.57.1': 708 + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} 709 + cpu: [x64] 710 + os: [freebsd] 711 + 712 + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': 713 + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} 714 + cpu: [arm] 715 + os: [linux] 716 + 717 + '@rollup/rollup-linux-arm-musleabihf@4.57.1': 718 + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} 719 + cpu: [arm] 720 + os: [linux] 721 + 722 + '@rollup/rollup-linux-arm64-gnu@4.57.1': 723 + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} 724 + cpu: [arm64] 725 + os: [linux] 726 + 727 + '@rollup/rollup-linux-arm64-musl@4.57.1': 728 + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} 729 + cpu: [arm64] 730 + os: [linux] 731 + 732 + '@rollup/rollup-linux-loong64-gnu@4.57.1': 733 + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} 734 + cpu: [loong64] 735 + os: [linux] 736 + 737 + '@rollup/rollup-linux-loong64-musl@4.57.1': 738 + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} 739 + cpu: [loong64] 740 + os: [linux] 741 + 742 + '@rollup/rollup-linux-ppc64-gnu@4.57.1': 743 + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} 744 + cpu: [ppc64] 745 + os: [linux] 746 + 747 + '@rollup/rollup-linux-ppc64-musl@4.57.1': 748 + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} 749 + cpu: [ppc64] 750 + os: [linux] 751 + 752 + '@rollup/rollup-linux-riscv64-gnu@4.57.1': 753 + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} 754 + cpu: [riscv64] 755 + os: [linux] 756 + 757 + '@rollup/rollup-linux-riscv64-musl@4.57.1': 758 + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} 759 + cpu: [riscv64] 760 + os: [linux] 761 + 762 + '@rollup/rollup-linux-s390x-gnu@4.57.1': 763 + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} 764 + cpu: [s390x] 765 + os: [linux] 766 + 767 + '@rollup/rollup-linux-x64-gnu@4.57.1': 768 + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} 769 + cpu: [x64] 770 + os: [linux] 771 + 772 + '@rollup/rollup-linux-x64-musl@4.57.1': 773 + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} 774 + cpu: [x64] 775 + os: [linux] 776 + 777 + '@rollup/rollup-openbsd-x64@4.57.1': 778 + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} 779 + cpu: [x64] 780 + os: [openbsd] 781 + 782 + '@rollup/rollup-openharmony-arm64@4.57.1': 783 + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} 784 + cpu: [arm64] 785 + os: [openharmony] 786 + 787 + '@rollup/rollup-win32-arm64-msvc@4.57.1': 788 + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} 789 + cpu: [arm64] 790 + os: [win32] 791 + 792 + '@rollup/rollup-win32-ia32-msvc@4.57.1': 793 + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} 794 + cpu: [ia32] 795 + os: [win32] 796 + 797 + '@rollup/rollup-win32-x64-gnu@4.57.1': 798 + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} 799 + cpu: [x64] 800 + os: [win32] 801 + 802 + '@rollup/rollup-win32-x64-msvc@4.57.1': 803 + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} 804 + cpu: [x64] 805 + os: [win32] 806 + 664 807 '@skyware/jetstream@0.2.5': 665 808 resolution: {integrity: sha512-fM/zs03DLwqRyzZZJFWN20e76KrdqIp97Tlm8Cek+vxn96+tu5d/fx79V6H85L0QN6HvGiX2l9A8hWFqHvYlOA==} 666 809 ··· 670 813 '@ts-morph/common@0.17.0': 671 814 resolution: {integrity: sha512-RMSSvSfs9kb0VzkvQ2NWobwnj7TxCA9vI/IjR9bDHqgAyVbu2T0DN4wiKVqomyDWqO7dPr/tErSfq7urQ1Q37g==} 672 815 816 + '@types/chai@5.2.3': 817 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 818 + 819 + '@types/deep-eql@4.0.2': 820 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 821 + 822 + '@types/estree@1.0.8': 823 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 824 + 673 825 '@types/node@22.19.9': 674 826 resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} 827 + 828 + '@vitest/expect@3.2.4': 829 + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} 830 + 831 + '@vitest/expect@4.0.18': 832 + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} 833 + 834 + '@vitest/mocker@3.2.4': 835 + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} 836 + peerDependencies: 837 + msw: ^2.4.9 838 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 839 + peerDependenciesMeta: 840 + msw: 841 + optional: true 842 + vite: 843 + optional: true 844 + 845 + '@vitest/mocker@4.0.18': 846 + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} 847 + peerDependencies: 848 + msw: ^2.4.9 849 + vite: ^6.0.0 || ^7.0.0-0 850 + peerDependenciesMeta: 851 + msw: 852 + optional: true 853 + vite: 854 + optional: true 855 + 856 + '@vitest/pretty-format@3.2.4': 857 + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} 858 + 859 + '@vitest/pretty-format@4.0.18': 860 + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} 861 + 862 + '@vitest/runner@3.2.4': 863 + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} 864 + 865 + '@vitest/runner@4.0.18': 866 + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} 867 + 868 + '@vitest/snapshot@3.2.4': 869 + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} 870 + 871 + '@vitest/snapshot@4.0.18': 872 + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} 873 + 874 + '@vitest/spy@3.2.4': 875 + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} 876 + 877 + '@vitest/spy@4.0.18': 878 + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} 879 + 880 + '@vitest/utils@3.2.4': 881 + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} 882 + 883 + '@vitest/utils@4.0.18': 884 + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} 675 885 676 886 ansi-styles@4.3.0: 677 887 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 678 888 engines: {node: '>=8'} 679 889 890 + assertion-error@2.0.1: 891 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 892 + engines: {node: '>=12'} 893 + 680 894 await-lock@2.2.2: 681 895 resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 682 896 ··· 693 907 buffer-from@1.1.2: 694 908 resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} 695 909 910 + cac@6.7.14: 911 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} 912 + engines: {node: '>=8'} 913 + 914 + chai@5.3.3: 915 + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} 916 + engines: {node: '>=18'} 917 + 918 + chai@6.2.2: 919 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 920 + engines: {node: '>=18'} 921 + 696 922 chalk@4.1.2: 697 923 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 698 924 engines: {node: '>=10'} 925 + 926 + check-error@2.1.3: 927 + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} 928 + engines: {node: '>= 16'} 699 929 700 930 code-block-writer@11.0.3: 701 931 resolution: {integrity: sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw==} ··· 723 953 peerDependenciesMeta: 724 954 supports-color: 725 955 optional: true 956 + 957 + deep-eql@5.0.2: 958 + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 959 + engines: {node: '>=6'} 726 960 727 961 drizzle-kit@0.31.8: 728 962 resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} ··· 820 1054 sqlite3: 821 1055 optional: true 822 1056 1057 + es-module-lexer@1.7.0: 1058 + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 1059 + 823 1060 esbuild-register@3.6.0: 824 1061 resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} 825 1062 peerDependencies: ··· 842 1079 843 1080 esm-env@1.2.2: 844 1081 resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 1082 + 1083 + estree-walker@3.0.3: 1084 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 845 1085 846 1086 event-target-polyfill@0.0.4: 847 1087 resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} 848 1088 1089 + expect-type@1.3.0: 1090 + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 1091 + engines: {node: '>=12.0.0'} 1092 + 849 1093 fast-glob@3.3.3: 850 1094 resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} 851 1095 engines: {node: '>=8.6.0'} 852 1096 853 1097 fastq@1.20.1: 854 1098 resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} 1099 + 1100 + fdir@6.5.0: 1101 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 1102 + engines: {node: '>=12.0.0'} 1103 + peerDependencies: 1104 + picomatch: ^3 || ^4 1105 + peerDependenciesMeta: 1106 + picomatch: 1107 + optional: true 855 1108 856 1109 fill-range@7.1.1: 857 1110 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} ··· 909 1162 resolution: {integrity: sha512-GPBXyfcZSGujjddPeA+V34bW70ZJT7jzCEbloVasSH4yjiqWqXHX8iZQtZdVbOhc5esSeAIuiSmMutRZQB/olg==} 910 1163 engines: {node: 20 || >=22} 911 1164 1165 + js-tokens@9.0.1: 1166 + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} 1167 + 1168 + loupe@3.2.1: 1169 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} 1170 + 912 1171 lru-cache@11.2.5: 913 1172 resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} 914 1173 engines: {node: 20 || >=22} 1174 + 1175 + magic-string@0.30.21: 1176 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 915 1177 916 1178 merge2@1.4.1: 917 1179 resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} ··· 941 1203 ms@2.1.3: 942 1204 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 943 1205 944 - multiformats@13.4.2: 945 - resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} 946 - 947 1206 multiformats@9.9.0: 948 1207 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 949 1208 1209 + nanoid@3.3.11: 1210 + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 1211 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1212 + hasBin: true 1213 + 1214 + obug@2.1.1: 1215 + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 1216 + 950 1217 package-json-from-dist@1.0.1: 951 1218 resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 952 1219 ··· 964 1231 resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} 965 1232 engines: {node: 20 || >=22} 966 1233 1234 + pathe@2.0.3: 1235 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 1236 + 1237 + pathval@2.0.1: 1238 + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} 1239 + engines: {node: '>= 14.16'} 1240 + 1241 + picocolors@1.1.1: 1242 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 1243 + 967 1244 picomatch@2.3.1: 968 1245 resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 969 1246 engines: {node: '>=8.6'} 970 1247 1248 + picomatch@4.0.3: 1249 + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 1250 + engines: {node: '>=12'} 1251 + 1252 + postcss@8.5.6: 1253 + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} 1254 + engines: {node: ^10 || ^12 || >=14} 1255 + 971 1256 postgres@3.4.8: 972 1257 resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} 973 1258 engines: {node: '>=12'} ··· 987 1272 resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} 988 1273 engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 989 1274 1275 + rollup@4.57.1: 1276 + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} 1277 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1278 + hasBin: true 1279 + 990 1280 run-parallel@1.2.0: 991 1281 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 992 1282 ··· 997 1287 shebang-regex@3.0.0: 998 1288 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 999 1289 engines: {node: '>=8'} 1290 + 1291 + siginfo@2.0.0: 1292 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 1000 1293 1001 1294 signal-exit@4.1.0: 1002 1295 resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 1003 1296 engines: {node: '>=14'} 1004 1297 1298 + source-map-js@1.2.1: 1299 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 1300 + engines: {node: '>=0.10.0'} 1301 + 1005 1302 source-map-support@0.5.21: 1006 1303 resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} 1007 1304 ··· 1009 1306 resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 1010 1307 engines: {node: '>=0.10.0'} 1011 1308 1309 + stackback@0.0.2: 1310 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 1311 + 1312 + std-env@3.10.0: 1313 + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 1314 + 1315 + strip-literal@3.1.0: 1316 + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 1317 + 1012 1318 supports-color@7.2.0: 1013 1319 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 1014 1320 engines: {node: '>=8'} ··· 1016 1322 tiny-emitter@2.1.0: 1017 1323 resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} 1018 1324 1325 + tinybench@2.9.0: 1326 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 1327 + 1328 + tinyexec@0.3.2: 1329 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} 1330 + 1331 + tinyexec@1.0.2: 1332 + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} 1333 + engines: {node: '>=18'} 1334 + 1335 + tinyglobby@0.2.15: 1336 + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 1337 + engines: {node: '>=12.0.0'} 1338 + 1339 + tinypool@1.1.1: 1340 + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} 1341 + engines: {node: ^18.0.0 || >=20.0.0} 1342 + 1343 + tinyrainbow@2.0.0: 1344 + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} 1345 + engines: {node: '>=14.0.0'} 1346 + 1347 + tinyrainbow@3.0.3: 1348 + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} 1349 + engines: {node: '>=14.0.0'} 1350 + 1351 + tinyspy@4.0.4: 1352 + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} 1353 + engines: {node: '>=14.0.0'} 1354 + 1019 1355 tlds@1.261.0: 1020 1356 resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 1021 1357 hasBin: true ··· 1091 1427 unicode-segmenter@0.14.5: 1092 1428 resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 1093 1429 1430 + vite-node@3.2.4: 1431 + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} 1432 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} 1433 + hasBin: true 1434 + 1435 + vite@7.3.1: 1436 + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} 1437 + engines: {node: ^20.19.0 || >=22.12.0} 1438 + hasBin: true 1439 + peerDependencies: 1440 + '@types/node': ^20.19.0 || >=22.12.0 1441 + jiti: '>=1.21.0' 1442 + less: ^4.0.0 1443 + lightningcss: ^1.21.0 1444 + sass: ^1.70.0 1445 + sass-embedded: ^1.70.0 1446 + stylus: '>=0.54.8' 1447 + sugarss: ^5.0.0 1448 + terser: ^5.16.0 1449 + tsx: ^4.8.1 1450 + yaml: ^2.4.2 1451 + peerDependenciesMeta: 1452 + '@types/node': 1453 + optional: true 1454 + jiti: 1455 + optional: true 1456 + less: 1457 + optional: true 1458 + lightningcss: 1459 + optional: true 1460 + sass: 1461 + optional: true 1462 + sass-embedded: 1463 + optional: true 1464 + stylus: 1465 + optional: true 1466 + sugarss: 1467 + optional: true 1468 + terser: 1469 + optional: true 1470 + tsx: 1471 + optional: true 1472 + yaml: 1473 + optional: true 1474 + 1475 + vitest@3.2.4: 1476 + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} 1477 + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} 1478 + hasBin: true 1479 + peerDependencies: 1480 + '@edge-runtime/vm': '*' 1481 + '@types/debug': ^4.1.12 1482 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 1483 + '@vitest/browser': 3.2.4 1484 + '@vitest/ui': 3.2.4 1485 + happy-dom: '*' 1486 + jsdom: '*' 1487 + peerDependenciesMeta: 1488 + '@edge-runtime/vm': 1489 + optional: true 1490 + '@types/debug': 1491 + optional: true 1492 + '@types/node': 1493 + optional: true 1494 + '@vitest/browser': 1495 + optional: true 1496 + '@vitest/ui': 1497 + optional: true 1498 + happy-dom: 1499 + optional: true 1500 + jsdom: 1501 + optional: true 1502 + 1503 + vitest@4.0.18: 1504 + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} 1505 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 1506 + hasBin: true 1507 + peerDependencies: 1508 + '@edge-runtime/vm': '*' 1509 + '@opentelemetry/api': ^1.9.0 1510 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 1511 + '@vitest/browser-playwright': 4.0.18 1512 + '@vitest/browser-preview': 4.0.18 1513 + '@vitest/browser-webdriverio': 4.0.18 1514 + '@vitest/ui': 4.0.18 1515 + happy-dom: '*' 1516 + jsdom: '*' 1517 + peerDependenciesMeta: 1518 + '@edge-runtime/vm': 1519 + optional: true 1520 + '@opentelemetry/api': 1521 + optional: true 1522 + '@types/node': 1523 + optional: true 1524 + '@vitest/browser-playwright': 1525 + optional: true 1526 + '@vitest/browser-preview': 1527 + optional: true 1528 + '@vitest/browser-webdriverio': 1529 + optional: true 1530 + '@vitest/ui': 1531 + optional: true 1532 + happy-dom: 1533 + optional: true 1534 + jsdom: 1535 + optional: true 1536 + 1094 1537 which@2.0.2: 1095 1538 resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 1096 1539 engines: {node: '>= 8'} 1540 + hasBin: true 1541 + 1542 + why-is-node-running@2.3.0: 1543 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 1544 + engines: {node: '>=8'} 1097 1545 hasBin: true 1098 1546 1099 1547 yaml@2.8.2: ··· 1445 1893 1446 1894 '@isaacs/cliui@9.0.0': {} 1447 1895 1896 + '@jridgewell/sourcemap-codec@1.5.5': {} 1897 + 1448 1898 '@nodelib/fs.scandir@2.1.5': 1449 1899 dependencies: 1450 1900 '@nodelib/fs.stat': 2.0.5 ··· 1457 1907 '@nodelib/fs.scandir': 2.1.5 1458 1908 fastq: 1.20.1 1459 1909 1910 + '@rollup/rollup-android-arm-eabi@4.57.1': 1911 + optional: true 1912 + 1913 + '@rollup/rollup-android-arm64@4.57.1': 1914 + optional: true 1915 + 1916 + '@rollup/rollup-darwin-arm64@4.57.1': 1917 + optional: true 1918 + 1919 + '@rollup/rollup-darwin-x64@4.57.1': 1920 + optional: true 1921 + 1922 + '@rollup/rollup-freebsd-arm64@4.57.1': 1923 + optional: true 1924 + 1925 + '@rollup/rollup-freebsd-x64@4.57.1': 1926 + optional: true 1927 + 1928 + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': 1929 + optional: true 1930 + 1931 + '@rollup/rollup-linux-arm-musleabihf@4.57.1': 1932 + optional: true 1933 + 1934 + '@rollup/rollup-linux-arm64-gnu@4.57.1': 1935 + optional: true 1936 + 1937 + '@rollup/rollup-linux-arm64-musl@4.57.1': 1938 + optional: true 1939 + 1940 + '@rollup/rollup-linux-loong64-gnu@4.57.1': 1941 + optional: true 1942 + 1943 + '@rollup/rollup-linux-loong64-musl@4.57.1': 1944 + optional: true 1945 + 1946 + '@rollup/rollup-linux-ppc64-gnu@4.57.1': 1947 + optional: true 1948 + 1949 + '@rollup/rollup-linux-ppc64-musl@4.57.1': 1950 + optional: true 1951 + 1952 + '@rollup/rollup-linux-riscv64-gnu@4.57.1': 1953 + optional: true 1954 + 1955 + '@rollup/rollup-linux-riscv64-musl@4.57.1': 1956 + optional: true 1957 + 1958 + '@rollup/rollup-linux-s390x-gnu@4.57.1': 1959 + optional: true 1960 + 1961 + '@rollup/rollup-linux-x64-gnu@4.57.1': 1962 + optional: true 1963 + 1964 + '@rollup/rollup-linux-x64-musl@4.57.1': 1965 + optional: true 1966 + 1967 + '@rollup/rollup-openbsd-x64@4.57.1': 1968 + optional: true 1969 + 1970 + '@rollup/rollup-openharmony-arm64@4.57.1': 1971 + optional: true 1972 + 1973 + '@rollup/rollup-win32-arm64-msvc@4.57.1': 1974 + optional: true 1975 + 1976 + '@rollup/rollup-win32-ia32-msvc@4.57.1': 1977 + optional: true 1978 + 1979 + '@rollup/rollup-win32-x64-gnu@4.57.1': 1980 + optional: true 1981 + 1982 + '@rollup/rollup-win32-x64-msvc@4.57.1': 1983 + optional: true 1984 + 1460 1985 '@skyware/jetstream@0.2.5': 1461 1986 dependencies: 1462 1987 '@atcute/atproto': 3.1.10 ··· 1474 1999 mkdirp: 1.0.4 1475 2000 path-browserify: 1.0.1 1476 2001 2002 + '@types/chai@5.2.3': 2003 + dependencies: 2004 + '@types/deep-eql': 4.0.2 2005 + assertion-error: 2.0.1 2006 + 2007 + '@types/deep-eql@4.0.2': {} 2008 + 2009 + '@types/estree@1.0.8': {} 2010 + 1477 2011 '@types/node@22.19.9': 1478 2012 dependencies: 1479 2013 undici-types: 6.21.0 1480 2014 2015 + '@vitest/expect@3.2.4': 2016 + dependencies: 2017 + '@types/chai': 5.2.3 2018 + '@vitest/spy': 3.2.4 2019 + '@vitest/utils': 3.2.4 2020 + chai: 5.3.3 2021 + tinyrainbow: 2.0.0 2022 + 2023 + '@vitest/expect@4.0.18': 2024 + dependencies: 2025 + '@standard-schema/spec': 1.1.0 2026 + '@types/chai': 5.2.3 2027 + '@vitest/spy': 4.0.18 2028 + '@vitest/utils': 4.0.18 2029 + chai: 6.2.2 2030 + tinyrainbow: 3.0.3 2031 + 2032 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2))': 2033 + dependencies: 2034 + '@vitest/spy': 3.2.4 2035 + estree-walker: 3.0.3 2036 + magic-string: 0.30.21 2037 + optionalDependencies: 2038 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2039 + 2040 + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2))': 2041 + dependencies: 2042 + '@vitest/spy': 4.0.18 2043 + estree-walker: 3.0.3 2044 + magic-string: 0.30.21 2045 + optionalDependencies: 2046 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2047 + 2048 + '@vitest/pretty-format@3.2.4': 2049 + dependencies: 2050 + tinyrainbow: 2.0.0 2051 + 2052 + '@vitest/pretty-format@4.0.18': 2053 + dependencies: 2054 + tinyrainbow: 3.0.3 2055 + 2056 + '@vitest/runner@3.2.4': 2057 + dependencies: 2058 + '@vitest/utils': 3.2.4 2059 + pathe: 2.0.3 2060 + strip-literal: 3.1.0 2061 + 2062 + '@vitest/runner@4.0.18': 2063 + dependencies: 2064 + '@vitest/utils': 4.0.18 2065 + pathe: 2.0.3 2066 + 2067 + '@vitest/snapshot@3.2.4': 2068 + dependencies: 2069 + '@vitest/pretty-format': 3.2.4 2070 + magic-string: 0.30.21 2071 + pathe: 2.0.3 2072 + 2073 + '@vitest/snapshot@4.0.18': 2074 + dependencies: 2075 + '@vitest/pretty-format': 4.0.18 2076 + magic-string: 0.30.21 2077 + pathe: 2.0.3 2078 + 2079 + '@vitest/spy@3.2.4': 2080 + dependencies: 2081 + tinyspy: 4.0.4 2082 + 2083 + '@vitest/spy@4.0.18': {} 2084 + 2085 + '@vitest/utils@3.2.4': 2086 + dependencies: 2087 + '@vitest/pretty-format': 3.2.4 2088 + loupe: 3.2.1 2089 + tinyrainbow: 2.0.0 2090 + 2091 + '@vitest/utils@4.0.18': 2092 + dependencies: 2093 + '@vitest/pretty-format': 4.0.18 2094 + tinyrainbow: 3.0.3 2095 + 1481 2096 ansi-styles@4.3.0: 1482 2097 dependencies: 1483 2098 color-convert: 2.0.1 2099 + 2100 + assertion-error@2.0.1: {} 1484 2101 1485 2102 await-lock@2.2.2: {} 1486 2103 ··· 1496 2113 1497 2114 buffer-from@1.1.2: {} 1498 2115 2116 + cac@6.7.14: {} 2117 + 2118 + chai@5.3.3: 2119 + dependencies: 2120 + assertion-error: 2.0.1 2121 + check-error: 2.1.3 2122 + deep-eql: 5.0.2 2123 + loupe: 3.2.1 2124 + pathval: 2.0.1 2125 + 2126 + chai@6.2.2: {} 2127 + 1499 2128 chalk@4.1.2: 1500 2129 dependencies: 1501 2130 ansi-styles: 4.3.0 1502 2131 supports-color: 7.2.0 2132 + 2133 + check-error@2.1.3: {} 1503 2134 1504 2135 code-block-writer@11.0.3: {} 1505 2136 ··· 1521 2152 dependencies: 1522 2153 ms: 2.1.3 1523 2154 2155 + deep-eql@5.0.2: {} 2156 + 1524 2157 drizzle-kit@0.31.8: 1525 2158 dependencies: 1526 2159 '@drizzle-team/brocli': 0.10.2 ··· 1533 2166 drizzle-orm@0.45.1(postgres@3.4.8): 1534 2167 optionalDependencies: 1535 2168 postgres: 3.4.8 2169 + 2170 + es-module-lexer@1.7.0: {} 1536 2171 1537 2172 esbuild-register@3.6.0(esbuild@0.25.12): 1538 2173 dependencies: ··· 1626 2261 1627 2262 esm-env@1.2.2: {} 1628 2263 2264 + estree-walker@3.0.3: 2265 + dependencies: 2266 + '@types/estree': 1.0.8 2267 + 1629 2268 event-target-polyfill@0.0.4: {} 2269 + 2270 + expect-type@1.3.0: {} 1630 2271 1631 2272 fast-glob@3.3.3: 1632 2273 dependencies: ··· 1639 2280 fastq@1.20.1: 1640 2281 dependencies: 1641 2282 reusify: 1.1.0 2283 + 2284 + fdir@6.5.0(picomatch@4.0.3): 2285 + optionalDependencies: 2286 + picomatch: 4.0.3 1642 2287 1643 2288 fill-range@7.1.1: 1644 2289 dependencies: ··· 1689 2334 dependencies: 1690 2335 '@isaacs/cliui': 9.0.0 1691 2336 2337 + js-tokens@9.0.1: {} 2338 + 2339 + loupe@3.2.1: {} 2340 + 1692 2341 lru-cache@11.2.5: {} 2342 + 2343 + magic-string@0.30.21: 2344 + dependencies: 2345 + '@jridgewell/sourcemap-codec': 1.5.5 1693 2346 1694 2347 merge2@1.4.1: {} 1695 2348 ··· 1712 2365 1713 2366 ms@2.1.3: {} 1714 2367 1715 - multiformats@13.4.2: {} 2368 + multiformats@9.9.0: {} 2369 + 2370 + nanoid@3.3.11: {} 1716 2371 1717 - multiformats@9.9.0: {} 2372 + obug@2.1.1: {} 1718 2373 1719 2374 package-json-from-dist@1.0.1: {} 1720 2375 ··· 1731 2386 lru-cache: 11.2.5 1732 2387 minipass: 7.1.2 1733 2388 2389 + pathe@2.0.3: {} 2390 + 2391 + pathval@2.0.1: {} 2392 + 2393 + picocolors@1.1.1: {} 2394 + 1734 2395 picomatch@2.3.1: {} 1735 2396 2397 + picomatch@4.0.3: {} 2398 + 2399 + postcss@8.5.6: 2400 + dependencies: 2401 + nanoid: 3.3.11 2402 + picocolors: 1.1.1 2403 + source-map-js: 1.2.1 2404 + 1736 2405 postgres@3.4.8: {} 1737 2406 1738 2407 prettier@3.8.1: {} ··· 1743 2412 1744 2413 reusify@1.1.0: {} 1745 2414 2415 + rollup@4.57.1: 2416 + dependencies: 2417 + '@types/estree': 1.0.8 2418 + optionalDependencies: 2419 + '@rollup/rollup-android-arm-eabi': 4.57.1 2420 + '@rollup/rollup-android-arm64': 4.57.1 2421 + '@rollup/rollup-darwin-arm64': 4.57.1 2422 + '@rollup/rollup-darwin-x64': 4.57.1 2423 + '@rollup/rollup-freebsd-arm64': 4.57.1 2424 + '@rollup/rollup-freebsd-x64': 4.57.1 2425 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 2426 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 2427 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 2428 + '@rollup/rollup-linux-arm64-musl': 4.57.1 2429 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 2430 + '@rollup/rollup-linux-loong64-musl': 4.57.1 2431 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 2432 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 2433 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 2434 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 2435 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 2436 + '@rollup/rollup-linux-x64-gnu': 4.57.1 2437 + '@rollup/rollup-linux-x64-musl': 4.57.1 2438 + '@rollup/rollup-openbsd-x64': 4.57.1 2439 + '@rollup/rollup-openharmony-arm64': 4.57.1 2440 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 2441 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 2442 + '@rollup/rollup-win32-x64-gnu': 4.57.1 2443 + '@rollup/rollup-win32-x64-msvc': 4.57.1 2444 + fsevents: 2.3.3 2445 + 1746 2446 run-parallel@1.2.0: 1747 2447 dependencies: 1748 2448 queue-microtask: 1.2.3 ··· 1752 2452 shebang-regex: 3.0.0 1753 2453 1754 2454 shebang-regex@3.0.0: {} 2455 + 2456 + siginfo@2.0.0: {} 1755 2457 1756 2458 signal-exit@4.1.0: {} 1757 2459 2460 + source-map-js@1.2.1: {} 2461 + 1758 2462 source-map-support@0.5.21: 1759 2463 dependencies: 1760 2464 buffer-from: 1.1.2 ··· 1762 2466 1763 2467 source-map@0.6.1: {} 1764 2468 2469 + stackback@0.0.2: {} 2470 + 2471 + std-env@3.10.0: {} 2472 + 2473 + strip-literal@3.1.0: 2474 + dependencies: 2475 + js-tokens: 9.0.1 2476 + 1765 2477 supports-color@7.2.0: 1766 2478 dependencies: 1767 2479 has-flag: 4.0.0 1768 2480 1769 2481 tiny-emitter@2.1.0: {} 2482 + 2483 + tinybench@2.9.0: {} 2484 + 2485 + tinyexec@0.3.2: {} 2486 + 2487 + tinyexec@1.0.2: {} 2488 + 2489 + tinyglobby@0.2.15: 2490 + dependencies: 2491 + fdir: 6.5.0(picomatch@4.0.3) 2492 + picomatch: 4.0.3 2493 + 2494 + tinypool@1.1.1: {} 2495 + 2496 + tinyrainbow@2.0.0: {} 2497 + 2498 + tinyrainbow@3.0.3: {} 2499 + 2500 + tinyspy@4.0.4: {} 1770 2501 1771 2502 tlds@1.261.0: {} 1772 2503 ··· 1831 2562 1832 2563 unicode-segmenter@0.14.5: {} 1833 2564 2565 + vite-node@3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2): 2566 + dependencies: 2567 + cac: 6.7.14 2568 + debug: 4.4.3 2569 + es-module-lexer: 1.7.0 2570 + pathe: 2.0.3 2571 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2572 + transitivePeerDependencies: 2573 + - '@types/node' 2574 + - jiti 2575 + - less 2576 + - lightningcss 2577 + - sass 2578 + - sass-embedded 2579 + - stylus 2580 + - sugarss 2581 + - supports-color 2582 + - terser 2583 + - tsx 2584 + - yaml 2585 + 2586 + vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2): 2587 + dependencies: 2588 + esbuild: 0.27.3 2589 + fdir: 6.5.0(picomatch@4.0.3) 2590 + picomatch: 4.0.3 2591 + postcss: 8.5.6 2592 + rollup: 4.57.1 2593 + tinyglobby: 0.2.15 2594 + optionalDependencies: 2595 + '@types/node': 22.19.9 2596 + fsevents: 2.3.3 2597 + tsx: 4.21.0 2598 + yaml: 2.8.2 2599 + 2600 + vitest@3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2): 2601 + dependencies: 2602 + '@types/chai': 5.2.3 2603 + '@vitest/expect': 3.2.4 2604 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2)) 2605 + '@vitest/pretty-format': 3.2.4 2606 + '@vitest/runner': 3.2.4 2607 + '@vitest/snapshot': 3.2.4 2608 + '@vitest/spy': 3.2.4 2609 + '@vitest/utils': 3.2.4 2610 + chai: 5.3.3 2611 + debug: 4.4.3 2612 + expect-type: 1.3.0 2613 + magic-string: 0.30.21 2614 + pathe: 2.0.3 2615 + picomatch: 4.0.3 2616 + std-env: 3.10.0 2617 + tinybench: 2.9.0 2618 + tinyexec: 0.3.2 2619 + tinyglobby: 0.2.15 2620 + tinypool: 1.1.1 2621 + tinyrainbow: 2.0.0 2622 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2623 + vite-node: 3.2.4(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2624 + why-is-node-running: 2.3.0 2625 + optionalDependencies: 2626 + '@types/node': 22.19.9 2627 + transitivePeerDependencies: 2628 + - jiti 2629 + - less 2630 + - lightningcss 2631 + - msw 2632 + - sass 2633 + - sass-embedded 2634 + - stylus 2635 + - sugarss 2636 + - supports-color 2637 + - terser 2638 + - tsx 2639 + - yaml 2640 + 2641 + vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2): 2642 + dependencies: 2643 + '@vitest/expect': 4.0.18 2644 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2)) 2645 + '@vitest/pretty-format': 4.0.18 2646 + '@vitest/runner': 4.0.18 2647 + '@vitest/snapshot': 4.0.18 2648 + '@vitest/spy': 4.0.18 2649 + '@vitest/utils': 4.0.18 2650 + es-module-lexer: 1.7.0 2651 + expect-type: 1.3.0 2652 + magic-string: 0.30.21 2653 + obug: 2.1.1 2654 + pathe: 2.0.3 2655 + picomatch: 4.0.3 2656 + std-env: 3.10.0 2657 + tinybench: 2.9.0 2658 + tinyexec: 1.0.2 2659 + tinyglobby: 0.2.15 2660 + tinyrainbow: 3.0.3 2661 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0)(yaml@2.8.2) 2662 + why-is-node-running: 2.3.0 2663 + optionalDependencies: 2664 + '@types/node': 22.19.9 2665 + transitivePeerDependencies: 2666 + - jiti 2667 + - less 2668 + - lightningcss 2669 + - msw 2670 + - sass 2671 + - sass-embedded 2672 + - stylus 2673 + - sugarss 2674 + - terser 2675 + - tsx 2676 + - yaml 2677 + 1834 2678 which@2.0.2: 1835 2679 dependencies: 1836 2680 isexe: 2.0.0 2681 + 2682 + why-is-node-running@2.3.0: 2683 + dependencies: 2684 + siginfo: 2.0.0 2685 + stackback: 0.0.2 1837 2686 1838 2687 yaml@2.8.2: {} 1839 2688
+1
pnpm-workspace.yaml
··· 1 1 packages: 2 + - "apps/*" 2 3 - "packages/*"
+3
turbo.json
··· 14 14 "lint": { 15 15 "dependsOn": ["^build"] 16 16 }, 17 + "test": { 18 + "dependsOn": ["^build"] 19 + }, 17 20 "clean": { 18 21 "cache": false 19 22 }
+8
vitest.workspace.ts
··· 1 + import { defineWorkspace } from "vitest/config"; 2 + 3 + export default defineWorkspace([ 4 + "apps/appview", 5 + "apps/web", 6 + "packages/db", 7 + "packages/lexicon", 8 + ]);