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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

PostgreSQL Database Schema Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a PostgreSQL database schema to the AppView with Drizzle ORM, defining 6 tables for indexed AT Proto forum state.

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.

Tech Stack: Drizzle ORM, drizzle-kit, postgres (pg driver), PostgreSQL


Task 1: Install Drizzle dependencies#

Files:

  • Modify: apps/appview/package.json

Step 1: Add runtime dependencies

Run from repo root:

pnpm --filter @atbb/appview add drizzle-orm postgres

This adds:

  • drizzle-orm — the ORM / query builder
  • postgres — the PostgreSQL driver (postgres.js, not pg)

Step 2: Add dev dependencies

pnpm --filter @atbb/appview add -D drizzle-kit

This adds:

  • drizzle-kit — CLI for migration generation and management

Step 3: Verify build still passes

pnpm build

Expected: all 3 packages build successfully.

Step 4: Commit

git add apps/appview/package.json pnpm-lock.yaml
git commit -m "feat(appview): add drizzle-orm and postgres dependencies"

Task 2: Define the database schema#

Files:

  • Create: apps/appview/src/db/schema.ts

Step 1: Create the schema file

Create apps/appview/src/db/schema.ts with the full schema:

import {
  pgTable,
  bigserial,
  text,
  timestamp,
  integer,
  boolean,
  bigint,
  uniqueIndex,
  index,
} from "drizzle-orm/pg-core";

// ── forums ──────────────────────────────────────────────
// Singleton forum metadata record, owned by Forum DID.
// Key: literal:self (rkey is always "self").
export const forums = pgTable(
  "forums",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did").notNull(),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    name: text("name").notNull(),
    description: text("description"),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
  },
  (table) => [uniqueIndex("forums_did_rkey_idx").on(table.did, table.rkey)]
);

// ── categories ──────────────────────────────────────────
// Subforum / category definitions, owned by Forum DID.
export const categories = pgTable(
  "categories",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did").notNull(),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    name: text("name").notNull(),
    description: text("description"),
    slug: text("slug"),
    sortOrder: integer("sort_order"),
    forumId: bigint("forum_id", { mode: "bigint" }).references(() => forums.id),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
  },
  (table) => [
    uniqueIndex("categories_did_rkey_idx").on(table.did, table.rkey),
  ]
);

// ── users ───────────────────────────────────────────────
// Known AT Proto identities. Populated when any record
// from a DID is indexed. DID is the primary key.
export const users = pgTable("users", {
  did: text("did").primaryKey(),
  handle: text("handle"),
  indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
});

// ── memberships ─────────────────────────────────────────
// User membership in a forum. Owned by user DID.
// `did` is both the record owner and the member.
export const memberships = pgTable(
  "memberships",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did")
      .notNull()
      .references(() => users.did),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    forumId: bigint("forum_id", { mode: "bigint" }).references(
      () => forums.id
    ),
    forumUri: text("forum_uri").notNull(),
    role: text("role"),
    roleUri: text("role_uri"),
    joinedAt: timestamp("joined_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
  },
  (table) => [
    uniqueIndex("memberships_did_rkey_idx").on(table.did, table.rkey),
    index("memberships_did_idx").on(table.did),
  ]
);

// ── posts ───────────────────────────────────────────────
// Unified post model. NULL root/parent = thread starter (topic).
// Non-null root/parent = reply. Mirrors app.bsky.feed.post pattern.
// Owned by user DID.
export const posts = pgTable(
  "posts",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did")
      .notNull()
      .references(() => users.did),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    text: text("text").notNull(),
    forumUri: text("forum_uri"),
    rootPostId: bigint("root_post_id", { mode: "bigint" }).references(
      (): any => posts.id
    ),
    parentPostId: bigint("parent_post_id", { mode: "bigint" }).references(
      (): any => posts.id
    ),
    rootUri: text("root_uri"),
    parentUri: text("parent_uri"),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
    deleted: boolean("deleted").notNull().default(false),
  },
  (table) => [
    uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey),
    index("posts_forum_uri_idx").on(table.forumUri),
    index("posts_root_post_id_idx").on(table.rootPostId),
  ]
);

// ── mod_actions ─────────────────────────────────────────
// Moderation actions, owned by Forum DID. Written by AppView
// on behalf of authorized moderators after role verification.
export const modActions = pgTable(
  "mod_actions",
  {
    id: bigserial("id", { mode: "bigint" }).primaryKey(),
    did: text("did").notNull(),
    rkey: text("rkey").notNull(),
    cid: text("cid").notNull(),
    action: text("action").notNull(),
    subjectDid: text("subject_did"),
    subjectPostUri: text("subject_post_uri"),
    forumId: bigint("forum_id", { mode: "bigint" }).references(
      () => forums.id
    ),
    reason: text("reason"),
    createdBy: text("created_by").notNull(),
    expiresAt: timestamp("expires_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
    indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
  },
  (table) => [
    uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey),
  ]
);

