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

refactor(appview): convert indexer to class, eliminate module state (#8)

Replace module-level state in indexer with class-based architecture:

- Convert all handler functions to methods on new Indexer class
- Database instance passed to constructor, not module-level variable
- Remove initIndexer() function in favor of instantiation
- Update FirehoseService to create and use Indexer instance
- Update all tests to instantiate Indexer with test database
- Add TxOrDb type alias for cleaner transaction/database parameter types

Benefits:
- Explicit dependencies - database requirement visible in constructor
- Testability - no shared module state between tests
- Flexibility - can create multiple indexer instances if needed
- Type safety - transaction parameters properly typed

authored by

Malpercio and committed by
GitHub
d3c76fe8 c35e9dc8

+628 -627
+25 -23
apps/appview/src/lib/__tests__/firehose.test.ts
··· 21 // Mock indexer 22 vi.mock("../indexer.js", () => { 23 return { 24 - initIndexer: vi.fn(), 25 - handlePostCreate: vi.fn(), 26 - handlePostUpdate: vi.fn(), 27 - handlePostDelete: vi.fn(), 28 - handleForumCreate: vi.fn(), 29 - handleForumUpdate: vi.fn(), 30 - handleForumDelete: vi.fn(), 31 - handleCategoryCreate: vi.fn(), 32 - handleCategoryUpdate: vi.fn(), 33 - handleCategoryDelete: vi.fn(), 34 - handleMembershipCreate: vi.fn(), 35 - handleMembershipUpdate: vi.fn(), 36 - handleMembershipDelete: vi.fn(), 37 - handleModActionCreate: vi.fn(), 38 - handleModActionUpdate: vi.fn(), 39 - handleModActionDelete: vi.fn(), 40 - handleReactionCreate: vi.fn(), 41 - handleReactionUpdate: vi.fn(), 42 - handleReactionDelete: vi.fn(), 43 }; 44 }); 45 ··· 83 }).not.toThrow(); 84 }); 85 86 - it("should call initIndexer with database instance", async () => { 87 - const indexerModule = await import("../indexer.js"); 88 - const spy = vi.spyOn(indexerModule, "initIndexer"); 89 90 firehoseService = new FirehoseService( 91 mockDb, 92 "wss://jetstream.example.com" 93 ); 94 95 - expect(spy).toHaveBeenCalledWith(mockDb); 96 }); 97 }); 98
··· 21 // Mock indexer 22 vi.mock("../indexer.js", () => { 23 return { 24 + Indexer: vi.fn().mockImplementation(() => { 25 + return { 26 + handlePostCreate: vi.fn(), 27 + handlePostUpdate: vi.fn(), 28 + handlePostDelete: vi.fn(), 29 + handleForumCreate: vi.fn(), 30 + handleForumUpdate: vi.fn(), 31 + handleForumDelete: vi.fn(), 32 + handleCategoryCreate: vi.fn(), 33 + handleCategoryUpdate: vi.fn(), 34 + handleCategoryDelete: vi.fn(), 35 + handleMembershipCreate: vi.fn(), 36 + handleMembershipUpdate: vi.fn(), 37 + handleMembershipDelete: vi.fn(), 38 + handleModActionCreate: vi.fn(), 39 + handleModActionUpdate: vi.fn(), 40 + handleModActionDelete: vi.fn(), 41 + handleReactionCreate: vi.fn(), 42 + handleReactionUpdate: vi.fn(), 43 + handleReactionDelete: vi.fn(), 44 + }; 45 + }), 46 }; 47 }); 48 ··· 86 }).not.toThrow(); 87 }); 88 89 + it("should create Indexer with database instance", async () => { 90 + const { Indexer } = await import("../indexer.js"); 91 92 firehoseService = new FirehoseService( 93 mockDb, 94 "wss://jetstream.example.com" 95 ); 96 97 + expect(Indexer).toHaveBeenCalledWith(mockDb); 98 }); 99 }); 100
+21 -22
apps/appview/src/lib/__tests__/indexer.test.ts
··· 1 import { describe, it, expect, beforeEach, vi } from "vitest"; 2 - import { initIndexer } from "../indexer.js"; 3 import type { Database } from "@atbb/db"; 4 import type { CommitCreateEvent, CommitUpdateEvent, CommitDeleteEvent } from "@skyware/jetstream"; 5 ··· 50 51 describe("Indexer", () => { 52 let mockDb: Database; 53 54 beforeEach(() => { 55 mockDb = createMockDb(); 56 - initIndexer(mockDb); 57 }); 58 59 describe("Post Handler", () => { 60 it("should handle post creation with minimal fields", async () => { 61 - const { handlePostCreate } = await import("../indexer.js"); 62 63 const event: CommitCreateEvent<"space.atbb.post"> = { 64 did: "did:plc:test123", ··· 78 }, 79 }; 80 81 - await handlePostCreate(event); 82 83 expect(mockDb.insert).toHaveBeenCalled(); 84 }); 85 86 it("should handle post creation with forum reference", async () => { 87 - const { handlePostCreate } = await import("../indexer.js"); 88 89 const event: CommitCreateEvent<"space.atbb.post"> = { 90 did: "did:plc:test123", ··· 110 }, 111 }; 112 113 - await handlePostCreate(event); 114 115 expect(mockDb.insert).toHaveBeenCalled(); 116 }); 117 118 it("should handle post creation with reply references", async () => { 119 - const { handlePostCreate } = await import("../indexer.js"); 120 121 const event: CommitCreateEvent<"space.atbb.post"> = { 122 did: "did:plc:test123", ··· 146 }, 147 }; 148 149 - await handlePostCreate(event); 150 151 expect(mockDb.insert).toHaveBeenCalled(); 152 }); 153 154 it("should handle post update", async () => { 155 - const { handlePostUpdate } = await import("../indexer.js"); 156 157 const event: CommitUpdateEvent<"space.atbb.post"> = { 158 did: "did:plc:test123", ··· 172 }, 173 }; 174 175 - await handlePostUpdate(event); 176 177 expect(mockDb.update).toHaveBeenCalled(); 178 }); 179 180 it("should handle post deletion with soft delete", async () => { 181 - const { handlePostDelete } = await import("../indexer.js"); 182 183 const event: CommitDeleteEvent<"space.atbb.post"> = { 184 did: "did:plc:test123", ··· 192 }, 193 }; 194 195 - await handlePostDelete(event); 196 197 expect(mockDb.update).toHaveBeenCalled(); 198 }); ··· 200 201 describe("Forum Handler", () => { 202 it("should handle forum creation", async () => { 203 - const { handleForumCreate } = await import("../indexer.js"); 204 205 const event: CommitCreateEvent<"space.atbb.forum.forum"> = { 206 did: "did:plc:forum", ··· 220 }, 221 }; 222 223 - await handleForumCreate(event); 224 225 expect(mockDb.insert).toHaveBeenCalled(); 226 }); 227 228 it("should handle forum update", async () => { 229 - const { handleForumUpdate } = await import("../indexer.js"); 230 231 const event: CommitUpdateEvent<"space.atbb.forum.forum"> = { 232 did: "did:plc:forum", ··· 246 }, 247 }; 248 249 - await handleForumUpdate(event); 250 251 expect(mockDb.update).toHaveBeenCalled(); 252 }); 253 254 it("should handle forum deletion", async () => { 255 - const { handleForumDelete } = await import("../indexer.js"); 256 257 const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { 258 did: "did:plc:forum", ··· 266 }, 267 }; 268 269 - await handleForumDelete(event); 270 271 expect(mockDb.delete).toHaveBeenCalled(); 272 }); ··· 274 275 describe("Category Handler", () => { 276 it("should handle category creation without errors", async () => { 277 - const { handleCategoryCreate } = await import("../indexer.js"); 278 279 const event: CommitCreateEvent<"space.atbb.forum.category"> = { 280 did: "did:plc:forum", ··· 304 305 // Test that function executes without throwing 306 // Note: Since forum doesn't exist in mock, it will skip insertion 307 - await expect(handleCategoryCreate(event)).resolves.not.toThrow(); 308 }); 309 310 it("should skip category creation if forum not found", async () => { 311 - const { handleCategoryCreate } = await import("../indexer.js"); 312 313 // Mock failed forum lookup 314 vi.spyOn(mockDb, "select").mockReturnValue({ ··· 343 }, 344 }; 345 346 - await handleCategoryCreate(event); 347 348 expect(mockDb.insert).not.toHaveBeenCalled(); 349 });
··· 1 import { describe, it, expect, beforeEach, vi } from "vitest"; 2 + import { Indexer } from "../indexer.js"; 3 import type { Database } from "@atbb/db"; 4 import type { CommitCreateEvent, CommitUpdateEvent, CommitDeleteEvent } from "@skyware/jetstream"; 5 ··· 50 51 describe("Indexer", () => { 52 let mockDb: Database; 53 + let indexer: Indexer; 54 55 beforeEach(() => { 56 mockDb = createMockDb(); 57 + indexer = new Indexer(mockDb); 58 }); 59 60 describe("Post Handler", () => { 61 it("should handle post creation with minimal fields", async () => { 62 63 const event: CommitCreateEvent<"space.atbb.post"> = { 64 did: "did:plc:test123", ··· 78 }, 79 }; 80 81 + await indexer.handlePostCreate(event); 82 83 expect(mockDb.insert).toHaveBeenCalled(); 84 }); 85 86 it("should handle post creation with forum reference", async () => { 87 88 const event: CommitCreateEvent<"space.atbb.post"> = { 89 did: "did:plc:test123", ··· 109 }, 110 }; 111 112 + await indexer.handlePostCreate(event); 113 114 expect(mockDb.insert).toHaveBeenCalled(); 115 }); 116 117 it("should handle post creation with reply references", async () => { 118 + 119 120 const event: CommitCreateEvent<"space.atbb.post"> = { 121 did: "did:plc:test123", ··· 145 }, 146 }; 147 148 + await indexer.handlePostCreate(event); 149 150 expect(mockDb.insert).toHaveBeenCalled(); 151 }); 152 153 it("should handle post update", async () => { 154 + 155 156 const event: CommitUpdateEvent<"space.atbb.post"> = { 157 did: "did:plc:test123", ··· 171 }, 172 }; 173 174 + await indexer.handlePostUpdate(event); 175 176 expect(mockDb.update).toHaveBeenCalled(); 177 }); 178 179 it("should handle post deletion with soft delete", async () => { 180 + 181 182 const event: CommitDeleteEvent<"space.atbb.post"> = { 183 did: "did:plc:test123", ··· 191 }, 192 }; 193 194 + await indexer.handlePostDelete(event); 195 196 expect(mockDb.update).toHaveBeenCalled(); 197 }); ··· 199 200 describe("Forum Handler", () => { 201 it("should handle forum creation", async () => { 202 + 203 204 const event: CommitCreateEvent<"space.atbb.forum.forum"> = { 205 did: "did:plc:forum", ··· 219 }, 220 }; 221 222 + await indexer.handleForumCreate(event); 223 224 expect(mockDb.insert).toHaveBeenCalled(); 225 }); 226 227 it("should handle forum update", async () => { 228 + 229 230 const event: CommitUpdateEvent<"space.atbb.forum.forum"> = { 231 did: "did:plc:forum", ··· 245 }, 246 }; 247 248 + await indexer.handleForumUpdate(event); 249 250 expect(mockDb.update).toHaveBeenCalled(); 251 }); 252 253 it("should handle forum deletion", async () => { 254 + 255 256 const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { 257 did: "did:plc:forum", ··· 265 }, 266 }; 267 268 + await indexer.handleForumDelete(event); 269 270 expect(mockDb.delete).toHaveBeenCalled(); 271 }); ··· 273 274 describe("Category Handler", () => { 275 it("should handle category creation without errors", async () => { 276 + 277 278 const event: CommitCreateEvent<"space.atbb.forum.category"> = { 279 did: "did:plc:forum", ··· 303 304 // Test that function executes without throwing 305 // Note: Since forum doesn't exist in mock, it will skip insertion 306 + await expect(indexer.handleCategoryCreate(event)).resolves.not.toThrow(); 307 }); 308 309 it("should skip category creation if forum not found", async () => { 310 + 311 312 // Mock failed forum lookup 313 vi.spyOn(mockDb, "select").mockReturnValue({ ··· 342 }, 343 }; 344 345 + await indexer.handleCategoryCreate(event); 346 347 expect(mockDb.insert).not.toHaveBeenCalled(); 348 });
+40 -39
apps/appview/src/lib/firehose.ts
··· 1 import { Jetstream } from "@skyware/jetstream"; 2 import { type Database, firehoseCursor } from "@atbb/db"; 3 import { eq } from "drizzle-orm"; 4 - import * as indexer from "./indexer.js"; 5 6 /** 7 * Firehose service that subscribes to AT Proto Jetstream ··· 9 */ 10 export class FirehoseService { 11 private jetstream: Jetstream; 12 private isRunning = false; 13 private reconnectAttempts = 0; 14 private readonly maxReconnectAttempts = 10; ··· 32 private db: Database, 33 private jetstreamUrl: string 34 ) { 35 - // Initialize the indexer with the database instance 36 - indexer.initIndexer(db); 37 38 // Initialize with a placeholder - will be recreated with cursor in start() 39 this.jetstream = this.createJetstream(); ··· 318 319 // ── Event Handlers ────────────────────────────────────── 320 321 - private handlePostCreate = async (event: Parameters<typeof indexer.handlePostCreate>[0]) => 322 - this.wrapHandler(indexer.handlePostCreate, event, "handlePostCreate"); 323 - private handlePostUpdate = async (event: Parameters<typeof indexer.handlePostUpdate>[0]) => 324 - this.wrapHandler(indexer.handlePostUpdate, event, "handlePostUpdate"); 325 - private handlePostDelete = async (event: Parameters<typeof indexer.handlePostDelete>[0]) => 326 - this.wrapHandler(indexer.handlePostDelete, event, "handlePostDelete"); 327 328 - private handleForumCreate = async (event: Parameters<typeof indexer.handleForumCreate>[0]) => 329 - this.wrapHandler(indexer.handleForumCreate, event, "handleForumCreate"); 330 - private handleForumUpdate = async (event: Parameters<typeof indexer.handleForumUpdate>[0]) => 331 - this.wrapHandler(indexer.handleForumUpdate, event, "handleForumUpdate"); 332 - private handleForumDelete = async (event: Parameters<typeof indexer.handleForumDelete>[0]) => 333 - this.wrapHandler(indexer.handleForumDelete, event, "handleForumDelete"); 334 335 - private handleCategoryCreate = async (event: Parameters<typeof indexer.handleCategoryCreate>[0]) => 336 - this.wrapHandler(indexer.handleCategoryCreate, event, "handleCategoryCreate"); 337 - private handleCategoryUpdate = async (event: Parameters<typeof indexer.handleCategoryUpdate>[0]) => 338 - this.wrapHandler(indexer.handleCategoryUpdate, event, "handleCategoryUpdate"); 339 - private handleCategoryDelete = async (event: Parameters<typeof indexer.handleCategoryDelete>[0]) => 340 - this.wrapHandler(indexer.handleCategoryDelete, event, "handleCategoryDelete"); 341 342 - private handleMembershipCreate = async (event: Parameters<typeof indexer.handleMembershipCreate>[0]) => 343 - this.wrapHandler(indexer.handleMembershipCreate, event, "handleMembershipCreate"); 344 - private handleMembershipUpdate = async (event: Parameters<typeof indexer.handleMembershipUpdate>[0]) => 345 - this.wrapHandler(indexer.handleMembershipUpdate, event, "handleMembershipUpdate"); 346 - private handleMembershipDelete = async (event: Parameters<typeof indexer.handleMembershipDelete>[0]) => 347 - this.wrapHandler(indexer.handleMembershipDelete, event, "handleMembershipDelete"); 348 349 - private handleModActionCreate = async (event: Parameters<typeof indexer.handleModActionCreate>[0]) => 350 - this.wrapHandler(indexer.handleModActionCreate, event, "handleModActionCreate"); 351 - private handleModActionUpdate = async (event: Parameters<typeof indexer.handleModActionUpdate>[0]) => 352 - this.wrapHandler(indexer.handleModActionUpdate, event, "handleModActionUpdate"); 353 - private handleModActionDelete = async (event: Parameters<typeof indexer.handleModActionDelete>[0]) => 354 - this.wrapHandler(indexer.handleModActionDelete, event, "handleModActionDelete"); 355 356 - private handleReactionCreate = async (event: Parameters<typeof indexer.handleReactionCreate>[0]) => 357 - this.wrapHandler(indexer.handleReactionCreate, event, "handleReactionCreate"); 358 - private handleReactionUpdate = async (event: Parameters<typeof indexer.handleReactionUpdate>[0]) => 359 - this.wrapHandler(indexer.handleReactionUpdate, event, "handleReactionUpdate"); 360 - private handleReactionDelete = async (event: Parameters<typeof indexer.handleReactionDelete>[0]) => 361 - this.wrapHandler(indexer.handleReactionDelete, event, "handleReactionDelete"); 362 }
··· 1 import { Jetstream } from "@skyware/jetstream"; 2 import { type Database, firehoseCursor } from "@atbb/db"; 3 import { eq } from "drizzle-orm"; 4 + import { Indexer } from "./indexer.js"; 5 6 /** 7 * Firehose service that subscribes to AT Proto Jetstream ··· 9 */ 10 export class FirehoseService { 11 private jetstream: Jetstream; 12 + private indexer: Indexer; 13 private isRunning = false; 14 private reconnectAttempts = 0; 15 private readonly maxReconnectAttempts = 10; ··· 33 private db: Database, 34 private jetstreamUrl: string 35 ) { 36 + // Initialize the indexer instance with the database 37 + this.indexer = new Indexer(db); 38 39 // Initialize with a placeholder - will be recreated with cursor in start() 40 this.jetstream = this.createJetstream(); ··· 319 320 // ── Event Handlers ────────────────────────────────────── 321 322 + private handlePostCreate = async (event: Parameters<Indexer['handlePostCreate']>[0]) => 323 + this.wrapHandler(this.indexer.handlePostCreate.bind(this.indexer), event, "handlePostCreate"); 324 + private handlePostUpdate = async (event: Parameters<Indexer['handlePostUpdate']>[0]) => 325 + this.wrapHandler(this.indexer.handlePostUpdate.bind(this.indexer), event, "handlePostUpdate"); 326 + private handlePostDelete = async (event: Parameters<Indexer['handlePostDelete']>[0]) => 327 + this.wrapHandler(this.indexer.handlePostDelete.bind(this.indexer), event, "handlePostDelete"); 328 329 + private handleForumCreate = async (event: Parameters<Indexer['handleForumCreate']>[0]) => 330 + this.wrapHandler(this.indexer.handleForumCreate.bind(this.indexer), event, "handleForumCreate"); 331 + private handleForumUpdate = async (event: Parameters<Indexer['handleForumUpdate']>[0]) => 332 + this.wrapHandler(this.indexer.handleForumUpdate.bind(this.indexer), event, "handleForumUpdate"); 333 + private handleForumDelete = async (event: Parameters<Indexer['handleForumDelete']>[0]) => 334 + this.wrapHandler(this.indexer.handleForumDelete.bind(this.indexer), event, "handleForumDelete"); 335 336 + private handleCategoryCreate = async (event: Parameters<Indexer['handleCategoryCreate']>[0]) => 337 + this.wrapHandler(this.indexer.handleCategoryCreate.bind(this.indexer), event, "handleCategoryCreate"); 338 + private handleCategoryUpdate = async (event: Parameters<Indexer['handleCategoryUpdate']>[0]) => 339 + this.wrapHandler(this.indexer.handleCategoryUpdate.bind(this.indexer), event, "handleCategoryUpdate"); 340 + private handleCategoryDelete = async (event: Parameters<Indexer['handleCategoryDelete']>[0]) => 341 + this.wrapHandler(this.indexer.handleCategoryDelete.bind(this.indexer), event, "handleCategoryDelete"); 342 343 + private handleMembershipCreate = async (event: Parameters<Indexer['handleMembershipCreate']>[0]) => 344 + this.wrapHandler(this.indexer.handleMembershipCreate.bind(this.indexer), event, "handleMembershipCreate"); 345 + private handleMembershipUpdate = async (event: Parameters<Indexer['handleMembershipUpdate']>[0]) => 346 + this.wrapHandler(this.indexer.handleMembershipUpdate.bind(this.indexer), event, "handleMembershipUpdate"); 347 + private handleMembershipDelete = async (event: Parameters<Indexer['handleMembershipDelete']>[0]) => 348 + this.wrapHandler(this.indexer.handleMembershipDelete.bind(this.indexer), event, "handleMembershipDelete"); 349 350 + private handleModActionCreate = async (event: Parameters<Indexer['handleModActionCreate']>[0]) => 351 + this.wrapHandler(this.indexer.handleModActionCreate.bind(this.indexer), event, "handleModActionCreate"); 352 + private handleModActionUpdate = async (event: Parameters<Indexer['handleModActionUpdate']>[0]) => 353 + this.wrapHandler(this.indexer.handleModActionUpdate.bind(this.indexer), event, "handleModActionUpdate"); 354 + private handleModActionDelete = async (event: Parameters<Indexer['handleModActionDelete']>[0]) => 355 + this.wrapHandler(this.indexer.handleModActionDelete.bind(this.indexer), event, "handleModActionDelete"); 356 357 + private handleReactionCreate = async (event: Parameters<Indexer['handleReactionCreate']>[0]) => 358 + this.wrapHandler(this.indexer.handleReactionCreate.bind(this.indexer), event, "handleReactionCreate"); 359 + private handleReactionUpdate = async (event: Parameters<Indexer['handleReactionUpdate']>[0]) => 360 + this.wrapHandler(this.indexer.handleReactionUpdate.bind(this.indexer), event, "handleReactionUpdate"); 361 + private handleReactionDelete = async (event: Parameters<Indexer['handleReactionDelete']>[0]) => 362 + this.wrapHandler(this.indexer.handleReactionDelete.bind(this.indexer), event, "handleReactionDelete"); 363 }
+542 -543
apps/appview/src/lib/indexer.ts
··· 19 import * as Membership from "@atbb/lexicon/dist/types/types/space/atbb/membership.js"; 20 import * as ModAction from "@atbb/lexicon/dist/types/types/space/atbb/modAction.js"; 21 22 - // Module-level db instance set via initIndexer 23 - let db: Database; 24 25 /** 26 - * Initialize the indexer with a database instance 27 */ 28 - export function initIndexer(database: Database) { 29 - db = database; 30 - } 31 32 - /** 33 - * Parse an AT Proto URI to extract DID, collection, and rkey 34 - * Format: at://did:plc:xxx/collection.name/rkey 35 - */ 36 - function parseAtUri(uri: string): { 37 - did: string; 38 - collection: string; 39 - rkey: string; 40 - } | null { 41 - try { 42 - // AT Protocol URIs use at:// scheme which isn't recognized by URL constructor 43 - // Pattern: at://did:plc:xxx/space.atbb.post/rkey123 44 - const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 45 - if (!match) { 46 - console.error(`Invalid AT URI format: ${uri}`); 47 return null; 48 } 49 - 50 - const [, did, collection, rkey] = match; 51 - return { did, collection, rkey }; 52 - } catch (error) { 53 - console.error(`Failed to parse AT URI: ${uri}`, error); 54 - return null; 55 } 56 - } 57 58 - /** 59 - * Ensure a user exists in the database. Creates if not exists. 60 - * @param dbOrTx - Database instance or transaction 61 - */ 62 - async function ensureUser(did: string, dbOrTx: DbOrTransaction = db) { 63 - try { 64 - const existing = await dbOrTx.select().from(users).where(eq(users.did, did)).limit(1); 65 66 - if (existing.length === 0) { 67 - await dbOrTx.insert(users).values({ 68 - did, 69 - handle: null, // Will be updated by identity events 70 - indexedAt: new Date(), 71 - }); 72 - console.log(`[USER] Created user: ${did}`); 73 } 74 - } catch (error) { 75 - console.error(`Failed to ensure user exists: ${did}`, error); 76 - throw error; 77 } 78 - } 79 80 - /** 81 - * Look up a forum ID by its AT URI 82 - * @param dbOrTx - Database instance or transaction 83 - */ 84 - async function getForumIdByUri( 85 - forumUri: string, 86 - dbOrTx: DbOrTransaction = db 87 - ): Promise<bigint | null> { 88 - const parsed = parseAtUri(forumUri); 89 - if (!parsed) return null; 90 - 91 - const result = await dbOrTx 92 - .select({ id: forums.id }) 93 - .from(forums) 94 - .where(and(eq(forums.did, parsed.did), eq(forums.rkey, parsed.rkey))) 95 - .limit(1); 96 - 97 - return result.length > 0 ? result[0].id : null; 98 - } 99 100 - /** 101 - * Look up a forum ID by the forum's DID 102 - * Used for records owned by the forum (categories, modActions) 103 - * @param dbOrTx - Database instance or transaction 104 - */ 105 - async function getForumIdByDid( 106 - forumDid: string, 107 - dbOrTx: DbOrTransaction = db 108 - ): Promise<bigint | null> { 109 - try { 110 const result = await dbOrTx 111 .select({ id: forums.id }) 112 .from(forums) 113 - .where(eq(forums.did, forumDid)) 114 .limit(1); 115 116 return result.length > 0 ? result[0].id : null; 117 - } catch (error) { 118 - console.error(`Failed to look up forum by DID: ${forumDid}`, error); 119 - return null; 120 } 121 - } 122 123 - /** 124 - * Look up a post ID by its AT URI 125 - * @param dbOrTx - Database instance or transaction 126 - */ 127 - async function getPostIdByUri( 128 - postUri: string, 129 - dbOrTx: DbOrTransaction = db 130 - ): Promise<bigint | null> { 131 - const parsed = parseAtUri(postUri); 132 - if (!parsed) return null; 133 134 - const result = await dbOrTx 135 - .select({ id: posts.id }) 136 - .from(posts) 137 - .where(and(eq(posts.did, parsed.did), eq(posts.rkey, parsed.rkey))) 138 - .limit(1); 139 140 - return result.length > 0 ? result[0].id : null; 141 - } 142 143 - // ── Post Handlers ─────────────────────────────────────── 144 145 - export async function handlePostCreate( 146 - event: CommitCreateEvent<"space.atbb.post"> 147 - ) { 148 - try { 149 - const record = event.commit.record as unknown as Post.Record; 150 151 - await db.transaction(async (tx) => { 152 - // Ensure author exists 153 - await ensureUser(event.did, tx); 154 155 - // Look up parent/root for replies 156 - let rootId: bigint | null = null; 157 - let parentId: bigint | null = null; 158 159 - if (Post.isReplyRef(record.reply)) { 160 - rootId = await getPostIdByUri(record.reply.root.uri, tx); 161 - parentId = await getPostIdByUri(record.reply.parent.uri, tx); 162 - } 163 164 - // Insert post 165 - await tx.insert(posts).values({ 166 - did: event.did, 167 - rkey: event.commit.rkey, 168 - cid: event.commit.cid, 169 - text: record.text, 170 - forumUri: record.forum?.forum.uri ?? null, 171 - rootPostId: rootId, 172 - rootUri: record.reply?.root.uri ?? null, 173 - parentPostId: parentId, 174 - parentUri: record.reply?.parent.uri ?? null, 175 - createdAt: new Date(record.createdAt), 176 - indexedAt: new Date(), 177 }); 178 - }); 179 180 - console.log(`[CREATE] Post: ${event.did}/${event.commit.rkey}`); 181 - } catch (error) { 182 - console.error( 183 - `Failed to index post create: ${event.did}/${event.commit.rkey}`, 184 - error 185 - ); 186 - throw error; 187 } 188 - } 189 190 - export async function handlePostUpdate( 191 - event: CommitUpdateEvent<"space.atbb.post"> 192 - ) { 193 - try { 194 - const record = event.commit.record as unknown as Post.Record; 195 196 - // Update post 197 - await db 198 - .update(posts) 199 - .set({ 200 - cid: event.commit.cid, 201 - text: record.text, 202 - forumUri: record.forum?.forum.uri ?? null, 203 - indexedAt: new Date(), 204 - }) 205 - .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 206 207 - console.log(`[UPDATE] Post: ${event.did}/${event.commit.rkey}`); 208 - } catch (error) { 209 - console.error( 210 - `Failed to update post: ${event.did}/${event.commit.rkey}`, 211 - error 212 - ); 213 - throw error; 214 } 215 - } 216 217 - export async function handlePostDelete( 218 - event: CommitDeleteEvent<"space.atbb.post"> 219 - ) { 220 - try { 221 - // Soft delete 222 - await db 223 - .update(posts) 224 - .set({ 225 - deleted: true, 226 - }) 227 - .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 228 229 - console.log(`[DELETE] Post: ${event.did}/${event.commit.rkey}`); 230 - } catch (error) { 231 - console.error( 232 - `Failed to delete post: ${event.did}/${event.commit.rkey}`, 233 - error 234 - ); 235 - throw error; 236 } 237 - } 238 239 - // ── Forum Handlers ────────────────────────────────────── 240 241 - export async function handleForumCreate( 242 - event: CommitCreateEvent<"space.atbb.forum.forum"> 243 - ) { 244 - try { 245 - const record = event.commit.record as unknown as Forum.Record; 246 247 - await db.transaction(async (tx) => { 248 - // Ensure owner exists 249 - await ensureUser(event.did, tx); 250 251 - // Insert forum 252 - await tx.insert(forums).values({ 253 - did: event.did, 254 - rkey: event.commit.rkey, 255 - cid: event.commit.cid, 256 - name: record.name, 257 - description: record.description ?? null, 258 - indexedAt: new Date(), 259 }); 260 - }); 261 262 - console.log(`[CREATE] Forum: ${event.did}/${event.commit.rkey}`); 263 - } catch (error) { 264 - console.error( 265 - `Failed to index forum create: ${event.did}/${event.commit.rkey}`, 266 - error 267 - ); 268 - throw error; 269 } 270 - } 271 272 - export async function handleForumUpdate( 273 - event: CommitUpdateEvent<"space.atbb.forum.forum"> 274 - ) { 275 - try { 276 - const record = event.commit.record as unknown as Forum.Record; 277 278 - await db 279 - .update(forums) 280 - .set({ 281 - cid: event.commit.cid, 282 - name: record.name, 283 - description: record.description ?? null, 284 - indexedAt: new Date(), 285 - }) 286 - .where(and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey))); 287 - 288 - console.log(`[UPDATE] Forum: ${event.did}/${event.commit.rkey}`); 289 - } catch (error) { 290 - console.error( 291 - `Failed to update forum: ${event.did}/${event.commit.rkey}`, 292 - error 293 - ); 294 - throw error; 295 - } 296 - } 297 298 - export async function handleForumDelete( 299 - event: CommitDeleteEvent<"space.atbb.forum.forum"> 300 - ) { 301 - try { 302 - // Hard delete 303 - await db 304 - .delete(forums) 305 - .where( 306 - and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey)) 307 ); 308 - 309 - console.log(`[DELETE] Forum: ${event.did}/${event.commit.rkey}`); 310 - } catch (error) { 311 - console.error( 312 - `Failed to delete forum: ${event.did}/${event.commit.rkey}`, 313 - error 314 - ); 315 - throw error; 316 } 317 - } 318 319 - // ── Category Handlers ─────────────────────────────────── 320 - 321 - export async function handleCategoryCreate( 322 - event: CommitCreateEvent<"space.atbb.forum.category"> 323 - ) { 324 - try { 325 - const record = event.commit.record as unknown as Category.Record; 326 - 327 - await db.transaction(async (tx) => { 328 - // Categories are owned by the Forum DID, so event.did IS the forum DID 329 - const forumId = await getForumIdByDid(event.did, tx); 330 - 331 - if (!forumId) { 332 - console.warn( 333 - `[CREATE] Category: Forum not found for DID ${event.did}` 334 ); 335 - return; 336 - } 337 338 - // Insert category 339 - await tx.insert(categories).values({ 340 - did: event.did, 341 - rkey: event.commit.rkey, 342 - cid: event.commit.cid, 343 - forumId, 344 - name: record.name, 345 - description: record.description ?? null, 346 - slug: record.slug ?? null, 347 - sortOrder: record.sortOrder ?? 0, 348 - createdAt: new Date(record.createdAt), 349 - indexedAt: new Date(), 350 - }); 351 - }); 352 353 - console.log(`[CREATE] Category: ${event.did}/${event.commit.rkey}`); 354 - } catch (error) { 355 - console.error( 356 - `Failed to index category create: ${event.did}/${event.commit.rkey}`, 357 - error 358 - ); 359 - throw error; 360 - } 361 - } 362 363 - export async function handleCategoryUpdate( 364 - event: CommitUpdateEvent<"space.atbb.forum.category"> 365 - ) { 366 - try { 367 - const record = event.commit.record as unknown as Category.Record; 368 369 - await db.transaction(async (tx) => { 370 - // Categories are owned by the Forum DID, so event.did IS the forum DID 371 - const forumId = await getForumIdByDid(event.did, tx); 372 373 - if (!forumId) { 374 - console.warn( 375 - `[UPDATE] Category: Forum not found for DID ${event.did}` 376 - ); 377 - return; 378 - } 379 380 - await tx 381 - .update(categories) 382 - .set({ 383 cid: event.commit.cid, 384 forumId, 385 name: record.name, 386 description: record.description ?? null, 387 slug: record.slug ?? null, 388 sortOrder: record.sortOrder ?? 0, 389 indexedAt: new Date(), 390 - }) 391 - .where( 392 - and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 393 - ); 394 - }); 395 - 396 - console.log(`[UPDATE] Category: ${event.did}/${event.commit.rkey}`); 397 - } catch (error) { 398 - console.error( 399 - `Failed to update category: ${event.did}/${event.commit.rkey}`, 400 - error 401 - ); 402 - throw error; 403 - } 404 - } 405 406 - export async function handleCategoryDelete( 407 - event: CommitDeleteEvent<"space.atbb.forum.category"> 408 - ) { 409 - try { 410 - // Hard delete 411 - await db 412 - .delete(categories) 413 - .where( 414 - and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 415 ); 416 - 417 - console.log(`[DELETE] Category: ${event.did}/${event.commit.rkey}`); 418 - } catch (error) { 419 - console.error( 420 - `Failed to delete category: ${event.did}/${event.commit.rkey}`, 421 - error 422 - ); 423 - throw error; 424 } 425 - } 426 427 - // ── Membership Handlers ───────────────────────────────── 428 429 - export async function handleMembershipCreate( 430 - event: CommitCreateEvent<"space.atbb.membership"> 431 - ) { 432 - try { 433 - const record = event.commit.record as unknown as Membership.Record; 434 435 - await db.transaction(async (tx) => { 436 - // Ensure user exists 437 - await ensureUser(event.did, tx); 438 439 - // Look up forum by URI (inside transaction) 440 - const forumId = await getForumIdByUri(record.forum.forum.uri, tx); 441 442 - if (!forumId) { 443 - console.warn( 444 - `[CREATE] Membership: Forum not found for ${record.forum.forum.uri}` 445 ); 446 - return; 447 - } 448 449 - // Insert membership 450 - await tx.insert(memberships).values({ 451 - did: event.did, 452 - rkey: event.commit.rkey, 453 - cid: event.commit.cid, 454 - forumId, 455 - forumUri: record.forum.forum.uri, 456 - role: null, // TODO: Extract role name from roleUri or lexicon 457 - roleUri: record.role?.role.uri ?? null, 458 - joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 459 - createdAt: new Date(record.createdAt), 460 - indexedAt: new Date(), 461 - }); 462 - }); 463 464 - console.log(`[CREATE] Membership: ${event.did}/${event.commit.rkey}`); 465 - } catch (error) { 466 - console.error( 467 - `Failed to index membership create: ${event.did}/${event.commit.rkey}`, 468 - error 469 - ); 470 - throw error; 471 - } 472 - } 473 474 - export async function handleMembershipUpdate( 475 - event: CommitUpdateEvent<"space.atbb.membership"> 476 - ) { 477 - try { 478 - const record = event.commit.record as unknown as Membership.Record; 479 480 - await db.transaction(async (tx) => { 481 - // Look up forum by URI (may have changed) 482 - const forumId = await getForumIdByUri(record.forum.forum.uri, tx); 483 484 - if (!forumId) { 485 - console.warn( 486 - `[UPDATE] Membership: Forum not found for ${record.forum.forum.uri}` 487 - ); 488 - return; 489 - } 490 491 - await tx 492 - .update(memberships) 493 - .set({ 494 cid: event.commit.cid, 495 forumId, 496 forumUri: record.forum.forum.uri, 497 role: null, // TODO: Extract role name from roleUri or lexicon 498 roleUri: record.role?.role.uri ?? null, 499 joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 500 indexedAt: new Date(), 501 - }) 502 - .where( 503 - and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 504 - ); 505 - }); 506 507 - console.log(`[UPDATE] Membership: ${event.did}/${event.commit.rkey}`); 508 - } catch (error) { 509 - console.error( 510 - `Failed to update membership: ${event.did}/${event.commit.rkey}`, 511 - error 512 - ); 513 - throw error; 514 - } 515 - } 516 - 517 - export async function handleMembershipDelete( 518 - event: CommitDeleteEvent<"space.atbb.membership"> 519 - ) { 520 - try { 521 - // Hard delete 522 - await db 523 - .delete(memberships) 524 - .where( 525 - and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 526 ); 527 - 528 - console.log(`[DELETE] Membership: ${event.did}/${event.commit.rkey}`); 529 - } catch (error) { 530 - console.error( 531 - `Failed to delete membership: ${event.did}/${event.commit.rkey}`, 532 - error 533 - ); 534 - throw error; 535 } 536 - } 537 538 - // ── ModAction Handlers ────────────────────────────────── 539 540 - export async function handleModActionCreate( 541 - event: CommitCreateEvent<"space.atbb.modAction"> 542 - ) { 543 - try { 544 - const record = event.commit.record as unknown as ModAction.Record; 545 546 - // ModActions are owned by the Forum DID, so event.did IS the forum DID 547 - const forumId = await getForumIdByDid(event.did); 548 549 - if (!forumId) { 550 - console.warn( 551 - `[CREATE] ModAction: Forum not found for DID ${event.did}` 552 ); 553 - return; 554 } 555 - 556 - await db.transaction(async (tx) => { 557 - // Ensure moderator exists 558 - await ensureUser(record.createdBy, tx); 559 560 - // Determine subject type (post or user) 561 - let subjectPostUri: string | null = null; 562 - let subjectDid: string | null = null; 563 - 564 - if (record.subject.post) { 565 - subjectPostUri = record.subject.post.uri; 566 - } 567 - if (record.subject.did) { 568 - subjectDid = record.subject.did; 569 - } 570 - 571 - // Insert mod action 572 - await tx.insert(modActions).values({ 573 - did: event.did, 574 - rkey: event.commit.rkey, 575 - cid: event.commit.cid, 576 - forumId, 577 - action: record.action, 578 - subjectPostUri, 579 - subjectDid, 580 - reason: record.reason ?? null, 581 - createdBy: record.createdBy, 582 - expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 583 - createdAt: new Date(record.createdAt), 584 - indexedAt: new Date(), 585 - }); 586 - }); 587 588 - console.log(`[CREATE] ModAction: ${event.did}/${event.commit.rkey}`); 589 - } catch (error) { 590 - console.error( 591 - `Failed to index mod action create: ${event.did}/${event.commit.rkey}`, 592 - error 593 - ); 594 - throw error; 595 } 596 - } 597 598 - export async function handleModActionUpdate( 599 - event: CommitUpdateEvent<"space.atbb.modAction"> 600 - ) { 601 - try { 602 - const record = event.commit.record as unknown as ModAction.Record; 603 604 - await db.transaction(async (tx) => { 605 // ModActions are owned by the Forum DID, so event.did IS the forum DID 606 - const forumId = await getForumIdByDid(event.did, tx); 607 608 if (!forumId) { 609 console.warn( 610 - `[UPDATE] ModAction: Forum not found for DID ${event.did}` 611 ); 612 return; 613 } 614 615 - // Determine subject type (post or user) 616 - let subjectPostUri: string | null = null; 617 - let subjectDid: string | null = null; 618 619 - if (record.subject.post) { 620 - subjectPostUri = record.subject.post.uri; 621 - } 622 - if (record.subject.did) { 623 - subjectDid = record.subject.did; 624 - } 625 626 - await tx 627 - .update(modActions) 628 - .set({ 629 cid: event.commit.cid, 630 forumId, 631 action: record.action, ··· 634 reason: record.reason ?? null, 635 createdBy: record.createdBy, 636 expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 637 indexedAt: new Date(), 638 - }) 639 .where( 640 and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 641 ); 642 - }); 643 644 - console.log(`[UPDATE] ModAction: ${event.did}/${event.commit.rkey}`); 645 - } catch (error) { 646 - console.error( 647 - `Failed to update mod action: ${event.did}/${event.commit.rkey}`, 648 - error 649 - ); 650 - throw error; 651 } 652 - } 653 654 - export async function handleModActionDelete( 655 - event: CommitDeleteEvent<"space.atbb.modAction"> 656 - ) { 657 - try { 658 - // Hard delete 659 - await db 660 - .delete(modActions) 661 - .where( 662 - and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 663 - ); 664 665 - console.log(`[DELETE] ModAction: ${event.did}/${event.commit.rkey}`); 666 - } catch (error) { 667 - console.error( 668 - `Failed to delete mod action: ${event.did}/${event.commit.rkey}`, 669 - error 670 - ); 671 - throw error; 672 } 673 - } 674 675 - // ── Reaction Handlers (Stub) ──────────────────────────── 676 677 - export async function handleReactionCreate( 678 - event: CommitCreateEvent<"space.atbb.reaction"> 679 - ) { 680 - console.log(`[CREATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 681 - // TODO: Add reactions table to schema 682 - } 683 - 684 - export async function handleReactionUpdate( 685 - event: CommitUpdateEvent<"space.atbb.reaction"> 686 - ) { 687 - console.log(`[UPDATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 688 - // TODO: Add reactions table to schema 689 - } 690 - 691 - export async function handleReactionDelete( 692 - event: CommitDeleteEvent<"space.atbb.reaction"> 693 - ) { 694 - console.log(`[DELETE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 695 - // TODO: Add reactions table to schema 696 }
··· 19 import * as Membership from "@atbb/lexicon/dist/types/types/space/atbb/membership.js"; 20 import * as ModAction from "@atbb/lexicon/dist/types/types/space/atbb/modAction.js"; 21 22 23 /** 24 + * Indexer class for processing AT Proto firehose events 25 + * Converts events into database records for the atBB AppView 26 */ 27 + export class Indexer { 28 + constructor(private db: Database) {} 29 30 + /** 31 + * Parse an AT Proto URI to extract DID, collection, and rkey 32 + * Format: at://did:plc:xxx/collection.name/rkey 33 + */ 34 + private parseAtUri(uri: string): { 35 + did: string; 36 + collection: string; 37 + rkey: string; 38 + } | null { 39 + try { 40 + // AT Protocol URIs use at:// scheme which isn't recognized by URL constructor 41 + // Pattern: at://did:plc:xxx/space.atbb.post/rkey123 42 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 43 + if (!match) { 44 + console.error(`Invalid AT URI format: ${uri}`); 45 + return null; 46 + } 47 + 48 + const [, did, collection, rkey] = match; 49 + return { did, collection, rkey }; 50 + } catch (error) { 51 + console.error(`Failed to parse AT URI: ${uri}`, error); 52 return null; 53 } 54 } 55 56 + /** 57 + * Ensure a user exists in the database. Creates if not exists. 58 + * @param dbOrTx - Database instance or transaction 59 + */ 60 + private async ensureUser(did: string, dbOrTx: DbOrTransaction = this.db) { 61 + try { 62 + const existing = await dbOrTx.select().from(users).where(eq(users.did, did)).limit(1); 63 64 + if (existing.length === 0) { 65 + await dbOrTx.insert(users).values({ 66 + did, 67 + handle: null, // Will be updated by identity events 68 + indexedAt: new Date(), 69 + }); 70 + console.log(`[USER] Created user: ${did}`); 71 + } 72 + } catch (error) { 73 + console.error(`Failed to ensure user exists: ${did}`, error); 74 + throw error; 75 } 76 } 77 78 + /** 79 + * Look up a forum ID by its AT URI 80 + * @param dbOrTx - Database instance or transaction 81 + */ 82 + private async getForumIdByUri( 83 + forumUri: string, 84 + dbOrTx: DbOrTransaction = this.db 85 + ): Promise<bigint | null> { 86 + const parsed = this.parseAtUri(forumUri); 87 + if (!parsed) return null; 88 89 const result = await dbOrTx 90 .select({ id: forums.id }) 91 .from(forums) 92 + .where(and(eq(forums.did, parsed.did), eq(forums.rkey, parsed.rkey))) 93 .limit(1); 94 95 return result.length > 0 ? result[0].id : null; 96 } 97 98 + /** 99 + * Look up a forum ID by the forum's DID 100 + * Used for records owned by the forum (categories, modActions) 101 + * @param dbOrTx - Database instance or transaction 102 + */ 103 + private async getForumIdByDid( 104 + forumDid: string, 105 + dbOrTx: DbOrTransaction = this.db 106 + ): Promise<bigint | null> { 107 + try { 108 + const result = await dbOrTx 109 + .select({ id: forums.id }) 110 + .from(forums) 111 + .where(eq(forums.did, forumDid)) 112 + .limit(1); 113 114 + return result.length > 0 ? result[0].id : null; 115 + } catch (error) { 116 + console.error(`Failed to look up forum by DID: ${forumDid}`, error); 117 + return null; 118 + } 119 + } 120 121 + /** 122 + * Look up a post ID by its AT URI 123 + * @param dbOrTx - Database instance or transaction 124 + */ 125 + private async getPostIdByUri( 126 + postUri: string, 127 + dbOrTx: DbOrTransaction = this.db 128 + ): Promise<bigint | null> { 129 + const parsed = this.parseAtUri(postUri); 130 + if (!parsed) return null; 131 132 + const result = await dbOrTx 133 + .select({ id: posts.id }) 134 + .from(posts) 135 + .where(and(eq(posts.did, parsed.did), eq(posts.rkey, parsed.rkey))) 136 + .limit(1); 137 138 + return result.length > 0 ? result[0].id : null; 139 + } 140 141 + // ── Post Handlers ─────────────────────────────────────── 142 143 + async handlePostCreate( 144 + event: CommitCreateEvent<"space.atbb.post"> 145 + ) { 146 + try { 147 + const record = event.commit.record as unknown as Post.Record; 148 149 + await this.db.transaction(async (tx) => { 150 + // Ensure author exists 151 + await this.ensureUser(event.did, tx); 152 153 + // Look up parent/root for replies 154 + let rootId: bigint | null = null; 155 + let parentId: bigint | null = null; 156 + 157 + if (Post.isReplyRef(record.reply)) { 158 + rootId = await this.getPostIdByUri(record.reply.root.uri, tx); 159 + parentId = await this.getPostIdByUri(record.reply.parent.uri, tx); 160 + } 161 + 162 + // Insert post 163 + await tx.insert(posts).values({ 164 + did: event.did, 165 + rkey: event.commit.rkey, 166 + cid: event.commit.cid, 167 + text: record.text, 168 + forumUri: record.forum?.forum.uri ?? null, 169 + rootPostId: rootId, 170 + rootUri: record.reply?.root.uri ?? null, 171 + parentPostId: parentId, 172 + parentUri: record.reply?.parent.uri ?? null, 173 + createdAt: new Date(record.createdAt), 174 + indexedAt: new Date(), 175 + }); 176 }); 177 178 + console.log(`[CREATE] Post: ${event.did}/${event.commit.rkey}`); 179 + } catch (error) { 180 + console.error( 181 + `Failed to index post create: ${event.did}/${event.commit.rkey}`, 182 + error 183 + ); 184 + throw error; 185 + } 186 } 187 188 + async handlePostUpdate( 189 + event: CommitUpdateEvent<"space.atbb.post"> 190 + ) { 191 + try { 192 + const record = event.commit.record as unknown as Post.Record; 193 194 + // Update post 195 + await this.db 196 + .update(posts) 197 + .set({ 198 + cid: event.commit.cid, 199 + text: record.text, 200 + forumUri: record.forum?.forum.uri ?? null, 201 + indexedAt: new Date(), 202 + }) 203 + .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 204 205 + console.log(`[UPDATE] Post: ${event.did}/${event.commit.rkey}`); 206 + } catch (error) { 207 + console.error( 208 + `Failed to update post: ${event.did}/${event.commit.rkey}`, 209 + error 210 + ); 211 + throw error; 212 + } 213 } 214 215 + async handlePostDelete( 216 + event: CommitDeleteEvent<"space.atbb.post"> 217 + ) { 218 + try { 219 + // Soft delete 220 + await this.db 221 + .update(posts) 222 + .set({ 223 + deleted: true, 224 + }) 225 + .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 226 227 + console.log(`[DELETE] Post: ${event.did}/${event.commit.rkey}`); 228 + } catch (error) { 229 + console.error( 230 + `Failed to delete post: ${event.did}/${event.commit.rkey}`, 231 + error 232 + ); 233 + throw error; 234 + } 235 } 236 237 + // ── Forum Handlers ────────────────────────────────────── 238 239 + async handleForumCreate( 240 + event: CommitCreateEvent<"space.atbb.forum.forum"> 241 + ) { 242 + try { 243 + const record = event.commit.record as unknown as Forum.Record; 244 245 + await this.db.transaction(async (tx) => { 246 + // Ensure owner exists 247 + await this.ensureUser(event.did, tx); 248 249 + // Insert forum 250 + await tx.insert(forums).values({ 251 + did: event.did, 252 + rkey: event.commit.rkey, 253 + cid: event.commit.cid, 254 + name: record.name, 255 + description: record.description ?? null, 256 + indexedAt: new Date(), 257 + }); 258 }); 259 260 + console.log(`[CREATE] Forum: ${event.did}/${event.commit.rkey}`); 261 + } catch (error) { 262 + console.error( 263 + `Failed to index forum create: ${event.did}/${event.commit.rkey}`, 264 + error 265 + ); 266 + throw error; 267 + } 268 } 269 270 + async handleForumUpdate( 271 + event: CommitUpdateEvent<"space.atbb.forum.forum"> 272 + ) { 273 + try { 274 + const record = event.commit.record as unknown as Forum.Record; 275 276 + await this.db 277 + .update(forums) 278 + .set({ 279 + cid: event.commit.cid, 280 + name: record.name, 281 + description: record.description ?? null, 282 + indexedAt: new Date(), 283 + }) 284 + .where(and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey))); 285 286 + console.log(`[UPDATE] Forum: ${event.did}/${event.commit.rkey}`); 287 + } catch (error) { 288 + console.error( 289 + `Failed to update forum: ${event.did}/${event.commit.rkey}`, 290 + error 291 ); 292 + throw error; 293 + } 294 } 295 296 + async handleForumDelete( 297 + event: CommitDeleteEvent<"space.atbb.forum.forum"> 298 + ) { 299 + try { 300 + // Hard delete 301 + await this.db 302 + .delete(forums) 303 + .where( 304 + and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey)) 305 ); 306 307 + console.log(`[DELETE] Forum: ${event.did}/${event.commit.rkey}`); 308 + } catch (error) { 309 + console.error( 310 + `Failed to delete forum: ${event.did}/${event.commit.rkey}`, 311 + error 312 + ); 313 + throw error; 314 + } 315 + } 316 317 + // ── Category Handlers ─────────────────────────────────── 318 319 + async handleCategoryCreate( 320 + event: CommitCreateEvent<"space.atbb.forum.category"> 321 + ) { 322 + try { 323 + const record = event.commit.record as unknown as Category.Record; 324 325 + await this.db.transaction(async (tx) => { 326 + // Categories are owned by the Forum DID, so event.did IS the forum DID 327 + const forumId = await this.getForumIdByDid(event.did, tx); 328 329 + if (!forumId) { 330 + console.warn( 331 + `[CREATE] Category: Forum not found for DID ${event.did}` 332 + ); 333 + return; 334 + } 335 336 + // Insert category 337 + await tx.insert(categories).values({ 338 + did: event.did, 339 + rkey: event.commit.rkey, 340 cid: event.commit.cid, 341 forumId, 342 name: record.name, 343 description: record.description ?? null, 344 slug: record.slug ?? null, 345 sortOrder: record.sortOrder ?? 0, 346 + createdAt: new Date(record.createdAt), 347 indexedAt: new Date(), 348 + }); 349 + }); 350 351 + console.log(`[CREATE] Category: ${event.did}/${event.commit.rkey}`); 352 + } catch (error) { 353 + console.error( 354 + `Failed to index category create: ${event.did}/${event.commit.rkey}`, 355 + error 356 ); 357 + throw error; 358 + } 359 } 360 361 + async handleCategoryUpdate( 362 + event: CommitUpdateEvent<"space.atbb.forum.category"> 363 + ) { 364 + try { 365 + const record = event.commit.record as unknown as Category.Record; 366 367 + await this.db.transaction(async (tx) => { 368 + // Categories are owned by the Forum DID, so event.did IS the forum DID 369 + const forumId = await this.getForumIdByDid(event.did, tx); 370 371 + if (!forumId) { 372 + console.warn( 373 + `[UPDATE] Category: Forum not found for DID ${event.did}` 374 + ); 375 + return; 376 + } 377 378 + await tx 379 + .update(categories) 380 + .set({ 381 + cid: event.commit.cid, 382 + forumId, 383 + name: record.name, 384 + description: record.description ?? null, 385 + slug: record.slug ?? null, 386 + sortOrder: record.sortOrder ?? 0, 387 + indexedAt: new Date(), 388 + }) 389 + .where( 390 + and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 391 + ); 392 + }); 393 394 + console.log(`[UPDATE] Category: ${event.did}/${event.commit.rkey}`); 395 + } catch (error) { 396 + console.error( 397 + `Failed to update category: ${event.did}/${event.commit.rkey}`, 398 + error 399 + ); 400 + throw error; 401 + } 402 + } 403 + 404 + async handleCategoryDelete( 405 + event: CommitDeleteEvent<"space.atbb.forum.category"> 406 + ) { 407 + try { 408 + // Hard delete 409 + await this.db 410 + .delete(categories) 411 + .where( 412 + and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 413 ); 414 + 415 + console.log(`[DELETE] Category: ${event.did}/${event.commit.rkey}`); 416 + } catch (error) { 417 + console.error( 418 + `Failed to delete category: ${event.did}/${event.commit.rkey}`, 419 + error 420 + ); 421 + throw error; 422 + } 423 + } 424 425 + // ── Membership Handlers ───────────────────────────────── 426 427 + async handleMembershipCreate( 428 + event: CommitCreateEvent<"space.atbb.membership"> 429 + ) { 430 + try { 431 + const record = event.commit.record as unknown as Membership.Record; 432 433 + await this.db.transaction(async (tx) => { 434 + // Ensure user exists 435 + await this.ensureUser(event.did, tx); 436 437 + // Look up forum by URI (inside transaction) 438 + const forumId = await this.getForumIdByUri(record.forum.forum.uri, tx); 439 440 + if (!forumId) { 441 + console.warn( 442 + `[CREATE] Membership: Forum not found for ${record.forum.forum.uri}` 443 + ); 444 + return; 445 + } 446 447 + // Insert membership 448 + await tx.insert(memberships).values({ 449 + did: event.did, 450 + rkey: event.commit.rkey, 451 cid: event.commit.cid, 452 forumId, 453 forumUri: record.forum.forum.uri, 454 role: null, // TODO: Extract role name from roleUri or lexicon 455 roleUri: record.role?.role.uri ?? null, 456 joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 457 + createdAt: new Date(record.createdAt), 458 indexedAt: new Date(), 459 + }); 460 + }); 461 462 + console.log(`[CREATE] Membership: ${event.did}/${event.commit.rkey}`); 463 + } catch (error) { 464 + console.error( 465 + `Failed to index membership create: ${event.did}/${event.commit.rkey}`, 466 + error 467 ); 468 + throw error; 469 + } 470 } 471 472 + async handleMembershipUpdate( 473 + event: CommitUpdateEvent<"space.atbb.membership"> 474 + ) { 475 + try { 476 + const record = event.commit.record as unknown as Membership.Record; 477 478 + await this.db.transaction(async (tx) => { 479 + // Look up forum by URI (may have changed) 480 + const forumId = await this.getForumIdByUri(record.forum.forum.uri, tx); 481 482 + if (!forumId) { 483 + console.warn( 484 + `[UPDATE] Membership: Forum not found for ${record.forum.forum.uri}` 485 + ); 486 + return; 487 + } 488 489 + await tx 490 + .update(memberships) 491 + .set({ 492 + cid: event.commit.cid, 493 + forumId, 494 + forumUri: record.forum.forum.uri, 495 + role: null, // TODO: Extract role name from roleUri or lexicon 496 + roleUri: record.role?.role.uri ?? null, 497 + joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 498 + indexedAt: new Date(), 499 + }) 500 + .where( 501 + and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 502 + ); 503 + }); 504 + 505 + console.log(`[UPDATE] Membership: ${event.did}/${event.commit.rkey}`); 506 + } catch (error) { 507 + console.error( 508 + `Failed to update membership: ${event.did}/${event.commit.rkey}`, 509 + error 510 ); 511 + throw error; 512 } 513 + } 514 515 + async handleMembershipDelete( 516 + event: CommitDeleteEvent<"space.atbb.membership"> 517 + ) { 518 + try { 519 + // Hard delete 520 + await this.db 521 + .delete(memberships) 522 + .where( 523 + and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 524 + ); 525 526 + console.log(`[DELETE] Membership: ${event.did}/${event.commit.rkey}`); 527 + } catch (error) { 528 + console.error( 529 + `Failed to delete membership: ${event.did}/${event.commit.rkey}`, 530 + error 531 + ); 532 + throw error; 533 + } 534 } 535 536 + // ── ModAction Handlers ────────────────────────────────── 537 538 + async handleModActionCreate( 539 + event: CommitCreateEvent<"space.atbb.modAction"> 540 + ) { 541 + try { 542 + const record = event.commit.record as unknown as ModAction.Record; 543 + 544 // ModActions are owned by the Forum DID, so event.did IS the forum DID 545 + const forumId = await this.getForumIdByDid(event.did); 546 547 if (!forumId) { 548 console.warn( 549 + `[CREATE] ModAction: Forum not found for DID ${event.did}` 550 ); 551 return; 552 } 553 554 + await this.db.transaction(async (tx) => { 555 + // Ensure moderator exists 556 + await this.ensureUser(record.createdBy, tx); 557 558 + // Determine subject type (post or user) 559 + let subjectPostUri: string | null = null; 560 + let subjectDid: string | null = null; 561 + 562 + if (record.subject.post) { 563 + subjectPostUri = record.subject.post.uri; 564 + } 565 + if (record.subject.did) { 566 + subjectDid = record.subject.did; 567 + } 568 569 + // Insert mod action 570 + await tx.insert(modActions).values({ 571 + did: event.did, 572 + rkey: event.commit.rkey, 573 cid: event.commit.cid, 574 forumId, 575 action: record.action, ··· 578 reason: record.reason ?? null, 579 createdBy: record.createdBy, 580 expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 581 + createdAt: new Date(record.createdAt), 582 indexedAt: new Date(), 583 + }); 584 + }); 585 + 586 + console.log(`[CREATE] ModAction: ${event.did}/${event.commit.rkey}`); 587 + } catch (error) { 588 + console.error( 589 + `Failed to index mod action create: ${event.did}/${event.commit.rkey}`, 590 + error 591 + ); 592 + throw error; 593 + } 594 + } 595 + 596 + async handleModActionUpdate( 597 + event: CommitUpdateEvent<"space.atbb.modAction"> 598 + ) { 599 + try { 600 + const record = event.commit.record as unknown as ModAction.Record; 601 + 602 + await this.db.transaction(async (tx) => { 603 + // ModActions are owned by the Forum DID, so event.did IS the forum DID 604 + const forumId = await this.getForumIdByDid(event.did, tx); 605 + 606 + if (!forumId) { 607 + console.warn( 608 + `[UPDATE] ModAction: Forum not found for DID ${event.did}` 609 + ); 610 + return; 611 + } 612 + 613 + // Determine subject type (post or user) 614 + let subjectPostUri: string | null = null; 615 + let subjectDid: string | null = null; 616 + 617 + if (record.subject.post) { 618 + subjectPostUri = record.subject.post.uri; 619 + } 620 + if (record.subject.did) { 621 + subjectDid = record.subject.did; 622 + } 623 + 624 + await tx 625 + .update(modActions) 626 + .set({ 627 + cid: event.commit.cid, 628 + forumId, 629 + action: record.action, 630 + subjectPostUri, 631 + subjectDid, 632 + reason: record.reason ?? null, 633 + createdBy: record.createdBy, 634 + expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 635 + indexedAt: new Date(), 636 + }) 637 + .where( 638 + and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 639 + ); 640 + }); 641 + 642 + console.log(`[UPDATE] ModAction: ${event.did}/${event.commit.rkey}`); 643 + } catch (error) { 644 + console.error( 645 + `Failed to update mod action: ${event.did}/${event.commit.rkey}`, 646 + error 647 + ); 648 + throw error; 649 + } 650 + } 651 + 652 + async handleModActionDelete( 653 + event: CommitDeleteEvent<"space.atbb.modAction"> 654 + ) { 655 + try { 656 + // Hard delete 657 + await this.db 658 + .delete(modActions) 659 .where( 660 and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 661 ); 662 663 + console.log(`[DELETE] ModAction: ${event.did}/${event.commit.rkey}`); 664 + } catch (error) { 665 + console.error( 666 + `Failed to delete mod action: ${event.did}/${event.commit.rkey}`, 667 + error 668 + ); 669 + throw error; 670 + } 671 } 672 673 + // ── Reaction Handlers (Stub) ──────────────────────────── 674 675 + async handleReactionCreate( 676 + event: CommitCreateEvent<"space.atbb.reaction"> 677 + ) { 678 + console.log(`[CREATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 679 + // TODO: Add reactions table to schema 680 } 681 682 + async handleReactionUpdate( 683 + event: CommitUpdateEvent<"space.atbb.reaction"> 684 + ) { 685 + console.log(`[UPDATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 686 + // TODO: Add reactions table to schema 687 + } 688 689 + async handleReactionDelete( 690 + event: CommitDeleteEvent<"space.atbb.reaction"> 691 + ) { 692 + console.log(`[DELETE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 693 + // TODO: Add reactions table to schema 694 + } 695 }