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 review feedback on theme backfill PR

- Add syncRepoRecords dispatch tests for space.atbb.forum.theme and
space.atbb.forum.themePolicy — proves handleThemeCreate and
handleThemePolicyCreate are actually invoked, catches renames silently
- Add test verifying TypeError propagates when handler method is absent
on Indexer (covers the as-any cast gap)
- Re-throw isProgrammingError in syncRepoRecords outer catch so handler
bugs are not silently logged as pds_error
- Add null guard in themePolicyConfig.toInsertValues / toUpdateValues for
missing defaultLightTheme/defaultDarkTheme refs; returns null to skip
the insert rather than crashing with TypeError on malformed records

+136 -16
+101
apps/appview/src/lib/__tests__/backfill-manager.test.ts
··· 138 138 mockIndexer = { 139 139 handlePostCreate: vi.fn().mockResolvedValue(true), 140 140 handleForumCreate: vi.fn().mockResolvedValue(true), 141 + handleThemeCreate: vi.fn().mockResolvedValue(true), 142 + handleThemePolicyCreate: vi.fn().mockResolvedValue(true), 141 143 } as unknown as Indexer; 142 144 }); 143 145 ··· 286 288 expect(stats.recordsIndexed).toBe(0); 287 289 expect(stats.errors).toBe(1); 288 290 consoleSpy.mockRestore(); 291 + }); 292 + 293 + it("dispatches handleThemeCreate for space.atbb.forum.theme records", async () => { 294 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 295 + (mockAgent.com.atproto.repo.listRecords as any).mockResolvedValueOnce({ 296 + data: { 297 + records: [{ 298 + uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark", 299 + cid: "bafytheme1", 300 + value: { 301 + $type: "space.atbb.forum.theme", 302 + name: "Neobrutal Dark", 303 + colorScheme: "dark", 304 + tokens: { "color-bg": "#1a1a1a" }, 305 + createdAt: "2026-01-01T00:00:00Z", 306 + }, 307 + }], 308 + cursor: undefined, 309 + }, 310 + }); 311 + 312 + manager.setIndexer(mockIndexer); 313 + const stats = await manager.syncRepoRecords( 314 + "did:web:atbb.space", 315 + "space.atbb.forum.theme", 316 + mockAgent 317 + ); 318 + 319 + expect(stats.recordsFound).toBe(1); 320 + expect(stats.recordsIndexed).toBe(1); 321 + expect(stats.errors).toBe(0); 322 + expect(mockIndexer.handleThemeCreate).toHaveBeenCalledTimes(1); 323 + expect(mockIndexer.handleThemeCreate).toHaveBeenCalledWith( 324 + expect.objectContaining({ 325 + did: "did:web:atbb.space", 326 + commit: expect.objectContaining({ 327 + rkey: "neobrutal-dark", 328 + cid: "bafytheme1", 329 + record: expect.objectContaining({ name: "Neobrutal Dark", colorScheme: "dark" }), 330 + }), 331 + }) 332 + ); 333 + }); 334 + 335 + it("dispatches handleThemePolicyCreate for space.atbb.forum.themePolicy records", async () => { 336 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 337 + (mockAgent.com.atproto.repo.listRecords as any).mockResolvedValueOnce({ 338 + data: { 339 + records: [{ 340 + uri: "at://did:web:atbb.space/space.atbb.forum.themePolicy/self", 341 + cid: "bafypolicy1", 342 + value: { 343 + $type: "space.atbb.forum.themePolicy", 344 + availableThemes: [ 345 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 346 + ], 347 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 348 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 349 + allowUserChoice: true, 350 + }, 351 + }], 352 + cursor: undefined, 353 + }, 354 + }); 355 + 356 + manager.setIndexer(mockIndexer); 357 + const stats = await manager.syncRepoRecords( 358 + "did:web:atbb.space", 359 + "space.atbb.forum.themePolicy", 360 + mockAgent 361 + ); 362 + 363 + expect(stats.recordsFound).toBe(1); 364 + expect(stats.recordsIndexed).toBe(1); 365 + expect(stats.errors).toBe(0); 366 + expect(mockIndexer.handleThemePolicyCreate).toHaveBeenCalledTimes(1); 367 + expect(mockIndexer.handleThemePolicyCreate).toHaveBeenCalledWith( 368 + expect.objectContaining({ 369 + did: "did:web:atbb.space", 370 + commit: expect.objectContaining({ 371 + rkey: "self", 372 + cid: "bafypolicy1", 373 + }), 374 + }) 375 + ); 376 + }); 377 + 378 + it("returns error stats when handler method is missing on Indexer (as-any cast gap)", async () => { 379 + // COLLECTION_HANDLER_MAP entry exists but the method is absent on the indexer. 380 + // .bind() on undefined throws TypeError which propagates out of syncRepoRecords 381 + // and fails performBackfill's outer catch rather than being silently swallowed. 382 + const brokenIndexer = {} as unknown as Indexer; 383 + manager.setIndexer(brokenIndexer); 384 + 385 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 386 + // listRecords would never be called — the TypeError fires before the do-while 387 + await expect( 388 + manager.syncRepoRecords("did:web:atbb.space", "space.atbb.forum.theme", mockAgent) 389 + ).rejects.toThrow(TypeError); 289 390 }); 290 391 }); 291 392
+1
apps/appview/src/lib/backfill-manager.ts
··· 150 150 } 151 151 } while (cursor); 152 152 } catch (error) { 153 + if (isProgrammingError(error)) throw error; 153 154 stats.errors++; 154 155 this.logger.error("backfill.pds_error", { 155 156 event: "backfill.pds_error",
+34 -16
apps/appview/src/lib/indexer.ts
··· 390 390 name: "ThemePolicy", 391 391 table: themePolicies, 392 392 deleteStrategy: "hard", 393 - toInsertValues: async (event, record) => ({ 394 - did: event.did, 395 - rkey: event.commit.rkey, 396 - cid: event.commit.cid, 397 - defaultLightThemeUri: record.defaultLightTheme.uri, 398 - defaultDarkThemeUri: record.defaultDarkTheme.uri, 399 - allowUserChoice: record.allowUserChoice, 400 - indexedAt: new Date(), 401 - }), 402 - toUpdateValues: async (event, record) => ({ 403 - cid: event.commit.cid, 404 - defaultLightThemeUri: record.defaultLightTheme.uri, 405 - defaultDarkThemeUri: record.defaultDarkTheme.uri, 406 - allowUserChoice: record.allowUserChoice, 407 - indexedAt: new Date(), 408 - }), 393 + toInsertValues: async (event, record) => { 394 + if (!record.defaultLightTheme?.uri || !record.defaultDarkTheme?.uri) { 395 + this.logger.warn("ThemePolicy record missing required theme refs — skipping", { 396 + did: event.did, 397 + rkey: event.commit.rkey, 398 + }); 399 + return null; 400 + } 401 + return { 402 + did: event.did, 403 + rkey: event.commit.rkey, 404 + cid: event.commit.cid, 405 + defaultLightThemeUri: record.defaultLightTheme.uri, 406 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 407 + allowUserChoice: record.allowUserChoice, 408 + indexedAt: new Date(), 409 + }; 410 + }, 411 + toUpdateValues: async (event, record) => { 412 + if (!record.defaultLightTheme?.uri || !record.defaultDarkTheme?.uri) { 413 + this.logger.warn("ThemePolicy record missing required theme refs — skipping update", { 414 + did: event.did, 415 + rkey: event.commit.rkey, 416 + }); 417 + return null; 418 + } 419 + return { 420 + cid: event.commit.cid, 421 + defaultLightThemeUri: record.defaultLightTheme.uri, 422 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 423 + allowUserChoice: record.allowUserChoice, 424 + indexedAt: new Date(), 425 + }; 426 + }, 409 427 afterUpsert: async (_event, record, policyId, tx) => { 410 428 // Atomically replace all available-theme rows for this policy 411 429 await tx