tangled
alpha
login
or
join now
malpercio.dev
/
atbb
5
fork
atom
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
5
fork
atom
overview
issues
pulls
pipelines
docs: add database schema design document for ATB-7
malpercio.dev
1 month ago
358a8d56
8c47d8ae
+456
1 changed file
expand all
collapse all
unified
split
docs
plans
2026-02-06-database-schema-design.md
+456
docs/plans/2026-02-06-database-schema-design.md
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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` |