Step 2: Verify build passes

pnpm build

Expected: all packages build successfully (schema file is valid TypeScript).

Step 3: Commit

git add apps/appview/src/db/schema.ts
git commit -m "feat(appview): define database schema for all 6 tables"

Task 3: Create the database connection module#

Files:

  • Create: apps/appview/src/db/index.ts
  • Modify: apps/appview/src/lib/config.ts

Step 1: Add DATABASE_URL to config

Modify apps/appview/src/lib/config.ts to add databaseUrl:

export interface AppConfig {
  port: number;
  forumDid: string;
  pdsUrl: string;
  databaseUrl: string;
}

export function loadConfig(): AppConfig {
  return {
    port: parseInt(process.env.PORT ?? "3000", 10),
    forumDid: process.env.FORUM_DID ?? "",
    pdsUrl: process.env.PDS_URL ?? "https://bsky.social",
    databaseUrl: process.env.DATABASE_URL ?? "",
  };
}

Step 2: Create the database connection module

Create apps/appview/src/db/index.ts:

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";

export function createDb(databaseUrl: string) {
  const client = postgres(databaseUrl);
  return drizzle(client, { schema });
}

export type Database = ReturnType<typeof createDb>;

export * from "./schema.js";

Step 3: Verify build passes

pnpm build

Expected: all packages build successfully.

Step 4: Commit

git add apps/appview/src/db/index.ts apps/appview/src/lib/config.ts
git commit -m "feat(appview): add database connection module and DATABASE_URL config"

Task 4: Configure drizzle-kit and generate migrations#

Files:

  • Create: apps/appview/drizzle.config.ts

Step 1: Create drizzle-kit config

Create apps/appview/drizzle.config.ts:

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
});

Step 2: Add migration scripts to package.json

Add to apps/appview/package.json scripts:

{
  "db:generate": "drizzle-kit generate",
  "db:migrate": "drizzle-kit migrate"
}

Step 3: Generate the initial migration

Run from the appview package directory:

pnpm --filter @atbb/appview db:generate

Expected: a migration SQL file appears in apps/appview/drizzle/ with CREATE TABLE statements for all 6 tables.

Step 4: Inspect the generated SQL

Read the generated .sql file in apps/appview/drizzle/ and verify it contains:

  • 6 CREATE TABLE statements (forums, categories, users, memberships, posts, mod_actions)
  • All UNIQUE indexes on (did, rkey)
  • All additional indexes (posts.forum_uri, posts.root_post_id, memberships.did)
  • All foreign key constraints

Step 5: Commit

git add apps/appview/drizzle.config.ts apps/appview/drizzle/ apps/appview/package.json
git commit -m "feat(appview): add drizzle-kit config and generate initial migration"

Task 5: Update environment configuration#

Files:

  • Modify: .env.example

Step 1: Add DATABASE_URL to .env.example

Add to .env.example:

# Database
DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb

Step 2: Commit

git add .env.example
git commit -m "feat: add DATABASE_URL to .env.example"

Task 6: Verify migrations run on fresh Postgres#

Step 1: Start a temporary Postgres instance

Using Docker:

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

Wait for it to be ready:

until docker exec atbb-pg-test pg_isready -U atbb; do sleep 1; done

Step 2: Run migrations

DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb pnpm --filter @atbb/appview db:migrate

Expected: migrations complete successfully, all 6 tables created.

Step 3: Verify tables exist

docker exec atbb-pg-test psql -U atbb -d atbb -c '\dt'

Expected: lists forums, categories, users, memberships, posts, mod_actions tables (plus drizzle's internal migration tracking table).

Step 4: Verify indexes exist

docker exec atbb-pg-test psql -U atbb -d atbb -c '\di'

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.

Step 5: Clean up

docker stop atbb-pg-test

Step 6: No commit needed — this was verification only.


Task 7: Final build verification and docs update#

Step 1: Full build from repo root

pnpm build

Expected: all packages build successfully.

Step 2: Commit the design doc (if not already committed)

git add docs/plans/2026-02-06-database-schema-design.md
git commit -m "docs: add database schema design document for ATB-7"

Summary of files created/modified#

Action File
Modify apps/appview/package.json (deps + scripts)
Create apps/appview/src/db/schema.ts
Create apps/appview/src/db/index.ts
Modify apps/appview/src/lib/config.ts
Create apps/appview/drizzle.config.ts
Create apps/appview/drizzle/*.sql (generated)
Modify .env.example
Create docs/plans/2026-02-06-database-schema-design.md