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