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

refactor: extract per-entity response serializers from route handlers (#23)

* refactor: extract per-entity response serializers from route handlers

Route handlers in topics.ts, categories.ts, and forum.ts manually mapped
DB rows to JSON with repeated serializeBigInt/serializeDate/serializeAuthor
calls. Extract reusable serializer functions to reduce duplication:

- serializePost(post, author) for topic posts and replies
- serializeCategory(cat) for category listings
- serializeForum(forum) for forum metadata
- Add CategoryRow and ForumRow type aliases

Update all route handlers to use the new serializers and add comprehensive
unit tests covering happy paths, null handling, and BigInt serialization.

* fix: remove log spam from serializeBigInt for null values

Null values are expected for optional BigInt fields like parentPostId
(null for topic posts) and forumId (null for orphaned categories).

Logging these creates noise without adding debugging value since the
null return is the correct behavior.

* test commit

* fix: remove broken turbo filter from lefthook pre-commit

The --filter='...[HEAD]' syntax doesn't work during merges and returns
zero packages in scope, causing turbo commands to fail with non-zero
exit codes even when checks pass.

Removing the filter makes turbo run on all packages with staged changes,
which is more reliable for pre-commit hooks.

* test: add integration tests for serialized GET endpoint responses

Addresses PR #23 'Important' feedback - adds comprehensive integration
tests that verify GET /api/forum and GET /api/categories return properly
serialized responses.

Tests verify:
- BigInt id fields serialized to strings
- Date fields serialized to ISO 8601 strings
- Internal fields (rkey, cid) not leaked
- Null optional fields handled gracefully
- Response structure matches serializer output

Also fixes:
- createTestContext() return type (TestContext not AppContext)
- Cleanup order (delete categories before forums for FK constraints)

All 249 tests pass.

* docs: add API response shape documentation to serializers

Addresses PR #23 'Suggestion' feedback - adds comprehensive JSDoc
comments documenting the JSON response shape for each serializer.

Documents:
- Field types and serialization (BigInt → string, Date → ISO 8601)
- Null handling for optional fields
- Response structure for GET /api/forum, /api/categories, /api/topics/:id

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
e62d1c6f b16a0a24

+561 -78
+17 -12
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq } from "drizzle-orm"; 2 2 import { drizzle } from "drizzle-orm/postgres-js"; 3 3 import postgres from "postgres"; 4 - import { forums, posts, users } from "@atbb/db"; 4 + import { forums, posts, users, categories } from "@atbb/db"; 5 5 import * as schema from "@atbb/db"; 6 6 import type { AppConfig } from "../config.js"; 7 7 import type { AppContext } from "../app-context.js"; ··· 14 14 * Create test context with database and sample data. 15 15 * Call cleanup() after tests to remove test data. 16 16 */ 17 - export async function createTestContext(): Promise<AppContext> { 17 + export async function createTestContext(): Promise<TestContext> { 18 18 const config: AppConfig = { 19 19 port: 3000, 20 20 forumDid: "did:plc:test-forum", ··· 30 30 const sql = postgres(config.databaseUrl); 31 31 const db = drizzle(sql, { schema }); 32 32 33 - // Insert test forum 34 - await db.insert(forums).values({ 35 - did: config.forumDid, 36 - rkey: "self", 37 - cid: "bafytest", 38 - name: "Test Forum", 39 - description: "A test forum", 40 - indexedAt: new Date(), 41 - }); 33 + // Insert test forum (idempotent - safe to call multiple times) 34 + await db 35 + .insert(forums) 36 + .values({ 37 + did: config.forumDid, 38 + rkey: "self", 39 + cid: "bafytest", 40 + name: "Test Forum", 41 + description: "A test forum", 42 + indexedAt: new Date(), 43 + }) 44 + .onConflictDoNothing(); 42 45 43 46 // Create stub OAuth dependencies (unused in read-path tests) 44 47 const stubFirehose = { ··· 60 63 oauthSessionStore: stubOAuthSessionStore, 61 64 cookieSessionStore: stubCookieSessionStore, 62 65 cleanup: async () => { 63 - // Clean up test data (order matters due to FKs: posts -> users -> forums) 66 + // Clean up test data (order matters due to FKs: posts -> users -> categories -> forums) 64 67 // Only delete posts/users created by test-specific DIDs 65 68 await db.delete(posts).where(eq(posts.did, "did:plc:test-user")); 66 69 await db.delete(users).where(eq(users.did, "did:plc:test-user")); 70 + // Delete categories before forums (FK constraint) 71 + await db.delete(categories).where(eq(categories.did, config.forumDid)); 67 72 await db.delete(forums).where(eq(forums.did, config.forumDid)); 68 73 // Close postgres connection to prevent leaks 69 74 await sql.end();
+103 -7
apps/appview/src/routes/__tests__/categories.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 2 import { Hono } from "hono"; 3 - import { apiRoutes } from "../index.js"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { createCategoriesRoutes } from "../categories.js"; 5 + import { categories, forums } from "@atbb/db"; 6 + import { eq } from "drizzle-orm"; 4 7 5 - const app = new Hono().route("/api", apiRoutes); 8 + describe("GET /", () => { 9 + let ctx: TestContext; 10 + let app: Hono; 6 11 7 - describe("GET /api/categories", () => { 12 + beforeEach(async () => { 13 + ctx = await createTestContext(); 14 + app = new Hono().route("/", createCategoriesRoutes(ctx)); 15 + }); 16 + 17 + afterEach(async () => { 18 + await ctx.cleanup(); 19 + }); 20 + 8 21 it("returns 200", async () => { 9 - const res = await app.request("/api/categories"); 22 + const res = await app.request("/"); 10 23 expect(res.status).toBe(200); 11 24 }); 12 25 13 26 it("returns an object with a categories array", async () => { 14 - const res = await app.request("/api/categories"); 27 + const res = await app.request("/"); 15 28 const body = await res.json(); 16 29 expect(body).toHaveProperty("categories"); 17 30 expect(Array.isArray(body.categories)).toBe(true); 18 31 }); 32 + 33 + it("serializes each category with correct types", async () => { 34 + // Get the forum ID from the test forum 35 + const [testForum] = await ctx.db 36 + .select() 37 + .from(forums) 38 + .where(eq(forums.did, ctx.config.forumDid)) 39 + .limit(1); 40 + 41 + // Insert a test category (idempotent) 42 + await ctx.db 43 + .insert(categories) 44 + .values({ 45 + did: ctx.config.forumDid, 46 + rkey: "test-cat", 47 + cid: "bafycat", 48 + name: "Test Category", 49 + description: "A test category", 50 + slug: "test", 51 + sortOrder: 1, 52 + forumId: testForum.id, 53 + createdAt: new Date(), 54 + indexedAt: new Date(), 55 + }) 56 + .onConflictDoNothing(); 57 + 58 + const res = await app.request("/"); 59 + const body = await res.json(); 60 + 61 + expect(body.categories.length).toBeGreaterThan(0); 62 + const category = body.categories[0]; 63 + 64 + // Verify BigInt fields are stringified 65 + expect(typeof category.id).toBe("string"); 66 + expect(category.id).toMatch(/^\d+$/); 67 + 68 + if (category.forumId !== null) { 69 + expect(typeof category.forumId).toBe("string"); 70 + expect(category.forumId).toMatch(/^\d+$/); 71 + } 72 + 73 + // Verify date fields are ISO strings 74 + expect(typeof category.createdAt).toBe("string"); 75 + expect(category.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); 76 + expect(typeof category.indexedAt).toBe("string"); 77 + 78 + // Verify structure 79 + expect(category).toHaveProperty("name"); 80 + expect(category).toHaveProperty("description"); 81 + expect(category).toHaveProperty("slug"); 82 + expect(category).toHaveProperty("sortOrder"); 83 + }); 84 + 85 + it("does not leak internal fields (rkey, cid)", async () => { 86 + const res = await app.request("/"); 87 + const body = await res.json(); 88 + 89 + if (body.categories.length > 0) { 90 + const category = body.categories[0]; 91 + expect(category).not.toHaveProperty("rkey"); 92 + expect(category).not.toHaveProperty("cid"); 93 + } 94 + }); 95 + 96 + it("handles null optional fields gracefully", async () => { 97 + const res = await app.request("/"); 98 + const body = await res.json(); 99 + 100 + if (body.categories.length > 0) { 101 + const category = body.categories[0]; 102 + // Verify nullable fields are either null or the correct type 103 + expect( 104 + category.description === null || typeof category.description === "string" 105 + ).toBe(true); 106 + expect(category.slug === null || typeof category.slug === "string").toBe(true); 107 + expect( 108 + category.sortOrder === null || typeof category.sortOrder === "number" 109 + ).toBe(true); 110 + expect(category.forumId === null || typeof category.forumId === "string").toBe( 111 + true 112 + ); 113 + } 114 + }); 19 115 }); 20 116 21 - // Note: GET /api/categories/:id/topics endpoint removed 117 + // Note: GET //:id/topics endpoint removed 22 118 // Reason: posts table has no categoryUri field for filtering 23 119 // Tests removed - endpoint will be re-added when schema supports category-to-post association
+55 -6
apps/appview/src/routes/__tests__/forum.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 2 import { Hono } from "hono"; 3 - import { apiRoutes } from "../index.js"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { createForumRoutes } from "../forum.js"; 4 5 5 - const app = new Hono().route("/api", apiRoutes); 6 + describe("GET /", () => { 7 + let ctx: TestContext; 8 + let app: Hono; 9 + 10 + beforeEach(async () => { 11 + ctx = await createTestContext(); 12 + app = new Hono().route("/", createForumRoutes(ctx)); 13 + }); 14 + 15 + afterEach(async () => { 16 + await ctx.cleanup(); 17 + }); 6 18 7 - describe("GET /api/forum", () => { 8 19 it("returns 200", async () => { 9 - const res = await app.request("/api/forum"); 20 + const res = await app.request("/"); 10 21 expect(res.status).toBe(200); 11 22 }); 12 23 13 24 it("returns forum metadata with expected shape", async () => { 14 - const res = await app.request("/api/forum"); 25 + const res = await app.request("/"); 15 26 const body = await res.json(); 16 27 expect(body).toHaveProperty("name"); 17 28 expect(body).toHaveProperty("description"); ··· 19 30 expect(typeof body.name).toBe("string"); 20 31 expect(typeof body.description).toBe("string"); 21 32 expect(typeof body.did).toBe("string"); 33 + }); 34 + 35 + it("serializes BigInt id to string", async () => { 36 + const res = await app.request("/"); 37 + const body = await res.json(); 38 + 39 + expect(body).toHaveProperty("id"); 40 + expect(typeof body.id).toBe("string"); 41 + // Verify it's a valid stringified integer 42 + expect(body.id).toMatch(/^\d+$/); 43 + }); 44 + 45 + it("serializes indexedAt as ISO date string", async () => { 46 + const res = await app.request("/"); 47 + const body = await res.json(); 48 + 49 + expect(body).toHaveProperty("indexedAt"); 50 + expect(typeof body.indexedAt).toBe("string"); 51 + // Verify it's a valid ISO 8601 timestamp 52 + expect(body.indexedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); 53 + // Verify it's a valid date 54 + expect(new Date(body.indexedAt).toISOString()).toBe(body.indexedAt); 55 + }); 56 + 57 + it("does not leak internal fields (rkey, cid)", async () => { 58 + const res = await app.request("/"); 59 + const body = await res.json(); 60 + 61 + expect(body).not.toHaveProperty("rkey"); 62 + expect(body).not.toHaveProperty("cid"); 63 + }); 64 + 65 + it("handles null description gracefully", async () => { 66 + const res = await app.request("/"); 67 + const body = await res.json(); 68 + 69 + // Description can be null, verify it's either a string or null 70 + expect(body.description === null || typeof body.description === "string").toBe(true); 22 71 }); 23 72 });
+268 -1
apps/appview/src/routes/__tests__/helpers.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 - import { validatePostText, getForumByUri, getPostsByIds, validateReplyParent, isProgrammingError, isNetworkError } from "../helpers.js"; 2 + import { 3 + validatePostText, 4 + getForumByUri, 5 + getPostsByIds, 6 + validateReplyParent, 7 + isProgrammingError, 8 + isNetworkError, 9 + serializePost, 10 + serializeCategory, 11 + serializeForum, 12 + type PostRow, 13 + type UserRow, 14 + type CategoryRow, 15 + type ForumRow, 16 + } from "../helpers.js"; 3 17 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 18 import { posts, users } from "@atbb/db"; 5 19 import { eq } from "drizzle-orm"; ··· 237 251 238 252 expect(result.valid).toBe(false); 239 253 expect(result.error).toContain("does not belong to this thread"); 254 + }); 255 + }); 256 + 257 + describe("serializePost", () => { 258 + const baseDate = new Date("2025-01-15T12:00:00.000Z"); 259 + 260 + const makeTopicPost = (overrides?: Partial<PostRow>): PostRow => ({ 261 + id: 1n, 262 + did: "did:plc:topic-author", 263 + rkey: "3lbk7topic", 264 + cid: "bafytopic", 265 + text: "Hello, forum!", 266 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 267 + rootPostId: null, 268 + parentPostId: null, 269 + rootUri: null, 270 + parentUri: null, 271 + createdAt: baseDate, 272 + indexedAt: baseDate, 273 + deleted: false, 274 + ...overrides, 275 + }); 276 + 277 + const makeReplyPost = (overrides?: Partial<PostRow>): PostRow => ({ 278 + id: 2n, 279 + did: "did:plc:reply-author", 280 + rkey: "3lbk8reply", 281 + cid: "bafyreply", 282 + text: "Great topic!", 283 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 284 + rootPostId: 1n, 285 + parentPostId: 1n, 286 + rootUri: "at://did:plc:topic-author/space.atbb.post/3lbk7topic", 287 + parentUri: "at://did:plc:topic-author/space.atbb.post/3lbk7topic", 288 + createdAt: baseDate, 289 + indexedAt: baseDate, 290 + deleted: false, 291 + ...overrides, 292 + }); 293 + 294 + const makeAuthor = (overrides?: Partial<UserRow>): UserRow => ({ 295 + did: "did:plc:topic-author", 296 + handle: "testuser.test", 297 + indexedAt: baseDate, 298 + ...overrides, 299 + }); 300 + 301 + it("serializes a topic post with author", () => { 302 + const post = makeTopicPost(); 303 + const author = makeAuthor(); 304 + 305 + const result = serializePost(post, author); 306 + 307 + expect(result).toEqual({ 308 + id: "1", 309 + did: "did:plc:topic-author", 310 + rkey: "3lbk7topic", 311 + text: "Hello, forum!", 312 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 313 + parentPostId: null, 314 + createdAt: "2025-01-15T12:00:00.000Z", 315 + author: { 316 + did: "did:plc:topic-author", 317 + handle: "testuser.test", 318 + }, 319 + }); 320 + }); 321 + 322 + it("serializes a reply post with parentPostId", () => { 323 + const post = makeReplyPost(); 324 + const author = makeAuthor({ did: "did:plc:reply-author", handle: "replier.test" }); 325 + 326 + const result = serializePost(post, author); 327 + 328 + expect(result).toEqual({ 329 + id: "2", 330 + did: "did:plc:reply-author", 331 + rkey: "3lbk8reply", 332 + text: "Great topic!", 333 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 334 + parentPostId: "1", 335 + createdAt: "2025-01-15T12:00:00.000Z", 336 + author: { 337 + did: "did:plc:reply-author", 338 + handle: "replier.test", 339 + }, 340 + }); 341 + }); 342 + 343 + it("returns null author when author is null", () => { 344 + const post = makeTopicPost(); 345 + 346 + const result = serializePost(post, null); 347 + 348 + expect(result.author).toBeNull(); 349 + }); 350 + 351 + it("serializes BigInt id to string", () => { 352 + const post = makeTopicPost({ id: 9007199254740993n }); 353 + const result = serializePost(post, null); 354 + 355 + expect(result.id).toBe("9007199254740993"); 356 + expect(typeof result.id).toBe("string"); 357 + }); 358 + 359 + it("serializes createdAt as ISO string", () => { 360 + const date = new Date("2025-06-01T08:30:00.000Z"); 361 + const post = makeTopicPost({ createdAt: date }); 362 + 363 + const result = serializePost(post, null); 364 + 365 + expect(result.createdAt).toBe("2025-06-01T08:30:00.000Z"); 366 + }); 367 + 368 + it("returns null forumUri when post has no forumUri", () => { 369 + const post = makeTopicPost({ forumUri: null }); 370 + 371 + const result = serializePost(post, null); 372 + 373 + expect(result.forumUri).toBeNull(); 374 + }); 375 + }); 376 + 377 + describe("serializeCategory", () => { 378 + const baseDate = new Date("2025-01-15T12:00:00.000Z"); 379 + 380 + const makeCategory = (overrides?: Partial<CategoryRow>): CategoryRow => ({ 381 + id: 1n, 382 + did: "did:plc:forum", 383 + rkey: "3lbk9cat", 384 + cid: "bafycat", 385 + name: "General Discussion", 386 + description: "A place for general conversation", 387 + slug: "general", 388 + sortOrder: 1, 389 + forumId: 10n, 390 + createdAt: baseDate, 391 + indexedAt: baseDate, 392 + ...overrides, 393 + }); 394 + 395 + it("serializes a category with all fields", () => { 396 + const cat = makeCategory(); 397 + 398 + const result = serializeCategory(cat); 399 + 400 + expect(result).toEqual({ 401 + id: "1", 402 + did: "did:plc:forum", 403 + name: "General Discussion", 404 + description: "A place for general conversation", 405 + slug: "general", 406 + sortOrder: 1, 407 + forumId: "10", 408 + createdAt: "2025-01-15T12:00:00.000Z", 409 + indexedAt: "2025-01-15T12:00:00.000Z", 410 + }); 411 + }); 412 + 413 + it("handles null description", () => { 414 + const cat = makeCategory({ description: null }); 415 + 416 + const result = serializeCategory(cat); 417 + 418 + expect(result.description).toBeNull(); 419 + }); 420 + 421 + it("handles null slug", () => { 422 + const cat = makeCategory({ slug: null }); 423 + 424 + const result = serializeCategory(cat); 425 + 426 + expect(result.slug).toBeNull(); 427 + }); 428 + 429 + it("handles null sortOrder", () => { 430 + const cat = makeCategory({ sortOrder: null }); 431 + 432 + const result = serializeCategory(cat); 433 + 434 + expect(result.sortOrder).toBeNull(); 435 + }); 436 + 437 + it("handles null forumId", () => { 438 + const cat = makeCategory({ forumId: null }); 439 + 440 + const result = serializeCategory(cat); 441 + 442 + expect(result.forumId).toBeNull(); 443 + }); 444 + 445 + it("serializes BigInt id to string", () => { 446 + const cat = makeCategory({ id: 9007199254740993n }); 447 + 448 + const result = serializeCategory(cat); 449 + 450 + expect(result.id).toBe("9007199254740993"); 451 + expect(typeof result.id).toBe("string"); 452 + }); 453 + }); 454 + 455 + describe("serializeForum", () => { 456 + const baseDate = new Date("2025-01-15T12:00:00.000Z"); 457 + 458 + const makeForum = (overrides?: Partial<ForumRow>): ForumRow => ({ 459 + id: 1n, 460 + did: "did:plc:forum", 461 + rkey: "self", 462 + cid: "bafyforum", 463 + name: "Test Forum", 464 + description: "A test forum for unit tests", 465 + indexedAt: baseDate, 466 + ...overrides, 467 + }); 468 + 469 + it("serializes a forum with all fields", () => { 470 + const forum = makeForum(); 471 + 472 + const result = serializeForum(forum); 473 + 474 + expect(result).toEqual({ 475 + id: "1", 476 + did: "did:plc:forum", 477 + name: "Test Forum", 478 + description: "A test forum for unit tests", 479 + indexedAt: "2025-01-15T12:00:00.000Z", 480 + }); 481 + }); 482 + 483 + it("handles null description", () => { 484 + const forum = makeForum({ description: null }); 485 + 486 + const result = serializeForum(forum); 487 + 488 + expect(result.description).toBeNull(); 489 + }); 490 + 491 + it("serializes BigInt id to string", () => { 492 + const forum = makeForum({ id: 9007199254740993n }); 493 + 494 + const result = serializeForum(forum); 495 + 496 + expect(result.id).toBe("9007199254740993"); 497 + expect(typeof result.id).toBe("string"); 498 + }); 499 + 500 + it("does not include rkey or cid in output", () => { 501 + const forum = makeForum(); 502 + 503 + const result = serializeForum(forum); 504 + 505 + expect(result).not.toHaveProperty("rkey"); 506 + expect(result).not.toHaveProperty("cid"); 240 507 }); 241 508 }); 242 509
+2 -12
apps/appview/src/routes/categories.ts
··· 1 1 import { Hono } from "hono"; 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import { categories } from "@atbb/db"; 4 - import { serializeBigInt, serializeDate } from "./helpers.js"; 4 + import { serializeCategory } from "./helpers.js"; 5 5 6 6 /** 7 7 * Factory function that creates category routes with access to app context. ··· 22 22 .limit(1000); // Defensive limit 23 23 24 24 return c.json({ 25 - categories: allCategories.map((cat) => ({ 26 - id: serializeBigInt(cat.id), 27 - did: cat.did, 28 - name: cat.name, 29 - description: cat.description, 30 - slug: cat.slug, 31 - sortOrder: cat.sortOrder, 32 - forumId: serializeBigInt(cat.forumId), 33 - createdAt: serializeDate(cat.createdAt), 34 - indexedAt: serializeDate(cat.indexedAt), 35 - })), 25 + categories: allCategories.map(serializeCategory), 36 26 }); 37 27 } catch (error) { 38 28 console.error("Failed to query categories", {
+2 -8
apps/appview/src/routes/forum.ts
··· 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import { forums } from "@atbb/db"; 4 4 import { eq } from "drizzle-orm"; 5 - import { serializeBigInt, serializeDate } from "./helpers.js"; 5 + import { serializeForum } from "./helpers.js"; 6 6 7 7 /** 8 8 * Factory function that creates forum routes with access to app context. ··· 21 21 return c.json({ error: "Forum not found" }, 404); 22 22 } 23 23 24 - return c.json({ 25 - id: serializeBigInt(forum.id), 26 - did: forum.did, 27 - name: forum.name, 28 - description: forum.description, 29 - indexedAt: serializeDate(forum.indexedAt), 30 - }); 24 + return c.json(serializeForum(forum)); 31 25 } catch (error) { 32 26 console.error("Failed to query forum metadata", { 33 27 operation: "GET /api/forum",
+104 -5
apps/appview/src/routes/helpers.ts
··· 1 - import { users, forums, posts } from "@atbb/db"; 1 + import { users, forums, posts, categories } from "@atbb/db"; 2 2 import type { Database } from "@atbb/db"; 3 3 import { eq, and, inArray } from "drizzle-orm"; 4 4 import { UnicodeString } from "@atproto/api"; ··· 44 44 */ 45 45 export function serializeBigInt(value: bigint | null | undefined): string | null { 46 46 if (value === null || value === undefined) { 47 - console.warn("serializeBigInt received null/undefined value", { 48 - operation: "serializeBigInt", 49 - stack: new Error().stack, 50 - }); 51 47 return null; 52 48 } 53 49 return value.toString(); ··· 160 156 export type PostRow = typeof posts.$inferSelect; 161 157 162 158 /** 159 + * Type helper for category rows from database queries 160 + */ 161 + export type CategoryRow = typeof categories.$inferSelect; 162 + 163 + /** 164 + * Type helper for forum rows from database queries 165 + */ 166 + export type ForumRow = typeof forums.$inferSelect; 167 + 168 + /** 163 169 * Look up multiple posts by ID in a single query. 164 170 * Excludes deleted posts. 165 171 * Returns a Map for O(1) lookup. ··· 218 224 error: "Parent post does not belong to this thread", 219 225 }; 220 226 } 227 + 228 + /** 229 + * Serialize a post (topic or reply) and its author for API responses. 230 + * Produces the JSON shape used in GET /api/topics/:id for both the 231 + * topic post and each reply. 232 + * 233 + * For topic posts (thread starters): forumUri is set, parentPostId is null. 234 + * For replies: parentPostId is set, forumUri may also be set. 235 + * 236 + * @returns Response shape: 237 + * ```json 238 + * { 239 + * "id": "1234", // BigInt → string 240 + * "did": "did:plc:...", 241 + * "rkey": "3lbk7...", 242 + * "text": "Post content", 243 + * "forumUri": "at://..." | null, 244 + * "parentPostId": "123" | null, // BigInt → string 245 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 246 + * "author": { "did": "did:plc:...", "handle": "user.test" } | null 247 + * } 248 + * ``` 249 + */ 250 + export function serializePost(post: PostRow, author: UserRow | null) { 251 + return { 252 + id: serializeBigInt(post.id), 253 + did: post.did, 254 + rkey: post.rkey, 255 + text: post.text, 256 + forumUri: post.forumUri ?? null, 257 + parentPostId: serializeBigInt(post.parentPostId), 258 + createdAt: serializeDate(post.createdAt), 259 + author: serializeAuthor(author), 260 + }; 261 + } 262 + 263 + /** 264 + * Serialize a category row for API responses. 265 + * Produces the JSON shape used in GET /api/categories. 266 + * 267 + * @returns Response shape: 268 + * ```json 269 + * { 270 + * "id": "1234", // BigInt → string 271 + * "did": "did:plc:...", 272 + * "name": "General Discussion", 273 + * "description": "A place for..." | null, 274 + * "slug": "general" | null, 275 + * "sortOrder": 1 | null, 276 + * "forumId": "10" | null, // BigInt → string 277 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 278 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 279 + * } 280 + * ``` 281 + */ 282 + export function serializeCategory(cat: CategoryRow) { 283 + return { 284 + id: serializeBigInt(cat.id), 285 + did: cat.did, 286 + name: cat.name, 287 + description: cat.description, 288 + slug: cat.slug, 289 + sortOrder: cat.sortOrder, 290 + forumId: serializeBigInt(cat.forumId), 291 + createdAt: serializeDate(cat.createdAt), 292 + indexedAt: serializeDate(cat.indexedAt), 293 + }; 294 + } 295 + 296 + /** 297 + * Serialize a forum row for API responses. 298 + * Produces the JSON shape used in GET /api/forum. 299 + * 300 + * @returns Response shape: 301 + * ```json 302 + * { 303 + * "id": "1234", // BigInt → string 304 + * "did": "did:plc:...", 305 + * "name": "My Forum", 306 + * "description": "Forum description" | null, 307 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 308 + * } 309 + * ``` 310 + */ 311 + export function serializeForum(forum: ForumRow) { 312 + return { 313 + id: serializeBigInt(forum.id), 314 + did: forum.did, 315 + name: forum.name, 316 + description: forum.description, 317 + indexedAt: serializeDate(forum.indexedAt), 318 + }; 319 + }
+5 -21
apps/appview/src/routes/topics.ts
··· 6 6 import { requireAuth } from "../middleware/auth.js"; 7 7 import { 8 8 parseBigIntParam, 9 - serializeAuthor, 10 - serializeBigInt, 11 - serializeDate, 9 + serializePost, 12 10 validatePostText, 13 11 getForumByUri, 14 12 isProgrammingError, ··· 61 59 62 60 return c.json({ 63 61 topicId: id, 64 - post: { 65 - id: serializeBigInt(topicPost.id), 66 - did: topicPost.did, 67 - rkey: topicPost.rkey, 68 - text: topicPost.text, 69 - forumUri: topicPost.forumUri, 70 - createdAt: serializeDate(topicPost.createdAt), 71 - author: serializeAuthor(topicAuthor), 72 - }, 73 - replies: replyResults.map(({ post, author }) => ({ 74 - id: serializeBigInt(post.id), 75 - did: post.did, 76 - rkey: post.rkey, 77 - text: post.text, 78 - parentPostId: serializeBigInt(post.parentPostId), 79 - createdAt: serializeDate(post.createdAt), 80 - author: serializeAuthor(author), 81 - })), 62 + post: serializePost(topicPost, topicAuthor), 63 + replies: replyResults.map(({ post, author }) => 64 + serializePost(post, author) 65 + ), 82 66 }); 83 67 } catch (error) { 84 68 console.error("Failed to query topic", {
+1 -1
apps/appview/tsconfig.json
··· 6 6 "skipLibCheck": true 7 7 }, 8 8 "include": ["src/**/*.ts"], 9 - "exclude": ["../lexicon/dist"] 9 + "exclude": ["../../packages/lexicon/dist"] 10 10 }
+2 -2
lefthook.yml
··· 6 6 run: pnpm exec oxlint {staged_files} 7 7 typecheck: 8 8 glob: "**/*.{ts,tsx}" 9 - run: pnpm turbo lint --filter='...[HEAD]' 9 + run: pnpm turbo lint 10 10 test: 11 11 glob: "**/*.{ts,tsx,js,jsx}" 12 - run: pnpm turbo test --filter='...[HEAD]' 12 + run: pnpm turbo test
+2 -3
packages/lexicon/tsconfig.json
··· 1 1 { 2 2 "extends": "../../tsconfig.base.json", 3 3 "compilerOptions": { 4 - "outDir": "./dist/types", 5 - "rootDir": "./dist/types" 4 + "outDir": "./dist" 6 5 }, 7 - "include": ["dist/types/**/*.ts"] 6 + "include": ["scripts/**/*.ts", "__tests__/**/*.ts"] 8 7 }