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

fix: address PR #7 critical review issues

Address all 7 blocking issues identified in comprehensive PR review:

1. parseAtUri: Replace URL constructor with regex for at:// scheme support
2. Collection names: Use full lexicon IDs (space.atbb.forum.forum, space.atbb.forum.category)
3. Forum resolution: Add getForumIdByDid() for category/modAction records owned by Forum DID
4. ModAction subject: Access record.subject.post.uri and record.subject.did correctly
5. Circuit breaker: Track consecutive failures (max 100), stop firehose on threshold
6. Transactions: Wrap ensureUser + insert operations in db.transaction()
7. Reconnection state: Set isRunning=false on exhaustion, add health check methods

Additional improvements:
- Propagate errors from all handlers to circuit breaker
- Update test collection names and add type assertions
- Enhance error logging with event context

+289 -163
+22 -22
apps/appview/src/lib/__tests__/indexer.test.ts
··· 61 61 $type: "space.atbb.post", 62 62 text: "Hello world", 63 63 createdAt: "2024-01-01T00:00:00Z", 64 - }, 64 + } as any, 65 65 }, 66 66 }; 67 67 ··· 93 93 }, 94 94 }, 95 95 createdAt: "2024-01-01T00:00:00Z", 96 - }, 96 + } as any, 97 97 }, 98 98 }; 99 99 ··· 129 129 }, 130 130 }, 131 131 createdAt: "2024-01-01T01:00:00Z", 132 - }, 132 + } as any, 133 133 }, 134 134 }; 135 135 ··· 155 155 $type: "space.atbb.post", 156 156 text: "Updated text", 157 157 createdAt: "2024-01-01T00:00:00Z", 158 - }, 158 + } as any, 159 159 }, 160 160 }; 161 161 ··· 189 189 it("should handle forum creation", async () => { 190 190 const { handleForumCreate } = await import("../indexer.js"); 191 191 192 - const event: CommitCreateEvent<"space.atbb.forum"> = { 192 + const event: CommitCreateEvent<"space.atbb.forum.forum"> = { 193 193 did: "did:plc:forum", 194 194 time_us: 1234567890, 195 195 kind: "commit", 196 196 commit: { 197 197 rev: "abc", 198 198 operation: "create", 199 - collection: "space.atbb.forum", 199 + collection: "space.atbb.forum.forum", 200 200 rkey: "self", 201 201 cid: "cidForum", 202 202 record: { 203 - $type: "space.atbb.forum", 203 + $type: "space.atbb.forum.forum", 204 204 name: "Test Forum", 205 205 description: "A test forum", 206 - }, 206 + } as any, 207 207 }, 208 208 }; 209 209 ··· 215 215 it("should handle forum update", async () => { 216 216 const { handleForumUpdate } = await import("../indexer.js"); 217 217 218 - const event: CommitUpdateEvent<"space.atbb.forum"> = { 218 + const event: CommitUpdateEvent<"space.atbb.forum.forum"> = { 219 219 did: "did:plc:forum", 220 220 time_us: 1234567890, 221 221 kind: "commit", 222 222 commit: { 223 223 rev: "abc", 224 224 operation: "update", 225 - collection: "space.atbb.forum", 225 + collection: "space.atbb.forum.forum", 226 226 rkey: "self", 227 227 cid: "cidForumNew", 228 228 record: { 229 - $type: "space.atbb.forum", 229 + $type: "space.atbb.forum.forum", 230 230 name: "Updated Forum Name", 231 231 description: "Updated description", 232 - }, 232 + } as any, 233 233 }, 234 234 }; 235 235 ··· 241 241 it("should handle forum deletion", async () => { 242 242 const { handleForumDelete } = await import("../indexer.js"); 243 243 244 - const event: CommitDeleteEvent<"space.atbb.forum"> = { 244 + const event: CommitDeleteEvent<"space.atbb.forum.forum"> = { 245 245 did: "did:plc:forum", 246 246 time_us: 1234567890, 247 247 kind: "commit", 248 248 commit: { 249 249 rev: "abc", 250 250 operation: "delete", 251 - collection: "space.atbb.forum", 251 + collection: "space.atbb.forum.forum", 252 252 rkey: "self", 253 253 }, 254 254 }; ··· 263 263 it("should handle category creation without errors", async () => { 264 264 const { handleCategoryCreate } = await import("../indexer.js"); 265 265 266 - const event: CommitCreateEvent<"space.atbb.category"> = { 266 + const event: CommitCreateEvent<"space.atbb.forum.category"> = { 267 267 did: "did:plc:forum", 268 268 time_us: 1234567890, 269 269 kind: "commit", 270 270 commit: { 271 271 rev: "abc", 272 272 operation: "create", 273 - collection: "space.atbb.category", 273 + collection: "space.atbb.forum.category", 274 274 rkey: "cat1", 275 275 cid: "cidCat", 276 276 record: { 277 - $type: "space.atbb.category", 277 + $type: "space.atbb.forum.category", 278 278 name: "General Discussion", 279 279 forum: { 280 280 forum: { ··· 285 285 slug: "general-discussion", 286 286 sortOrder: 0, 287 287 createdAt: "2024-01-01T00:00:00Z", 288 - }, 288 + } as any, 289 289 }, 290 290 }; 291 291 ··· 306 306 }), 307 307 } as any); 308 308 309 - const event: CommitCreateEvent<"space.atbb.category"> = { 309 + const event: CommitCreateEvent<"space.atbb.forum.category"> = { 310 310 did: "did:plc:forum", 311 311 time_us: 1234567890, 312 312 kind: "commit", 313 313 commit: { 314 314 rev: "abc", 315 315 operation: "create", 316 - collection: "space.atbb.category", 316 + collection: "space.atbb.forum.category", 317 317 rkey: "cat1", 318 318 cid: "cidCat", 319 319 record: { 320 - $type: "space.atbb.category", 320 + $type: "space.atbb.forum.category", 321 321 name: "General Discussion", 322 322 forum: { 323 323 forum: { ··· 326 326 }, 327 327 }, 328 328 createdAt: "2024-01-01T00:00:00Z", 329 - }, 329 + } as any, 330 330 }, 331 331 }; 332 332
+111 -28
apps/appview/src/lib/firehose.ts
··· 14 14 private readonly maxReconnectAttempts = 10; 15 15 private readonly reconnectDelayMs = 5000; 16 16 17 - // Collections we're interested in 17 + // Circuit breaker for handler failures 18 + private consecutiveFailures = 0; 19 + private readonly maxConsecutiveFailures = 100; 20 + 21 + // Collections we're interested in (full lexicon IDs) 18 22 private readonly wantedCollections = [ 19 23 "space.atbb.post", 20 - "space.atbb.forum", 21 - "space.atbb.category", 24 + "space.atbb.forum.forum", 25 + "space.atbb.forum.category", 22 26 "space.atbb.membership", 23 27 "space.atbb.modAction", 24 28 "space.atbb.reaction", ··· 56 60 this.handlePostCreate(event); 57 61 }); 58 62 59 - this.jetstream.onCreate("space.atbb.forum", (event) => { 63 + this.jetstream.onCreate("space.atbb.forum.forum", (event) => { 60 64 this.handleForumCreate(event); 61 65 }); 62 66 63 - this.jetstream.onCreate("space.atbb.category", (event) => { 67 + this.jetstream.onCreate("space.atbb.forum.category", (event) => { 64 68 this.handleCategoryCreate(event); 65 69 }); 66 70 ··· 81 85 this.handlePostUpdate(event); 82 86 }); 83 87 84 - this.jetstream.onUpdate("space.atbb.forum", (event) => { 88 + this.jetstream.onUpdate("space.atbb.forum.forum", (event) => { 85 89 this.handleForumUpdate(event); 86 90 }); 87 91 88 - this.jetstream.onUpdate("space.atbb.category", (event) => { 92 + this.jetstream.onUpdate("space.atbb.forum.category", (event) => { 89 93 this.handleCategoryUpdate(event); 90 94 }); 91 95 ··· 106 110 this.handlePostDelete(event); 107 111 }); 108 112 109 - this.jetstream.onDelete("space.atbb.forum", (event) => { 113 + this.jetstream.onDelete("space.atbb.forum.forum", (event) => { 110 114 this.handleForumDelete(event); 111 115 }); 112 116 113 - this.jetstream.onDelete("space.atbb.category", (event) => { 117 + this.jetstream.onDelete("space.atbb.forum.category", (event) => { 114 118 this.handleCategoryDelete(event); 115 119 }); 116 120 ··· 186 190 } 187 191 188 192 /** 193 + * Check if the firehose is healthy and actively indexing 194 + */ 195 + isHealthy(): boolean { 196 + return this.isRunning; 197 + } 198 + 199 + /** 200 + * Get detailed health status for monitoring 201 + */ 202 + getHealthStatus(): { 203 + isRunning: boolean; 204 + reconnectAttempts: number; 205 + consecutiveFailures: number; 206 + maxReconnectAttempts: number; 207 + maxConsecutiveFailures: number; 208 + } { 209 + return { 210 + isRunning: this.isRunning, 211 + reconnectAttempts: this.reconnectAttempts, 212 + consecutiveFailures: this.consecutiveFailures, 213 + maxReconnectAttempts: this.maxReconnectAttempts, 214 + maxConsecutiveFailures: this.maxConsecutiveFailures, 215 + }; 216 + } 217 + 218 + /** 189 219 * Handle reconnection with exponential backoff 190 220 */ 191 221 private async handleReconnect() { 192 222 if (this.reconnectAttempts >= this.maxReconnectAttempts) { 193 223 console.error( 194 - `Max reconnect attempts (${this.maxReconnectAttempts}) reached. Giving up.` 224 + `[FATAL] Max reconnect attempts (${this.maxReconnectAttempts}) reached. Firehose indexing has stopped.` 225 + ); 226 + console.error( 227 + `[FATAL] The appview will continue serving stale data. Manual intervention required.` 195 228 ); 229 + this.isRunning = false; 196 230 return; 197 231 } 198 232 ··· 251 285 } 252 286 } 253 287 288 + // ── Circuit Breaker ───────────────────────────────────── 289 + 290 + /** 291 + * Wrap handler to track failures and stop firehose on excessive errors 292 + */ 293 + private async wrapHandler<T>( 294 + handler: (event: T) => Promise<void>, 295 + event: T, 296 + handlerName: string 297 + ): Promise<void> { 298 + try { 299 + await handler(event); 300 + // Success - reset failure counter 301 + this.consecutiveFailures = 0; 302 + } catch (error) { 303 + this.consecutiveFailures++; 304 + console.error( 305 + `[HANDLER ERROR] ${handlerName} failed (${this.consecutiveFailures}/${this.maxConsecutiveFailures}):`, 306 + error 307 + ); 308 + 309 + // Check circuit breaker threshold 310 + if (this.consecutiveFailures >= this.maxConsecutiveFailures) { 311 + console.error( 312 + `[CIRCUIT BREAKER] Max consecutive failures (${this.maxConsecutiveFailures}) reached. Stopping firehose to prevent data loss.` 313 + ); 314 + await this.stop(); 315 + } 316 + } 317 + } 318 + 254 319 // ── Event Handlers ────────────────────────────────────── 255 320 256 - private handlePostCreate = indexer.handlePostCreate; 257 - private handlePostUpdate = indexer.handlePostUpdate; 258 - private handlePostDelete = indexer.handlePostDelete; 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"); 259 327 260 - private handleForumCreate = indexer.handleForumCreate; 261 - private handleForumUpdate = indexer.handleForumUpdate; 262 - private handleForumDelete = indexer.handleForumDelete; 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"); 263 334 264 - private handleCategoryCreate = indexer.handleCategoryCreate; 265 - private handleCategoryUpdate = indexer.handleCategoryUpdate; 266 - private handleCategoryDelete = indexer.handleCategoryDelete; 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"); 267 341 268 - private handleMembershipCreate = indexer.handleMembershipCreate; 269 - private handleMembershipUpdate = indexer.handleMembershipUpdate; 270 - private handleMembershipDelete = indexer.handleMembershipDelete; 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"); 271 348 272 - private handleModActionCreate = indexer.handleModActionCreate; 273 - private handleModActionUpdate = indexer.handleModActionUpdate; 274 - private handleModActionDelete = indexer.handleModActionDelete; 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"); 275 355 276 - private handleReactionCreate = indexer.handleReactionCreate; 277 - private handleReactionUpdate = indexer.handleReactionUpdate; 278 - private handleReactionDelete = indexer.handleReactionDelete; 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"); 279 362 }
+156 -113
apps/appview/src/lib/indexer.ts
··· 31 31 32 32 /** 33 33 * Parse an AT Proto URI to extract DID, collection, and rkey 34 - * Format: at://did:plc:xxx/collection/rkey 34 + * Format: at://did:plc:xxx/collection.name/rkey 35 35 */ 36 36 function parseAtUri(uri: string): { 37 37 did: string; ··· 39 39 rkey: string; 40 40 } | null { 41 41 try { 42 - const url = new URL(uri); 43 - if (url.protocol !== "at:") return null; 44 - 45 - const did = url.hostname; 46 - const parts = url.pathname.split("/").filter((p) => p); 47 - if (parts.length < 2) return null; 48 - 49 - const rkey = parts[parts.length - 1]; 50 - const collection = parts.slice(0, -1).join("."); 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 + } 51 49 50 + const [, did, collection, rkey] = match; 52 51 return { did, collection, rkey }; 53 - } catch { 52 + } catch (error) { 53 + console.error(`Failed to parse AT URI: ${uri}`, error); 54 54 return null; 55 55 } 56 56 } 57 57 58 58 /** 59 59 * Ensure a user exists in the database. Creates if not exists. 60 + * @param dbOrTx - Database instance or transaction 60 61 */ 61 - async function ensureUser(did: string) { 62 + async function ensureUser(did: string, dbOrTx: Database | Parameters<Parameters<Database['transaction']>[0]>[0] = db) { 62 63 try { 63 - const existing = await db.select().from(users).where(eq(users.did, did)).limit(1); 64 + const existing = await dbOrTx.select().from(users).where(eq(users.did, did)).limit(1); 64 65 65 66 if (existing.length === 0) { 66 - await db.insert(users).values({ 67 + await dbOrTx.insert(users).values({ 67 68 did, 68 69 handle: null, // Will be updated by identity events 69 70 indexedAt: new Date(), ··· 93 94 } 94 95 95 96 /** 97 + * Look up a forum ID by the forum's DID 98 + * Used for records owned by the forum (categories, modActions) 99 + */ 100 + async function getForumIdByDid(forumDid: string): Promise<bigint | null> { 101 + try { 102 + const result = await db 103 + .select({ id: forums.id }) 104 + .from(forums) 105 + .where(eq(forums.did, forumDid)) 106 + .limit(1); 107 + 108 + return result.length > 0 ? result[0].id : null; 109 + } catch (error) { 110 + console.error(`Failed to look up forum by DID: ${forumDid}`, error); 111 + return null; 112 + } 113 + } 114 + 115 + /** 96 116 * Look up a post ID by its AT URI 97 117 */ 98 118 async function getPostIdByUri(postUri: string): Promise<bigint | null> { ··· 116 136 try { 117 137 const record = event.commit.record as unknown as Post.Record; 118 138 119 - // Ensure author exists 120 - await ensureUser(event.did); 139 + await db.transaction(async (tx) => { 140 + // Ensure author exists 141 + await ensureUser(event.did, tx); 121 142 122 - // Look up parent/root for replies 123 - let rootId: bigint | null = null; 124 - let parentId: bigint | null = null; 143 + // Look up parent/root for replies 144 + let rootId: bigint | null = null; 145 + let parentId: bigint | null = null; 125 146 126 - if (Post.isReplyRef(record.reply)) { 127 - rootId = await getPostIdByUri(record.reply.root.uri); 128 - parentId = await getPostIdByUri(record.reply.parent.uri); 129 - } 147 + if (Post.isReplyRef(record.reply)) { 148 + rootId = await getPostIdByUri(record.reply.root.uri); 149 + parentId = await getPostIdByUri(record.reply.parent.uri); 150 + } 130 151 131 - // Insert post 132 - await db.insert(posts).values({ 133 - did: event.did, 134 - rkey: event.commit.rkey, 135 - cid: event.commit.cid, 136 - text: record.text, 137 - forumUri: record.forum?.forum.uri ?? null, 138 - rootPostId: rootId, 139 - rootUri: record.reply?.root.uri ?? null, 140 - parentPostId: parentId, 141 - parentUri: record.reply?.parent.uri ?? null, 142 - createdAt: new Date(record.createdAt), 143 - indexedAt: new Date(), 152 + // Insert post 153 + await tx.insert(posts).values({ 154 + did: event.did, 155 + rkey: event.commit.rkey, 156 + cid: event.commit.cid, 157 + text: record.text, 158 + forumUri: record.forum?.forum.uri ?? null, 159 + rootPostId: rootId, 160 + rootUri: record.reply?.root.uri ?? null, 161 + parentPostId: parentId, 162 + parentUri: record.reply?.parent.uri ?? null, 163 + createdAt: new Date(record.createdAt), 164 + indexedAt: new Date(), 165 + }); 144 166 }); 145 167 146 168 console.log(`[CREATE] Post: ${event.did}/${event.commit.rkey}`); ··· 149 171 `Failed to index post create: ${event.did}/${event.commit.rkey}`, 150 172 error 151 173 ); 174 + throw error; 152 175 } 153 176 } 154 177 ··· 175 198 `Failed to update post: ${event.did}/${event.commit.rkey}`, 176 199 error 177 200 ); 201 + throw error; 178 202 } 179 203 } 180 204 ··· 196 220 `Failed to delete post: ${event.did}/${event.commit.rkey}`, 197 221 error 198 222 ); 223 + throw error; 199 224 } 200 225 } 201 226 202 227 // ── Forum Handlers ────────────────────────────────────── 203 228 204 229 export async function handleForumCreate( 205 - event: CommitCreateEvent<"space.atbb.forum"> 230 + event: CommitCreateEvent<"space.atbb.forum.forum"> 206 231 ) { 207 232 try { 208 233 const record = event.commit.record as unknown as Forum.Record; 209 234 210 - // Ensure owner exists 211 - await ensureUser(event.did); 235 + await db.transaction(async (tx) => { 236 + // Ensure owner exists 237 + await ensureUser(event.did, tx); 212 238 213 - // Insert forum 214 - await db.insert(forums).values({ 215 - did: event.did, 216 - rkey: event.commit.rkey, 217 - cid: event.commit.cid, 218 - name: record.name, 219 - description: record.description ?? null, 220 - indexedAt: new Date(), 239 + // Insert forum 240 + await tx.insert(forums).values({ 241 + did: event.did, 242 + rkey: event.commit.rkey, 243 + cid: event.commit.cid, 244 + name: record.name, 245 + description: record.description ?? null, 246 + indexedAt: new Date(), 247 + }); 221 248 }); 222 249 223 250 console.log(`[CREATE] Forum: ${event.did}/${event.commit.rkey}`); ··· 226 253 `Failed to index forum create: ${event.did}/${event.commit.rkey}`, 227 254 error 228 255 ); 256 + throw error; 229 257 } 230 258 } 231 259 232 260 export async function handleForumUpdate( 233 - event: CommitUpdateEvent<"space.atbb.forum"> 261 + event: CommitUpdateEvent<"space.atbb.forum.forum"> 234 262 ) { 235 263 try { 236 264 const record = event.commit.record as unknown as Forum.Record; ··· 251 279 `Failed to update forum: ${event.did}/${event.commit.rkey}`, 252 280 error 253 281 ); 282 + throw error; 254 283 } 255 284 } 256 285 257 286 export async function handleForumDelete( 258 - event: CommitDeleteEvent<"space.atbb.forum"> 287 + event: CommitDeleteEvent<"space.atbb.forum.forum"> 259 288 ) { 260 289 try { 261 290 // Hard delete ··· 271 300 `Failed to delete forum: ${event.did}/${event.commit.rkey}`, 272 301 error 273 302 ); 303 + throw error; 274 304 } 275 305 } 276 306 277 307 // ── Category Handlers ─────────────────────────────────── 278 308 279 309 export async function handleCategoryCreate( 280 - event: CommitCreateEvent<"space.atbb.category"> 310 + event: CommitCreateEvent<"space.atbb.forum.category"> 281 311 ) { 282 312 try { 283 313 const record = event.commit.record as unknown as Category.Record; 284 314 285 - // Look up forum by URI 286 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 315 + // Categories are owned by the Forum DID, so event.did IS the forum DID 316 + const forumId = await getForumIdByDid(event.did); 287 317 288 318 if (!forumId) { 289 319 console.warn( 290 - `[CREATE] Category: Forum not found for ${(record.forum as any).forum.uri}` 320 + `[CREATE] Category: Forum not found for DID ${event.did}` 291 321 ); 292 322 return; 293 323 } ··· 312 342 `Failed to index category create: ${event.did}/${event.commit.rkey}`, 313 343 error 314 344 ); 345 + throw error; 315 346 } 316 347 } 317 348 318 349 export async function handleCategoryUpdate( 319 - event: CommitUpdateEvent<"space.atbb.category"> 350 + event: CommitUpdateEvent<"space.atbb.forum.category"> 320 351 ) { 321 352 try { 322 353 const record = event.commit.record as unknown as Category.Record; 323 354 324 - // Look up forum by URI (may have changed) 325 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 355 + // Categories are owned by the Forum DID, so event.did IS the forum DID 356 + const forumId = await getForumIdByDid(event.did); 326 357 327 358 if (!forumId) { 328 359 console.warn( 329 - `[UPDATE] Category: Forum not found for ${(record.forum as any).forum.uri}` 360 + `[UPDATE] Category: Forum not found for DID ${event.did}` 330 361 ); 331 362 return; 332 363 } ··· 352 383 `Failed to update category: ${event.did}/${event.commit.rkey}`, 353 384 error 354 385 ); 386 + throw error; 355 387 } 356 388 } 357 389 358 390 export async function handleCategoryDelete( 359 - event: CommitDeleteEvent<"space.atbb.category"> 391 + event: CommitDeleteEvent<"space.atbb.forum.category"> 360 392 ) { 361 393 try { 362 394 // Hard delete ··· 372 404 `Failed to delete category: ${event.did}/${event.commit.rkey}`, 373 405 error 374 406 ); 407 + throw error; 375 408 } 376 409 } 377 410 ··· 383 416 try { 384 417 const record = event.commit.record as unknown as Membership.Record; 385 418 386 - // Ensure user exists 387 - await ensureUser(event.did); 388 - 389 - // Look up forum by URI 390 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 419 + // Look up forum by URI (outside transaction) 420 + const forumId = await getForumIdByUri(record.forum.forum.uri); 391 421 392 422 if (!forumId) { 393 423 console.warn( 394 - `[CREATE] Membership: Forum not found for ${(record.forum as any).forum.uri}` 424 + `[CREATE] Membership: Forum not found for ${record.forum.forum.uri}` 395 425 ); 396 426 return; 397 427 } 398 428 399 - // Insert membership 400 - await db.insert(memberships).values({ 401 - did: event.did, 402 - rkey: event.commit.rkey, 403 - cid: event.commit.cid, 404 - forumId, 405 - forumUri: record.forum.forum.uri, 406 - role: null, // TODO: Extract role name from roleUri or lexicon 407 - roleUri: record.role?.role.uri ?? null, 408 - joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 409 - createdAt: new Date(record.createdAt), 410 - indexedAt: new Date(), 429 + await db.transaction(async (tx) => { 430 + // Ensure user exists 431 + await ensureUser(event.did, tx); 432 + 433 + // Insert membership 434 + await tx.insert(memberships).values({ 435 + did: event.did, 436 + rkey: event.commit.rkey, 437 + cid: event.commit.cid, 438 + forumId, 439 + forumUri: record.forum.forum.uri, 440 + role: null, // TODO: Extract role name from roleUri or lexicon 441 + roleUri: record.role?.role.uri ?? null, 442 + joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 443 + createdAt: new Date(record.createdAt), 444 + indexedAt: new Date(), 445 + }); 411 446 }); 412 447 413 448 console.log(`[CREATE] Membership: ${event.did}/${event.commit.rkey}`); ··· 416 451 `Failed to index membership create: ${event.did}/${event.commit.rkey}`, 417 452 error 418 453 ); 454 + throw error; 419 455 } 420 456 } 421 457 ··· 456 492 `Failed to update membership: ${event.did}/${event.commit.rkey}`, 457 493 error 458 494 ); 495 + throw error; 459 496 } 460 497 } 461 498 ··· 476 513 `Failed to delete membership: ${event.did}/${event.commit.rkey}`, 477 514 error 478 515 ); 516 + throw error; 479 517 } 480 518 } 481 519 ··· 487 525 try { 488 526 const record = event.commit.record as unknown as ModAction.Record; 489 527 490 - // Ensure moderator exists 491 - await ensureUser(event.did); 492 - 493 - // Look up forum by URI 494 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 528 + // ModActions are owned by the Forum DID, so event.did IS the forum DID 529 + const forumId = await getForumIdByDid(event.did); 495 530 496 531 if (!forumId) { 497 532 console.warn( 498 - `[CREATE] ModAction: Forum not found for ${(record.forum as any).forum.uri}` 533 + `[CREATE] ModAction: Forum not found for DID ${event.did}` 499 534 ); 500 535 return; 501 536 } 502 537 503 - // Determine subject type (post or user) 504 - let subjectPostUri: string | null = null; 505 - let subjectDid: string | null = null; 538 + await db.transaction(async (tx) => { 539 + // Ensure moderator exists 540 + await ensureUser(record.createdBy, tx); 506 541 507 - if ((record.subject as any).uri.includes("/space.atbb.post/")) { 508 - subjectPostUri = (record.subject as any).uri; 509 - } else { 510 - const parsed = parseAtUri((record.subject as any).uri); 511 - if (parsed) subjectDid = parsed.did; 512 - } 542 + // Determine subject type (post or user) 543 + let subjectPostUri: string | null = null; 544 + let subjectDid: string | null = null; 513 545 514 - // Insert mod action 515 - await db.insert(modActions).values({ 516 - did: event.did, 517 - rkey: event.commit.rkey, 518 - cid: event.commit.cid, 519 - forumId, 520 - action: record.action, 521 - subjectPostUri, 522 - subjectDid, 523 - reason: record.reason ?? null, 524 - createdBy: record.createdBy, 525 - expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 526 - createdAt: new Date(record.createdAt), 527 - indexedAt: new Date(), 546 + if (record.subject.post) { 547 + subjectPostUri = record.subject.post.uri; 548 + } 549 + if (record.subject.did) { 550 + subjectDid = record.subject.did; 551 + } 552 + 553 + // Insert mod action 554 + await tx.insert(modActions).values({ 555 + did: event.did, 556 + rkey: event.commit.rkey, 557 + cid: event.commit.cid, 558 + forumId, 559 + action: record.action, 560 + subjectPostUri, 561 + subjectDid, 562 + reason: record.reason ?? null, 563 + createdBy: record.createdBy, 564 + expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 565 + createdAt: new Date(record.createdAt), 566 + indexedAt: new Date(), 567 + }); 528 568 }); 529 569 530 570 console.log(`[CREATE] ModAction: ${event.did}/${event.commit.rkey}`); ··· 533 573 `Failed to index mod action create: ${event.did}/${event.commit.rkey}`, 534 574 error 535 575 ); 576 + throw error; 536 577 } 537 578 } 538 579 ··· 542 583 try { 543 584 const record = event.commit.record as unknown as ModAction.Record; 544 585 545 - // Look up forum by URI (may have changed) 546 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 586 + // ModActions are owned by the Forum DID, so event.did IS the forum DID 587 + const forumId = await getForumIdByDid(event.did); 547 588 548 589 if (!forumId) { 549 590 console.warn( 550 - `[UPDATE] ModAction: Forum not found for ${(record.forum as any).forum.uri}` 591 + `[UPDATE] ModAction: Forum not found for DID ${event.did}` 551 592 ); 552 593 return; 553 594 } ··· 556 597 let subjectPostUri: string | null = null; 557 598 let subjectDid: string | null = null; 558 599 559 - if ((record.subject as any).uri.includes("/space.atbb.post/")) { 560 - subjectPostUri = (record.subject as any).uri; 561 - } else { 562 - const parsed = parseAtUri((record.subject as any).uri); 563 - if (parsed) subjectDid = parsed.did; 600 + if (record.subject.post) { 601 + subjectPostUri = record.subject.post.uri; 602 + } 603 + if (record.subject.did) { 604 + subjectDid = record.subject.did; 564 605 } 565 606 566 607 await db ··· 586 627 `Failed to update mod action: ${event.did}/${event.commit.rkey}`, 587 628 error 588 629 ); 630 + throw error; 589 631 } 590 632 } 591 633 ··· 606 648 `Failed to delete mod action: ${event.did}/${event.commit.rkey}`, 607 649 error 608 650 ); 651 + throw error; 609 652 } 610 653 } 611 654