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.

refactor(appview): create event handler registry to eliminate duplication (#12)

Replace 18 nearly-identical event handler registrations in FirehoseService
with a declarative registry pattern. This eliminates boilerplate and makes
adding new collections easier.

Changes:
- Add EventHandlerRegistry class with fluent interface
- Refactor FirehoseService to use registry for handler setup
- Derive wantedCollections from registered handlers
- Add comprehensive unit tests for registry

Benefits:
- DRY compliance: Single place to configure collection handlers
- Easier to maintain: Clear declaration-based configuration
- Simpler to extend: Adding a collection now requires one .register() call
- Better testability: Registry can be tested independently

The setupEventHandlers() method went from 86 lines of repetitive code
to 12 lines that apply the registry and set up cursor/error handlers.

authored by

Malpercio and committed by
GitHub
1976c1ea 61108c2c

+255 -83
+132
apps/appview/src/lib/__tests__/event-handler-registry.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { EventHandlerRegistry } from "../event-handler-registry.js"; 3 + 4 + describe("EventHandlerRegistry", () => { 5 + it("should register handlers for collections", () => { 6 + const registry = new EventHandlerRegistry(); 7 + 8 + registry.register({ 9 + collection: "space.atbb.post", 10 + onCreate: vi.fn(), 11 + }); 12 + 13 + expect(registry.getCollections()).toEqual(["space.atbb.post"]); 14 + }); 15 + 16 + it("should support fluent interface", () => { 17 + const registry = new EventHandlerRegistry() 18 + .register({ collection: "space.atbb.post", onCreate: vi.fn() }) 19 + .register({ collection: "space.atbb.forum.forum", onCreate: vi.fn() }); 20 + 21 + expect(registry.getCollections()).toEqual(["space.atbb.post", "space.atbb.forum.forum"]); 22 + }); 23 + 24 + it("should apply handlers to Jetstream instance", () => { 25 + const onCreate = vi.fn(); 26 + const onUpdate = vi.fn(); 27 + const onDelete = vi.fn(); 28 + 29 + const mockJetstream = { 30 + onCreate: vi.fn(), 31 + onUpdate: vi.fn(), 32 + onDelete: vi.fn(), 33 + }; 34 + 35 + const registry = new EventHandlerRegistry().register({ 36 + collection: "space.atbb.post", 37 + onCreate, 38 + onUpdate, 39 + onDelete, 40 + }); 41 + 42 + registry.applyTo(mockJetstream as any); 43 + 44 + expect(mockJetstream.onCreate).toHaveBeenCalledWith("space.atbb.post", onCreate); 45 + expect(mockJetstream.onUpdate).toHaveBeenCalledWith("space.atbb.post", onUpdate); 46 + expect(mockJetstream.onDelete).toHaveBeenCalledWith("space.atbb.post", onDelete); 47 + }); 48 + 49 + it("should only register provided handlers", () => { 50 + const onCreate = vi.fn(); 51 + const mockJetstream = { 52 + onCreate: vi.fn(), 53 + onUpdate: vi.fn(), 54 + onDelete: vi.fn(), 55 + }; 56 + 57 + const registry = new EventHandlerRegistry().register({ 58 + collection: "space.atbb.post", 59 + onCreate, // Only onCreate provided 60 + }); 61 + 62 + registry.applyTo(mockJetstream as any); 63 + 64 + expect(mockJetstream.onCreate).toHaveBeenCalledWith("space.atbb.post", onCreate); 65 + expect(mockJetstream.onUpdate).not.toHaveBeenCalled(); 66 + expect(mockJetstream.onDelete).not.toHaveBeenCalled(); 67 + }); 68 + 69 + it("should clear all registrations", () => { 70 + const registry = new EventHandlerRegistry().register({ 71 + collection: "space.atbb.post", 72 + onCreate: vi.fn(), 73 + }); 74 + 75 + expect(registry.getCollections()).toHaveLength(1); 76 + 77 + registry.clear(); 78 + 79 + expect(registry.getCollections()).toHaveLength(0); 80 + }); 81 + 82 + it("should handle multiple collections with partial handlers", () => { 83 + const mockJetstream = { 84 + onCreate: vi.fn(), 85 + onUpdate: vi.fn(), 86 + onDelete: vi.fn(), 87 + }; 88 + 89 + const postCreate = vi.fn(); 90 + const forumUpdate = vi.fn(); 91 + const categoryDelete = vi.fn(); 92 + 93 + const registry = new EventHandlerRegistry() 94 + .register({ 95 + collection: "space.atbb.post", 96 + onCreate: postCreate, 97 + }) 98 + .register({ 99 + collection: "space.atbb.forum.forum", 100 + onUpdate: forumUpdate, 101 + }) 102 + .register({ 103 + collection: "space.atbb.forum.category", 104 + onDelete: categoryDelete, 105 + }); 106 + 107 + registry.applyTo(mockJetstream as any); 108 + 109 + // Verify only the registered handlers were called 110 + expect(mockJetstream.onCreate).toHaveBeenCalledTimes(1); 111 + expect(mockJetstream.onCreate).toHaveBeenCalledWith("space.atbb.post", postCreate); 112 + 113 + expect(mockJetstream.onUpdate).toHaveBeenCalledTimes(1); 114 + expect(mockJetstream.onUpdate).toHaveBeenCalledWith("space.atbb.forum.forum", forumUpdate); 115 + 116 + expect(mockJetstream.onDelete).toHaveBeenCalledTimes(1); 117 + expect(mockJetstream.onDelete).toHaveBeenCalledWith("space.atbb.forum.category", categoryDelete); 118 + }); 119 + 120 + it("should preserve registration order", () => { 121 + const registry = new EventHandlerRegistry() 122 + .register({ collection: "space.atbb.post", onCreate: vi.fn() }) 123 + .register({ collection: "space.atbb.forum.forum", onCreate: vi.fn() }) 124 + .register({ collection: "space.atbb.membership", onCreate: vi.fn() }); 125 + 126 + expect(registry.getCollections()).toEqual([ 127 + "space.atbb.post", 128 + "space.atbb.forum.forum", 129 + "space.atbb.membership", 130 + ]); 131 + }); 132 + });
+68
apps/appview/src/lib/event-handler-registry.ts
··· 1 + import type { Jetstream } from "@skyware/jetstream"; 2 + 3 + /** 4 + * Event operation types supported by Jetstream 5 + */ 6 + export type EventOperation = "create" | "update" | "delete"; 7 + 8 + /** 9 + * Handler function signature for any event operation 10 + */ 11 + export type EventHandler<T extends string = string> = (event: any) => Promise<void>; 12 + 13 + /** 14 + * Configuration for a single collection's event handlers 15 + */ 16 + export interface CollectionHandlers { 17 + collection: string; 18 + onCreate?: EventHandler; 19 + onUpdate?: EventHandler; 20 + onDelete?: EventHandler; 21 + } 22 + 23 + /** 24 + * Registry for managing Jetstream event handler registrations. 25 + * Eliminates boilerplate by automating handler setup for collections. 26 + */ 27 + export class EventHandlerRegistry { 28 + private registrations: CollectionHandlers[] = []; 29 + 30 + /** 31 + * Register handlers for a collection 32 + */ 33 + register(handlers: CollectionHandlers): this { 34 + this.registrations.push(handlers); 35 + return this; // Fluent interface 36 + } 37 + 38 + /** 39 + * Apply all registered handlers to a Jetstream instance 40 + */ 41 + applyTo(jetstream: Jetstream): void { 42 + for (const { collection, onCreate, onUpdate, onDelete } of this.registrations) { 43 + if (onCreate) { 44 + jetstream.onCreate(collection as any, onCreate); 45 + } 46 + if (onUpdate) { 47 + jetstream.onUpdate(collection as any, onUpdate); 48 + } 49 + if (onDelete) { 50 + jetstream.onDelete(collection as any, onDelete); 51 + } 52 + } 53 + } 54 + 55 + /** 56 + * Get all registered collection names 57 + */ 58 + getCollections(): string[] { 59 + return this.registrations.map((r) => r.collection); 60 + } 61 + 62 + /** 63 + * Clear all registrations (useful for testing) 64 + */ 65 + clear(): void { 66 + this.registrations = []; 67 + } 68 + }
+55 -83
apps/appview/src/lib/firehose.ts
··· 4 4 import { CursorManager } from "./cursor-manager.js"; 5 5 import { CircuitBreaker } from "./circuit-breaker.js"; 6 6 import { ReconnectionManager } from "./reconnection-manager.js"; 7 + import { EventHandlerRegistry } from "./event-handler-registry.js"; 7 8 8 9 /** 9 10 * Firehose service that subscribes to AT Proto Jetstream ··· 27 28 private circuitBreaker: CircuitBreaker; 28 29 private reconnectionManager: ReconnectionManager; 29 30 31 + // Event handler registry 32 + private handlerRegistry: EventHandlerRegistry; 33 + 30 34 // Collections we're interested in (full lexicon IDs) 31 - private readonly wantedCollections = [ 32 - "space.atbb.post", 33 - "space.atbb.forum.forum", 34 - "space.atbb.forum.category", 35 - "space.atbb.membership", 36 - "space.atbb.modAction", 37 - "space.atbb.reaction", 38 - ]; 35 + private readonly wantedCollections: string[]; 39 36 40 37 constructor( 41 38 private db: Database, ··· 48 45 this.cursorManager = new CursorManager(db); 49 46 this.circuitBreaker = new CircuitBreaker(100, () => this.stop()); 50 47 this.reconnectionManager = new ReconnectionManager(10, 5000); 48 + 49 + // Build handler registry 50 + this.handlerRegistry = this.createHandlerRegistry(); 51 + this.wantedCollections = this.handlerRegistry.getCollections(); 51 52 52 53 // Initialize with a placeholder - will be recreated with cursor in start() 53 54 this.jetstream = this.createJetstream(); ··· 66 67 } 67 68 68 69 /** 69 - * Set up event handlers for different record operations 70 + * Create and configure the event handler registry 70 71 */ 71 - private setupEventHandlers() { 72 - // Handle record creates 73 - this.jetstream.onCreate("space.atbb.post", (event) => { 74 - this.handlePostCreate(event); 75 - }); 76 - 77 - this.jetstream.onCreate("space.atbb.forum.forum", (event) => { 78 - this.handleForumCreate(event); 79 - }); 80 - 81 - this.jetstream.onCreate("space.atbb.forum.category", (event) => { 82 - this.handleCategoryCreate(event); 83 - }); 84 - 85 - this.jetstream.onCreate("space.atbb.membership", (event) => { 86 - this.handleMembershipCreate(event); 87 - }); 88 - 89 - this.jetstream.onCreate("space.atbb.modAction", (event) => { 90 - this.handleModActionCreate(event); 91 - }); 92 - 93 - this.jetstream.onCreate("space.atbb.reaction", (event) => { 94 - this.handleReactionCreate(event); 95 - }); 96 - 97 - // Handle record updates 98 - this.jetstream.onUpdate("space.atbb.post", (event) => { 99 - this.handlePostUpdate(event); 100 - }); 101 - 102 - this.jetstream.onUpdate("space.atbb.forum.forum", (event) => { 103 - this.handleForumUpdate(event); 104 - }); 105 - 106 - this.jetstream.onUpdate("space.atbb.forum.category", (event) => { 107 - this.handleCategoryUpdate(event); 108 - }); 109 - 110 - this.jetstream.onUpdate("space.atbb.membership", (event) => { 111 - this.handleMembershipUpdate(event); 112 - }); 113 - 114 - this.jetstream.onUpdate("space.atbb.modAction", (event) => { 115 - this.handleModActionUpdate(event); 116 - }); 117 - 118 - this.jetstream.onUpdate("space.atbb.reaction", (event) => { 119 - this.handleReactionUpdate(event); 120 - }); 121 - 122 - // Handle record deletes (tombstones) 123 - this.jetstream.onDelete("space.atbb.post", (event) => { 124 - this.handlePostDelete(event); 125 - }); 126 - 127 - this.jetstream.onDelete("space.atbb.forum.forum", (event) => { 128 - this.handleForumDelete(event); 129 - }); 130 - 131 - this.jetstream.onDelete("space.atbb.forum.category", (event) => { 132 - this.handleCategoryDelete(event); 133 - }); 134 - 135 - this.jetstream.onDelete("space.atbb.membership", (event) => { 136 - this.handleMembershipDelete(event); 137 - }); 72 + private createHandlerRegistry(): EventHandlerRegistry { 73 + return new EventHandlerRegistry() 74 + .register({ 75 + collection: "space.atbb.post", 76 + onCreate: this.handlePostCreate, 77 + onUpdate: this.handlePostUpdate, 78 + onDelete: this.handlePostDelete, 79 + }) 80 + .register({ 81 + collection: "space.atbb.forum.forum", 82 + onCreate: this.handleForumCreate, 83 + onUpdate: this.handleForumUpdate, 84 + onDelete: this.handleForumDelete, 85 + }) 86 + .register({ 87 + collection: "space.atbb.forum.category", 88 + onCreate: this.handleCategoryCreate, 89 + onUpdate: this.handleCategoryUpdate, 90 + onDelete: this.handleCategoryDelete, 91 + }) 92 + .register({ 93 + collection: "space.atbb.membership", 94 + onCreate: this.handleMembershipCreate, 95 + onUpdate: this.handleMembershipUpdate, 96 + onDelete: this.handleMembershipDelete, 97 + }) 98 + .register({ 99 + collection: "space.atbb.modAction", 100 + onCreate: this.handleModActionCreate, 101 + onUpdate: this.handleModActionUpdate, 102 + onDelete: this.handleModActionDelete, 103 + }) 104 + .register({ 105 + collection: "space.atbb.reaction", 106 + onCreate: this.handleReactionCreate, 107 + onUpdate: this.handleReactionUpdate, 108 + onDelete: this.handleReactionDelete, 109 + }); 110 + } 138 111 139 - this.jetstream.onDelete("space.atbb.modAction", (event) => { 140 - this.handleModActionDelete(event); 141 - }); 142 - 143 - this.jetstream.onDelete("space.atbb.reaction", (event) => { 144 - this.handleReactionDelete(event); 145 - }); 112 + /** 113 + * Set up event handlers using the registry 114 + */ 115 + private setupEventHandlers() { 116 + // Apply all handlers from the registry 117 + this.handlerRegistry.applyTo(this.jetstream); 146 118 147 119 // Listen to all commits to track cursor 148 120 this.jetstream.on("commit", async (event) => {