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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(appview): update indexer to store role permissions in role_permissions table

- Remove permissions field from roleConfig.toInsertValues and toUpdateValues
(the permissions text[] column no longer exists on the roles table)
- Add optional afterUpsert hook to CollectionConfig for post-row child writes
- Implement roleConfig.afterUpsert to delete+insert into role_permissions
within the same transaction, using .returning({ id }) to get the row id
- Update genericCreate and genericUpdate to call afterUpsert when defined
- Rewrite indexer-roles test assertions to query role_permissions table
- Remove permissions field from direct db.insert(roles) test setup calls

Malpercio 57badb14 729390d0

+81 -18
+23 -7
apps/appview/src/lib/__tests__/indexer-roles.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 2 import { Indexer } from "../indexer.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 - import { roles } from "@atbb/db"; 4 + import { roles, rolePermissions } from "@atbb/db"; 5 5 import { eq } from "drizzle-orm"; 6 6 import type { 7 7 CommitCreateEvent, ··· 56 56 expect(role).toBeDefined(); 57 57 expect(role.name).toBe("Moderator"); 58 58 expect(role.description).toBe("Can moderate posts"); 59 - expect(role.permissions).toEqual(["space.atbb.permission.moderatePosts"]); 60 59 expect(role.priority).toBe(10); 60 + 61 + const perms = await ctx.db 62 + .select({ permission: rolePermissions.permission }) 63 + .from(rolePermissions) 64 + .where(eq(rolePermissions.roleId, role.id)); 65 + expect(perms.map((p) => p.permission)).toEqual([ 66 + "space.atbb.permission.moderatePosts", 67 + ]); 61 68 }); 62 69 63 70 it("handleRoleCreate indexes role without optional description", async () => { ··· 94 101 }); 95 102 96 103 it("handleRoleUpdate updates role fields", async () => { 97 - // First create a role 104 + // First create a role (no permissions column — permissions live in role_permissions) 98 105 await ctx.db.insert(roles).values({ 99 106 did: "did:plc:test-forum", 100 107 rkey: "role3", 101 108 cid: "bafyold", 102 109 name: "Old Name", 103 110 description: "Old description", 104 - permissions: ["space.atbb.permission.createPosts"], 105 111 priority: 30, 106 112 createdAt: new Date(), 107 113 indexedAt: new Date(), ··· 141 147 142 148 expect(role.name).toBe("Updated Name"); 143 149 expect(role.description).toBe("Updated description"); 144 - expect(role.permissions).toHaveLength(2); 145 150 expect(role.priority).toBe(20); 146 151 expect(role.cid).toBe("bafynew"); 152 + 153 + const perms = await ctx.db 154 + .select({ permission: rolePermissions.permission }) 155 + .from(rolePermissions) 156 + .where(eq(rolePermissions.roleId, role.id)); 157 + expect(perms).toHaveLength(2); 158 + expect(perms.map((p) => p.permission)).toEqual( 159 + expect.arrayContaining([ 160 + "space.atbb.permission.createPosts", 161 + "space.atbb.permission.moderatePosts", 162 + ]) 163 + ); 147 164 }); 148 165 149 166 it("handleRoleDelete removes role record", async () => { 150 - // First create a role 167 + // First create a role (no permissions column — permissions live in role_permissions) 151 168 await ctx.db.insert(roles).values({ 152 169 did: "did:plc:test-forum", 153 170 rkey: "role4", 154 171 cid: "bafyrole4", 155 172 name: "To Delete", 156 - permissions: [], 157 173 priority: 99, 158 174 createdAt: new Date(), 159 175 indexedAt: new Date(),
+58 -11
apps/appview/src/lib/indexer.ts
··· 14 14 memberships, 15 15 modActions, 16 16 roles, 17 + rolePermissions, 17 18 } from "@atbb/db"; 18 19 import { eq, and } from "drizzle-orm"; 19 20 import { parseAtUri } from "./at-uri.js"; ··· 62 63 record: TRecord, 63 64 tx: DbOrTransaction 64 65 ) => Promise<Record<string, any> | null>; 66 + /** 67 + * Optional hook called after a row is inserted or updated, within the same 68 + * transaction. Receives the row's numeric id (bigint) so callers can write 69 + * to child tables (e.g. role_permissions). 70 + */ 71 + afterUpsert?: ( 72 + event: any, 73 + record: TRecord, 74 + rowId: bigint, 75 + tx: DbOrTransaction 76 + ) => Promise<void>; 65 77 } 66 78 67 79 ··· 314 326 cid: event.commit.cid, 315 327 name: record.name, 316 328 description: record.description ?? null, 317 - permissions: record.permissions, 318 329 priority: record.priority, 319 330 createdAt: new Date(record.createdAt), 320 331 indexedAt: new Date(), ··· 323 334 cid: event.commit.cid, 324 335 name: record.name, 325 336 description: record.description ?? null, 326 - permissions: record.permissions, 327 337 priority: record.priority, 328 338 indexedAt: new Date(), 329 339 }), 340 + afterUpsert: async (event, record, roleId, tx) => { 341 + // Replace all permissions for this role atomically 342 + await tx 343 + .delete(rolePermissions) 344 + .where(eq(rolePermissions.roleId, roleId)); 345 + 346 + if (record.permissions && record.permissions.length > 0) { 347 + await tx.insert(rolePermissions).values( 348 + record.permissions.map((permission: string) => ({ 349 + roleId, 350 + permission, 351 + })) 352 + ); 353 + } 354 + }, 330 355 }; 331 356 332 357 private membershipConfig: CollectionConfig<Membership.Record> = { ··· 491 516 return; // Skip insert (e.g. foreign key not found) 492 517 } 493 518 494 - await tx.insert(config.table).values(values); 519 + if (config.afterUpsert) { 520 + const [inserted] = await tx 521 + .insert(config.table) 522 + .values(values) 523 + .returning({ id: config.table.id }); 524 + await config.afterUpsert(event, record, inserted.id, tx); 525 + } else { 526 + await tx.insert(config.table).values(values); 527 + } 495 528 }); 496 529 497 530 // Only log success if insert actually happened ··· 532 565 return; // Skip update (e.g. foreign key not found) 533 566 } 534 567 535 - await tx 536 - .update(config.table) 537 - .set(values) 538 - .where( 539 - and( 540 - eq(config.table.did, event.did), 541 - eq(config.table.rkey, event.commit.rkey) 568 + if (config.afterUpsert) { 569 + const [updated] = await tx 570 + .update(config.table) 571 + .set(values) 572 + .where( 573 + and( 574 + eq(config.table.did, event.did), 575 + eq(config.table.rkey, event.commit.rkey) 576 + ) 542 577 ) 543 - ); 578 + .returning({ id: config.table.id }); 579 + await config.afterUpsert(event, record, updated.id, tx); 580 + } else { 581 + await tx 582 + .update(config.table) 583 + .set(values) 584 + .where( 585 + and( 586 + eq(config.table.did, event.did), 587 + eq(config.table.rkey, event.commit.rkey) 588 + ) 589 + ); 590 + } 544 591 }); 545 592 546 593 // Only log success if update actually happened