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

docs: add database schema design document for ATB-7

+456
+456
docs/plans/2026-02-06-database-schema-design.md
··· 1 + # PostgreSQL Database Schema Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a PostgreSQL database schema to the AppView with Drizzle ORM, defining 6 tables for indexed AT Proto forum state. 6 + 7 + **Architecture:** Drizzle ORM provides schema-as-code in TypeScript. We define tables in `src/db/schema.ts`, configure a connection pool in `src/db/index.ts`, and use `drizzle-kit` to generate SQL migration files. The schema indexes AT Proto records from the firehose — it's a read-optimized mirror, not the source of truth. 8 + 9 + **Tech Stack:** Drizzle ORM, drizzle-kit, postgres (pg driver), PostgreSQL 10 + 11 + --- 12 + 13 + ### Task 1: Install Drizzle dependencies 14 + 15 + **Files:** 16 + - Modify: `packages/appview/package.json` 17 + 18 + **Step 1: Add runtime dependencies** 19 + 20 + Run from repo root: 21 + ```bash 22 + pnpm --filter @atbb/appview add drizzle-orm postgres 23 + ``` 24 + 25 + This adds: 26 + - `drizzle-orm` — the ORM / query builder 27 + - `postgres` — the PostgreSQL driver (postgres.js, not `pg`) 28 + 29 + **Step 2: Add dev dependencies** 30 + 31 + ```bash 32 + pnpm --filter @atbb/appview add -D drizzle-kit 33 + ``` 34 + 35 + This adds: 36 + - `drizzle-kit` — CLI for migration generation and management 37 + 38 + **Step 3: Verify build still passes** 39 + 40 + ```bash 41 + pnpm build 42 + ``` 43 + 44 + Expected: all 3 packages build successfully. 45 + 46 + **Step 4: Commit** 47 + 48 + ```bash 49 + git add packages/appview/package.json pnpm-lock.yaml 50 + git commit -m "feat(appview): add drizzle-orm and postgres dependencies" 51 + ``` 52 + 53 + --- 54 + 55 + ### Task 2: Define the database schema 56 + 57 + **Files:** 58 + - Create: `packages/appview/src/db/schema.ts` 59 + 60 + **Step 1: Create the schema file** 61 + 62 + Create `packages/appview/src/db/schema.ts` with the full schema: 63 + 64 + ```typescript 65 + import { 66 + pgTable, 67 + bigserial, 68 + text, 69 + timestamp, 70 + integer, 71 + boolean, 72 + bigint, 73 + uniqueIndex, 74 + index, 75 + } from "drizzle-orm/pg-core"; 76 + 77 + // ── forums ────────────────────────────────────────────── 78 + // Singleton forum metadata record, owned by Forum DID. 79 + // Key: literal:self (rkey is always "self"). 80 + export const forums = pgTable( 81 + "forums", 82 + { 83 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 84 + did: text("did").notNull(), 85 + rkey: text("rkey").notNull(), 86 + cid: text("cid").notNull(), 87 + name: text("name").notNull(), 88 + description: text("description"), 89 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 90 + }, 91 + (table) => [uniqueIndex("forums_did_rkey_idx").on(table.did, table.rkey)] 92 + ); 93 + 94 + // ── categories ────────────────────────────────────────── 95 + // Subforum / category definitions, owned by Forum DID. 96 + export const categories = pgTable( 97 + "categories", 98 + { 99 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 100 + did: text("did").notNull(), 101 + rkey: text("rkey").notNull(), 102 + cid: text("cid").notNull(), 103 + name: text("name").notNull(), 104 + description: text("description"), 105 + slug: text("slug"), 106 + sortOrder: integer("sort_order"), 107 + forumId: bigint("forum_id", { mode: "bigint" }).references(() => forums.id), 108 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 109 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 110 + }, 111 + (table) => [ 112 + uniqueIndex("categories_did_rkey_idx").on(table.did, table.rkey), 113 + ] 114 + ); 115 + 116 + // ── users ─────────────────────────────────────────────── 117 + // Known AT Proto identities. Populated when any record 118 + // from a DID is indexed. DID is the primary key. 119 + export const users = pgTable("users", { 120 + did: text("did").primaryKey(), 121 + handle: text("handle"), 122 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 123 + }); 124 + 125 + // ── memberships ───────────────────────────────────────── 126 + // User membership in a forum. Owned by user DID. 127 + // `did` is both the record owner and the member. 128 + export const memberships = pgTable( 129 + "memberships", 130 + { 131 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 132 + did: text("did") 133 + .notNull() 134 + .references(() => users.did), 135 + rkey: text("rkey").notNull(), 136 + cid: text("cid").notNull(), 137 + forumId: bigint("forum_id", { mode: "bigint" }).references( 138 + () => forums.id 139 + ), 140 + forumUri: text("forum_uri").notNull(), 141 + role: text("role"), 142 + roleUri: text("role_uri"), 143 + joinedAt: timestamp("joined_at", { withTimezone: true }), 144 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 145 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 146 + }, 147 + (table) => [ 148 + uniqueIndex("memberships_did_rkey_idx").on(table.did, table.rkey), 149 + index("memberships_did_idx").on(table.did), 150 + ] 151 + ); 152 + 153 + // ── posts ─────────────────────────────────────────────── 154 + // Unified post model. NULL root/parent = thread starter (topic). 155 + // Non-null root/parent = reply. Mirrors app.bsky.feed.post pattern. 156 + // Owned by user DID. 157 + export const posts = pgTable( 158 + "posts", 159 + { 160 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 161 + did: text("did") 162 + .notNull() 163 + .references(() => users.did), 164 + rkey: text("rkey").notNull(), 165 + cid: text("cid").notNull(), 166 + text: text("text").notNull(), 167 + forumUri: text("forum_uri"), 168 + rootPostId: bigint("root_post_id", { mode: "bigint" }).references( 169 + (): any => posts.id 170 + ), 171 + parentPostId: bigint("parent_post_id", { mode: "bigint" }).references( 172 + (): any => posts.id 173 + ), 174 + rootUri: text("root_uri"), 175 + parentUri: text("parent_uri"), 176 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 177 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 178 + deleted: boolean("deleted").notNull().default(false), 179 + }, 180 + (table) => [ 181 + uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 182 + index("posts_forum_uri_idx").on(table.forumUri), 183 + index("posts_root_post_id_idx").on(table.rootPostId), 184 + ] 185 + ); 186 + 187 + // ── mod_actions ───────────────────────────────────────── 188 + // Moderation actions, owned by Forum DID. Written by AppView 189 + // on behalf of authorized moderators after role verification. 190 + export const modActions = pgTable( 191 + "mod_actions", 192 + { 193 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 194 + did: text("did").notNull(), 195 + rkey: text("rkey").notNull(), 196 + cid: text("cid").notNull(), 197 + action: text("action").notNull(), 198 + subjectDid: text("subject_did"), 199 + subjectPostUri: text("subject_post_uri"), 200 + forumId: bigint("forum_id", { mode: "bigint" }).references( 201 + () => forums.id 202 + ), 203 + reason: text("reason"), 204 + createdBy: text("created_by").notNull(), 205 + expiresAt: timestamp("expires_at", { withTimezone: true }), 206 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 207 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 208 + }, 209 + (table) => [ 210 + uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 211 + ] 212 + ); 213 + ``` 214 + 215 + **Step 2: Verify build passes** 216 + 217 + ```bash 218 + pnpm build 219 + ``` 220 + 221 + Expected: all packages build successfully (schema file is valid TypeScript). 222 + 223 + **Step 3: Commit** 224 + 225 + ```bash 226 + git add packages/appview/src/db/schema.ts 227 + git commit -m "feat(appview): define database schema for all 6 tables" 228 + ``` 229 + 230 + --- 231 + 232 + ### Task 3: Create the database connection module 233 + 234 + **Files:** 235 + - Create: `packages/appview/src/db/index.ts` 236 + - Modify: `packages/appview/src/lib/config.ts` 237 + 238 + **Step 1: Add DATABASE_URL to config** 239 + 240 + Modify `packages/appview/src/lib/config.ts` to add `databaseUrl`: 241 + 242 + ```typescript 243 + export interface AppConfig { 244 + port: number; 245 + forumDid: string; 246 + pdsUrl: string; 247 + databaseUrl: string; 248 + } 249 + 250 + export function loadConfig(): AppConfig { 251 + return { 252 + port: parseInt(process.env.PORT ?? "3000", 10), 253 + forumDid: process.env.FORUM_DID ?? "", 254 + pdsUrl: process.env.PDS_URL ?? "https://bsky.social", 255 + databaseUrl: process.env.DATABASE_URL ?? "", 256 + }; 257 + } 258 + ``` 259 + 260 + **Step 2: Create the database connection module** 261 + 262 + Create `packages/appview/src/db/index.ts`: 263 + 264 + ```typescript 265 + import { drizzle } from "drizzle-orm/postgres-js"; 266 + import postgres from "postgres"; 267 + import * as schema from "./schema.js"; 268 + 269 + export function createDb(databaseUrl: string) { 270 + const client = postgres(databaseUrl); 271 + return drizzle(client, { schema }); 272 + } 273 + 274 + export type Database = ReturnType<typeof createDb>; 275 + 276 + export * from "./schema.js"; 277 + ``` 278 + 279 + **Step 3: Verify build passes** 280 + 281 + ```bash 282 + pnpm build 283 + ``` 284 + 285 + Expected: all packages build successfully. 286 + 287 + **Step 4: Commit** 288 + 289 + ```bash 290 + git add packages/appview/src/db/index.ts packages/appview/src/lib/config.ts 291 + git commit -m "feat(appview): add database connection module and DATABASE_URL config" 292 + ``` 293 + 294 + --- 295 + 296 + ### Task 4: Configure drizzle-kit and generate migrations 297 + 298 + **Files:** 299 + - Create: `packages/appview/drizzle.config.ts` 300 + 301 + **Step 1: Create drizzle-kit config** 302 + 303 + Create `packages/appview/drizzle.config.ts`: 304 + 305 + ```typescript 306 + import { defineConfig } from "drizzle-kit"; 307 + 308 + export default defineConfig({ 309 + schema: "./src/db/schema.ts", 310 + out: "./drizzle", 311 + dialect: "postgresql", 312 + }); 313 + ``` 314 + 315 + **Step 2: Add migration scripts to package.json** 316 + 317 + Add to `packages/appview/package.json` scripts: 318 + 319 + ```json 320 + { 321 + "db:generate": "drizzle-kit generate", 322 + "db:migrate": "drizzle-kit migrate" 323 + } 324 + ``` 325 + 326 + **Step 3: Generate the initial migration** 327 + 328 + Run from the appview package directory: 329 + 330 + ```bash 331 + pnpm --filter @atbb/appview db:generate 332 + ``` 333 + 334 + Expected: a migration SQL file appears in `packages/appview/drizzle/` with CREATE TABLE statements for all 6 tables. 335 + 336 + **Step 4: Inspect the generated SQL** 337 + 338 + Read the generated `.sql` file in `packages/appview/drizzle/` and verify it contains: 339 + - 6 CREATE TABLE statements (forums, categories, users, memberships, posts, mod_actions) 340 + - All UNIQUE indexes on (did, rkey) 341 + - All additional indexes (posts.forum_uri, posts.root_post_id, memberships.did) 342 + - All foreign key constraints 343 + 344 + **Step 5: Commit** 345 + 346 + ```bash 347 + git add packages/appview/drizzle.config.ts packages/appview/drizzle/ packages/appview/package.json 348 + git commit -m "feat(appview): add drizzle-kit config and generate initial migration" 349 + ``` 350 + 351 + --- 352 + 353 + ### Task 5: Update environment configuration 354 + 355 + **Files:** 356 + - Modify: `.env.example` 357 + 358 + **Step 1: Add DATABASE_URL to .env.example** 359 + 360 + Add to `.env.example`: 361 + 362 + ```bash 363 + # Database 364 + DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb 365 + ``` 366 + 367 + **Step 2: Commit** 368 + 369 + ```bash 370 + git add .env.example 371 + git commit -m "feat: add DATABASE_URL to .env.example" 372 + ``` 373 + 374 + --- 375 + 376 + ### Task 6: Verify migrations run on fresh Postgres 377 + 378 + **Step 1: Start a temporary Postgres instance** 379 + 380 + Using Docker: 381 + 382 + ```bash 383 + docker run --rm --name atbb-pg-test -e POSTGRES_USER=atbb -e POSTGRES_PASSWORD=atbb -e POSTGRES_DB=atbb -p 5432:5432 -d postgres:17 384 + ``` 385 + 386 + Wait for it to be ready: 387 + 388 + ```bash 389 + until docker exec atbb-pg-test pg_isready -U atbb; do sleep 1; done 390 + ``` 391 + 392 + **Step 2: Run migrations** 393 + 394 + ```bash 395 + DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb pnpm --filter @atbb/appview db:migrate 396 + ``` 397 + 398 + Expected: migrations complete successfully, all 6 tables created. 399 + 400 + **Step 3: Verify tables exist** 401 + 402 + ```bash 403 + docker exec atbb-pg-test psql -U atbb -d atbb -c '\dt' 404 + ``` 405 + 406 + Expected: lists forums, categories, users, memberships, posts, mod_actions tables (plus drizzle's internal migration tracking table). 407 + 408 + **Step 4: Verify indexes exist** 409 + 410 + ```bash 411 + docker exec atbb-pg-test psql -U atbb -d atbb -c '\di' 412 + ``` 413 + 414 + Expected: lists all primary key indexes, unique indexes on (did, rkey) for each record table, plus posts_forum_uri_idx, posts_root_post_id_idx, memberships_did_idx. 415 + 416 + **Step 5: Clean up** 417 + 418 + ```bash 419 + docker stop atbb-pg-test 420 + ``` 421 + 422 + **Step 6: No commit needed** — this was verification only. 423 + 424 + --- 425 + 426 + ### Task 7: Final build verification and docs update 427 + 428 + **Step 1: Full build from repo root** 429 + 430 + ```bash 431 + pnpm build 432 + ``` 433 + 434 + Expected: all packages build successfully. 435 + 436 + **Step 2: Commit the design doc (if not already committed)** 437 + 438 + ```bash 439 + git add docs/plans/2026-02-06-database-schema-design.md 440 + git commit -m "docs: add database schema design document for ATB-7" 441 + ``` 442 + 443 + --- 444 + 445 + ## Summary of files created/modified 446 + 447 + | Action | File | 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) | 455 + | Modify | `.env.example` | 456 + | Create | `docs/plans/2026-02-06-database-schema-design.md` |