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: make lookup functions transaction-aware and add mock transaction support

Addresses two critical issues from PR #7 code review:

1. Lookup functions now participate in transactions
- Added dbOrTx parameter to getForumIdByUri, getForumIdByDid, and getPostIdByUri
- Updated all handlers to pass transaction context to lookups
- Ensures lookups see uncommitted writes within the same transaction
- Fixes reply chain resolution when parent and child arrive in the same batch

2. Test mocks now support transactions
- Added transaction method to createMockDb() that executes callbacks
- Prevents TypeError: mockDb.transaction is not a function in tests

Additional improvements:
- Wrapped all multi-step handlers in transactions for atomicity
- handleCategoryCreate/Update, handleMembershipUpdate, handleModActionUpdate now use transactions

All tests pass (42/42).

+148 -115
+13
apps/appview/src/lib/__tests__/indexer.test.ts
··· 27 27 }), 28 28 }); 29 29 30 + const mockTransaction = vi.fn().mockImplementation(async (callback) => { 31 + // Create a transaction context that has the same methods as the db 32 + const txContext = { 33 + insert: mockInsert, 34 + update: mockUpdate, 35 + delete: mockDelete, 36 + select: mockSelect, 37 + }; 38 + // Execute the callback with the transaction context 39 + return await callback(txContext); 40 + }); 41 + 30 42 return { 31 43 insert: mockInsert, 32 44 update: mockUpdate, 33 45 delete: mockDelete, 34 46 select: mockSelect, 47 + transaction: mockTransaction, 35 48 } as unknown as Database; 36 49 }; 37 50
+135 -115
apps/appview/src/lib/indexer.ts
··· 79 79 80 80 /** 81 81 * Look up a forum ID by its AT URI 82 + * @param dbOrTx - Database instance or transaction 82 83 */ 83 - async function getForumIdByUri(forumUri: string): Promise<bigint | null> { 84 + async function getForumIdByUri( 85 + forumUri: string, 86 + dbOrTx: Database | Parameters<Parameters<Database['transaction']>[0]>[0] = db 87 + ): Promise<bigint | null> { 84 88 const parsed = parseAtUri(forumUri); 85 89 if (!parsed) return null; 86 90 87 - const result = await db 91 + const result = await dbOrTx 88 92 .select({ id: forums.id }) 89 93 .from(forums) 90 94 .where(and(eq(forums.did, parsed.did), eq(forums.rkey, parsed.rkey))) ··· 96 100 /** 97 101 * Look up a forum ID by the forum's DID 98 102 * Used for records owned by the forum (categories, modActions) 103 + * @param dbOrTx - Database instance or transaction 99 104 */ 100 - async function getForumIdByDid(forumDid: string): Promise<bigint | null> { 105 + async function getForumIdByDid( 106 + forumDid: string, 107 + dbOrTx: Database | Parameters<Parameters<Database['transaction']>[0]>[0] = db 108 + ): Promise<bigint | null> { 101 109 try { 102 - const result = await db 110 + const result = await dbOrTx 103 111 .select({ id: forums.id }) 104 112 .from(forums) 105 113 .where(eq(forums.did, forumDid)) ··· 114 122 115 123 /** 116 124 * Look up a post ID by its AT URI 125 + * @param dbOrTx - Database instance or transaction 117 126 */ 118 - async function getPostIdByUri(postUri: string): Promise<bigint | null> { 127 + async function getPostIdByUri( 128 + postUri: string, 129 + dbOrTx: Database | Parameters<Parameters<Database['transaction']>[0]>[0] = db 130 + ): Promise<bigint | null> { 119 131 const parsed = parseAtUri(postUri); 120 132 if (!parsed) return null; 121 133 122 - const result = await db 134 + const result = await dbOrTx 123 135 .select({ id: posts.id }) 124 136 .from(posts) 125 137 .where(and(eq(posts.did, parsed.did), eq(posts.rkey, parsed.rkey))) ··· 145 157 let parentId: bigint | null = null; 146 158 147 159 if (Post.isReplyRef(record.reply)) { 148 - rootId = await getPostIdByUri(record.reply.root.uri); 149 - parentId = await getPostIdByUri(record.reply.parent.uri); 160 + rootId = await getPostIdByUri(record.reply.root.uri, tx); 161 + parentId = await getPostIdByUri(record.reply.parent.uri, tx); 150 162 } 151 163 152 164 // Insert post ··· 312 324 try { 313 325 const record = event.commit.record as unknown as Category.Record; 314 326 315 - // Categories are owned by the Forum DID, so event.did IS the forum DID 316 - const forumId = await getForumIdByDid(event.did); 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); 317 330 318 - if (!forumId) { 319 - console.warn( 320 - `[CREATE] Category: Forum not found for DID ${event.did}` 321 - ); 322 - return; 323 - } 331 + if (!forumId) { 332 + console.warn( 333 + `[CREATE] Category: Forum not found for DID ${event.did}` 334 + ); 335 + return; 336 + } 324 337 325 - // Insert category 326 - await db.insert(categories).values({ 327 - did: event.did, 328 - rkey: event.commit.rkey, 329 - cid: event.commit.cid, 330 - forumId, 331 - name: record.name, 332 - description: record.description ?? null, 333 - slug: record.slug ?? null, 334 - sortOrder: record.sortOrder ?? 0, 335 - createdAt: new Date(record.createdAt), 336 - indexedAt: new Date(), 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 + }); 337 351 }); 338 352 339 353 console.log(`[CREATE] Category: ${event.did}/${event.commit.rkey}`); ··· 352 366 try { 353 367 const record = event.commit.record as unknown as Category.Record; 354 368 355 - // Categories are owned by the Forum DID, so event.did IS the forum DID 356 - const forumId = await getForumIdByDid(event.did); 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); 357 372 358 - if (!forumId) { 359 - console.warn( 360 - `[UPDATE] Category: Forum not found for DID ${event.did}` 361 - ); 362 - return; 363 - } 373 + if (!forumId) { 374 + console.warn( 375 + `[UPDATE] Category: Forum not found for DID ${event.did}` 376 + ); 377 + return; 378 + } 364 379 365 - await db 366 - .update(categories) 367 - .set({ 368 - cid: event.commit.cid, 369 - forumId, 370 - name: record.name, 371 - description: record.description ?? null, 372 - slug: record.slug ?? null, 373 - sortOrder: record.sortOrder ?? 0, 374 - indexedAt: new Date(), 375 - }) 376 - .where( 377 - and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 378 - ); 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 + }); 379 395 380 396 console.log(`[UPDATE] Category: ${event.did}/${event.commit.rkey}`); 381 397 } catch (error) { ··· 416 432 try { 417 433 const record = event.commit.record as unknown as Membership.Record; 418 434 419 - // Look up forum by URI (outside transaction) 420 - const forumId = await getForumIdByUri(record.forum.forum.uri); 421 - 422 - if (!forumId) { 423 - console.warn( 424 - `[CREATE] Membership: Forum not found for ${record.forum.forum.uri}` 425 - ); 426 - return; 427 - } 428 - 429 435 await db.transaction(async (tx) => { 430 436 // Ensure user exists 431 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 + } 432 448 433 449 // Insert membership 434 450 await tx.insert(memberships).values({ ··· 461 477 try { 462 478 const record = event.commit.record as unknown as Membership.Record; 463 479 464 - // Look up forum by URI (may have changed) 465 - const forumId = await getForumIdByUri((record.forum as any).forum.uri); 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); 466 483 467 - if (!forumId) { 468 - console.warn( 469 - `[UPDATE] Membership: Forum not found for ${(record.forum as any).forum.uri}` 470 - ); 471 - return; 472 - } 484 + if (!forumId) { 485 + console.warn( 486 + `[UPDATE] Membership: Forum not found for ${record.forum.forum.uri}` 487 + ); 488 + return; 489 + } 473 490 474 - await db 475 - .update(memberships) 476 - .set({ 477 - cid: event.commit.cid, 478 - forumId, 479 - forumUri: record.forum.forum.uri, 480 - role: null, // TODO: Extract role name from roleUri or lexicon 481 - roleUri: record.role?.role.uri ?? null, 482 - joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 483 - indexedAt: new Date(), 484 - }) 485 - .where( 486 - and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 487 - ); 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 + }); 488 506 489 507 console.log(`[UPDATE] Membership: ${event.did}/${event.commit.rkey}`); 490 508 } catch (error) { ··· 583 601 try { 584 602 const record = event.commit.record as unknown as ModAction.Record; 585 603 586 - // ModActions are owned by the Forum DID, so event.did IS the forum DID 587 - const forumId = await getForumIdByDid(event.did); 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); 588 607 589 - if (!forumId) { 590 - console.warn( 591 - `[UPDATE] ModAction: Forum not found for DID ${event.did}` 592 - ); 593 - return; 594 - } 608 + if (!forumId) { 609 + console.warn( 610 + `[UPDATE] ModAction: Forum not found for DID ${event.did}` 611 + ); 612 + return; 613 + } 595 614 596 - // Determine subject type (post or user) 597 - let subjectPostUri: string | null = null; 598 - let subjectDid: string | null = null; 615 + // Determine subject type (post or user) 616 + let subjectPostUri: string | null = null; 617 + let subjectDid: string | null = null; 599 618 600 - if (record.subject.post) { 601 - subjectPostUri = record.subject.post.uri; 602 - } 603 - if (record.subject.did) { 604 - subjectDid = record.subject.did; 605 - } 619 + if (record.subject.post) { 620 + subjectPostUri = record.subject.post.uri; 621 + } 622 + if (record.subject.did) { 623 + subjectDid = record.subject.did; 624 + } 606 625 607 - await db 608 - .update(modActions) 609 - .set({ 610 - cid: event.commit.cid, 611 - forumId, 612 - action: record.action, 613 - subjectPostUri, 614 - subjectDid, 615 - reason: record.reason ?? null, 616 - createdBy: record.createdBy, 617 - expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 618 - indexedAt: new Date(), 619 - }) 620 - .where( 621 - and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 622 - ); 626 + await tx 627 + .update(modActions) 628 + .set({ 629 + cid: event.commit.cid, 630 + forumId, 631 + action: record.action, 632 + subjectPostUri, 633 + subjectDid, 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 + }); 623 643 624 644 console.log(`[UPDATE] ModAction: ${event.did}/${event.commit.rkey}`); 625 645 } catch (error) {