atBB Boards Hierarchy Design#
Issue: ATB-23 - Add categoryUri column to posts schema Date: 2026-02-13 Status: Approved
Context#
The original ATB-23 scope was to add categoryUri to posts for category filtering. During design exploration, we discovered a fundamental mismatch between atBB's current 2-level hierarchy and traditional BB forum structure (phpBB, SMF).
Traditional BB forums use:
Forum (instance) → Categories (groupings) → Boards (postable areas) → Topics → Replies
atBB currently has:
Forum (instance) → Categories (postable areas) → Topics → Replies
The current categories table is serving the role of "boards" (postable areas), not groupings. This restructuring aligns atBB with traditional forum UX expectations.
Decision#
Restructure atBB to use the traditional 3-level hierarchy by introducing boards as the postable areas, with categories becoming non-postable groupings.
No migration needed: There are no published records in production, only local development data.
Data Model#
New Hierarchy#
space.atbb.forum.forum # Forum instance (singleton, Forum DID)
└─ space.atbb.forum.category # Category groupings (non-postable, Forum DID)
└─ space.atbb.forum.board # Boards (postable areas, Forum DID)
└─ space.atbb.post # Topics + replies (User DID)
Database Schema Changes#
1. Keep categories table (semantic shift to groupings):
- No structural changes
- Now represents non-postable category groupings
2. Create new boards table:
export const boards = pgTable("boards", {
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"),
categoryId: bigint("category_id", { mode: "bigint" }).references(() => categories.id),
categoryUri: text("category_uri").notNull(), // For out-of-order indexing
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
}, (table) => [
uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey),
index("boards_category_id_idx").on(table.categoryId),
]);
3. Update posts table:
export const posts = pgTable("posts", {
// ... existing fields ...
forumUri: text("forum_uri"), // KEEP - forum instance reference (for client flexibility)
boardUri: text("board_uri"), // NEW - board reference
boardId: bigint("board_id", { mode: "bigint" }).references(() => boards.id), // NEW
rootPostId: bigint("root_post_id", { mode: "bigint" }).references((): any => posts.id),
parentPostId: bigint("parent_post_id", { mode: "bigint" }).references((): any => posts.id),
// ... rest of fields ...
}, (table) => [
// ... existing indexes ...
index("posts_board_id_idx").on(table.boardId),
index("posts_board_uri_idx").on(table.boardUri),
]);
Rationale for keeping forumUri:
- Maintains flexibility for clients with different UX models
- Some clients may want flat view (forums → topics) without board hierarchy
- Storage is cheap, redundancy aids AT Proto interoperability
Lexicon Changes#
New Lexicon: space.atbb.forum.board#
lexicon: 1
id: space.atbb.forum.board
defs:
main:
type: record
description: A board (subforum) within a category. Owned by Forum DID.
key: tid
record:
type: object
required: [name, category, createdAt]
properties:
name:
type: string
maxLength: 300
maxGraphemes: 100
description: Display name of the board.
description:
type: string
maxLength: 3000
maxGraphemes: 300
description: A short description for the board.
slug:
type: string
maxLength: 100
description: URL-friendly identifier.
sortOrder:
type: integer
minimum: 0
description: Numeric sort position. Lower values appear first.
category:
type: ref
ref: "#categoryRef"
createdAt:
type: string
format: datetime
categoryRef:
type: object
required: [category]
properties:
category:
type: ref
ref: com.atproto.repo.strongRef
Updated Lexicon: space.atbb.post#
Add board reference (keep existing forum reference):
properties:
text: ...
forum: # KEEP - existing forum reference
type: ref
ref: "#forumRef"
board: # NEW - board reference
type: ref
ref: "#boardRef"
reply: ...
createdAt: ...
defs:
# Existing forumRef stays unchanged
forumRef:
type: object
required: [forum]
properties:
forum:
type: ref
ref: com.atproto.repo.strongRef
# New boardRef
boardRef:
type: object
required: [board]
properties:
board:
type: ref
ref: com.atproto.repo.strongRef
API Endpoints#
New Endpoints#
Boards:
GET /api/boards # List all boards (with category grouping)
GET /api/boards/:id # Get board details
GET /api/boards/:id/topics # List topics in a board (paginated by page number)
Categories (enhanced):
GET /api/categories # Existing - list all categories
GET /api/categories/:id/boards # NEW - list boards in a category
Updated Endpoints#
Topics:
POST /api/topics
Body: { text: string, boardUri: string }
- boardUri is REQUIRED
- forumUri is always the configured singleton (not accepted as input)
- Validates board exists before writing to PDS
- Writes both forum and board refs to PDS
GET /api/topics/:id
- No changes
Endpoint Details#
GET /api/boards/:id/topics:
- Returns topics (posts with NULL root) in the board
- Paginated using page numbers (traditional BB forum style)
- Sorted by last reply time (descending)
- Includes metadata: reply count, last post time, author info
GET /api/categories/:id/boards:
- Returns boards in a category
- Sorted by
sortOrder - Includes board metadata + topic count (e.g., "25 topics, 143 posts")
GET /api/boards:
- Returns all boards grouped by category
- Includes category metadata and board counts
Indexer Changes#
New Board Config#
private boardConfig: CollectionConfig<Board.Record> = {
name: "Board",
table: boards,
deleteStrategy: "hard",
toInsertValues: async (event, record, tx) => {
const categoryId = await this.getCategoryIdByUri(record.category.category.uri, tx);
if (!categoryId) {
console.warn(`[CREATE] Board: Category not found for URI ${record.category.category.uri}`);
return null;
}
return {
did: event.did,
rkey: event.commit.rkey,
cid: event.commit.cid,
name: record.name,
description: record.description ?? null,
slug: record.slug ?? null,
sortOrder: record.sortOrder ?? null,
categoryId,
categoryUri: record.category.category.uri,
createdAt: new Date(record.createdAt),
indexedAt: new Date(),
};
},
toUpdateValues: async (event, record, tx) => {
const categoryId = await this.getCategoryIdByUri(record.category.category.uri, tx);
return {
cid: event.commit.cid,
name: record.name,
description: record.description ?? null,
slug: record.slug ?? null,
sortOrder: record.sortOrder ?? null,
categoryId,
categoryUri: record.category.category.uri,
indexedAt: new Date(),
};
},
};
Updated Post Config#
private postConfig: CollectionConfig<Post.Record> = {
// ... existing config ...
toInsertValues: async (event, record, tx) => {
// Look up board for the post
let boardId: bigint | null = null;
if (record.board) {
boardId = await this.getBoardIdByUri(record.board.board.uri, tx);
}
// Look up parent/root for replies (existing logic)
let rootId: bigint | null = null;
let parentId: bigint | null = null;
if (Post.isReplyRef(record.reply)) {
rootId = await this.getPostIdByUri(record.reply.root.uri, tx);
parentId = await this.getPostIdByUri(record.reply.parent.uri, tx);
}
return {
did: event.did,
rkey: event.commit.rkey,
cid: event.commit.cid,
text: record.text,
forumUri: record.forum?.forum.uri ?? null, // KEEP
boardUri: record.board?.board.uri ?? null, // NEW
boardId, // NEW
rootPostId: rootId,
rootUri: record.reply?.root.uri ?? null,
parentPostId: parentId,
parentUri: record.reply?.parent.uri ?? null,
createdAt: new Date(record.createdAt),
indexedAt: new Date(),
};
},
toUpdateValues: async (event, record) => ({
cid: event.commit.cid,
text: record.text,
forumUri: record.forum?.forum.uri ?? null, // KEEP
boardUri: record.board?.board.uri ?? null, // NEW
indexedAt: new Date(),
}),
};
New Helper Methods#
private async getBoardIdByUri(uri: string, tx: DbOrTransaction): Promise<bigint | null> {
const { did, rkey } = parseAtUri(uri);
const [result] = await tx
.select({ id: boards.id })
.from(boards)
.where(and(eq(boards.did, did), eq(boards.rkey, rkey)))
.limit(1);
return result?.id ?? null;
}
private async getCategoryIdByUri(uri: string, tx: DbOrTransaction): Promise<bigint | null> {
const { did, rkey } = parseAtUri(uri);
const [result] = await tx
.select({ id: categories.id })
.from(categories)
.where(and(eq(categories.did, did), eq(categories.rkey, rkey)))
.limit(1);
return result?.id ?? null;
}
FirehoseService Registration#
.register({
collection: "space.atbb.forum.board",
onCreate: this.createWrappedHandler("handleBoardCreate"),
onUpdate: this.createWrappedHandler("handleBoardUpdate"),
onDelete: this.createWrappedHandler("handleBoardDelete"),
})
Write-Path Implementation#
Updated POST /api/topics#
.post("/", requireAuth(ctx), async (c) => {
const user = c.get("user")!;
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON in request body" }, 400);
}
const { text, boardUri } = body;
// Validate text
const validation = validatePostText(text);
if (!validation.valid) {
return c.json({ error: validation.error }, 400);
}
// Validate boardUri is required
if (typeof boardUri !== "string" || !boardUri.trim()) {
return c.json({ error: "boardUri is required" }, 400);
}
try {
// Always use the configured singleton forum
const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
// Look up forum to get CID
const forum = await getForumByUri(ctx.db, forumUri);
if (!forum) {
return c.json({ error: "Forum not found" }, 404);
}
// Look up board to get CID
const board = await getBoardByUri(ctx.db, boardUri);
if (!board) {
return c.json({ error: "Board not found" }, 404);
}
// Generate TID for rkey
const rkey = TID.nextStr();
// Write to user's PDS
const result = await user.agent.com.atproto.repo.putRecord({
repo: user.did,
collection: "space.atbb.post",
rkey,
record: {
$type: "space.atbb.post",
text: validation.trimmed!,
forum: {
forum: { uri: forumUri, cid: forum.cid },
},
board: {
board: { uri: boardUri, cid: board.cid },
},
createdAt: new Date().toISOString(),
},
});
return c.json({ uri: result.data.uri, cid: result.data.cid, rkey }, 201);
} catch (error) {
// ... existing error handling ...
}
});
New Helper Function#
export async function getBoardByUri(
db: Database,
uri: string
): Promise<{ cid: string } | null> {
const { did, rkey } = parseAtUri(uri);
const [result] = await db
.select({ cid: boards.cid })
.from(boards)
.where(and(eq(boards.did, did), eq(boards.rkey, rkey)))
.limit(1);
return result ?? null;
}
Database Migrations#
Migration 1: Create boards table
CREATE TABLE boards (
id BIGSERIAL PRIMARY KEY,
did TEXT NOT NULL,
rkey TEXT NOT NULL,
cid TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
slug TEXT,
sort_order INTEGER,
category_id BIGINT REFERENCES categories(id),
category_uri TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX boards_did_rkey_idx ON boards(did, rkey);
CREATE INDEX boards_category_id_idx ON boards(category_id);
Migration 2: Update posts table
ALTER TABLE posts ADD COLUMN board_uri TEXT;
ALTER TABLE posts ADD COLUMN board_id BIGINT REFERENCES boards(id);
CREATE INDEX posts_board_id_idx ON posts(board_id);
CREATE INDEX posts_board_uri_idx ON posts(board_uri);
Testing Strategy#
Test Coverage#
Lexicon:
- ✅
space.atbb.forum.boardgenerates correct TypeScript types - ✅ Post type includes both
forumandboardrefs
Indexer:
- ✅ Board create/update/delete events
- ✅ Posts with board references get indexed correctly
- ✅ Handles missing board gracefully (logs warning, skips insert)
- ✅ Category references resolve correctly
API Endpoints:
- ✅
GET /api/boardsreturns all boards grouped by category - ✅
GET /api/boards/:idreturns board details - ✅
GET /api/boards/:id/topicsreturns paginated topics with metadata (reply count, last post time, author) - ✅
GET /api/categories/:id/boardsreturns boards in category with topic counts - ✅
POST /api/topicsrequires boardUri, validates board exists - ✅
POST /api/topicswrites both forum and board refs to PDS
Error Cases:
- ✅ Invalid boardUri format (400)
- ✅ Board not found (404)
- ✅ Missing boardUri in POST request (400)
- ✅ Malformed JSON in POST request (400)
Integration:
- ✅ End-to-end: Create board → Create topic → View in board → Reply to topic
- ✅ Pagination works correctly with page numbers
- ✅ Topic counts update correctly
Implementation Phases#
Phase 1: Lexicon & Schema
- Create
space.atbb.forum.boardlexicon - Update
space.atbb.postlexicon with board reference - Regenerate TypeScript types
- Create database migration for boards table
- Create database migration for posts table updates
Phase 2: Indexer
- Add board config to Indexer
- Update post config to extract boardUri
- Add helper methods (getBoardIdByUri, getCategoryIdByUri)
- Register board handlers in FirehoseService
- Write indexer tests
Phase 3: API - Boards
- Create
GET /api/boardsendpoint - Create
GET /api/boards/:idendpoint - Create
GET /api/boards/:id/topicsendpoint with pagination - Create
GET /api/categories/:id/boardsendpoint - Write API tests
Phase 4: API - Topics
- Update
POST /api/topicsto require boardUri - Add getBoardByUri helper
- Update request validation
- Update PDS write to include board reference
- Write API tests
Phase 5: Bruno Collections
- Add board endpoints to Bruno collection
- Update topic creation request with boardUri
- Document all request/response formats
- Add error case examples
Success Criteria#
- ✅ Boards can be created and indexed from firehose
- ✅ Topics require board assignment
- ✅ API returns proper hierarchy: categories → boards → topics
- ✅ Pagination works with page numbers
- ✅ Topic metadata (reply count, last post) displays correctly
- ✅ All tests pass
- ✅ Bruno collections updated and tested
- ✅ Documentation updated (plan doc, Linear issue)
Related Issues#
- ATB-23: Original issue (scope expanded)
- ATB-11: Where missing categoryUri was first identified
- Future: Setup wizard to create initial forum/category/board structure