Barazo AppView backend barazo.forum

feat(schema): align API with lexicon v0.3.0 (#164)

- Content field now uses union object { $type, value } in PDS records
- Rename topic createdAt → publishedAt (DB column + all references)
- Remove contentFormat from topics and replies (DB + validation + routes)
- Add optional site field to topics
- Backward-compatible content extraction in firehose indexers
- Migration 0011 handles column rename, drop, and addition
- Update all test fixtures for new record format

Closes singi-labs/barazo-workspace#54

authored by

Guido X Jansen and committed by
GitHub
dcc9c768 0a2621a0

+120 -176
+9
drizzle/0011_lexicon-v030-compat.sql
··· 1 + -- Align with lexicon v0.3.0: content union, publishedAt, site field 2 + -- Topics: rename created_at → published_at, drop content_format, add site 3 + ALTER TABLE "topics" RENAME COLUMN "created_at" TO "published_at";--> statement-breakpoint 4 + ALTER TABLE "topics" DROP COLUMN IF EXISTS "content_format";--> statement-breakpoint 5 + ALTER TABLE "topics" ADD COLUMN "site" text;--> statement-breakpoint 6 + DROP INDEX IF EXISTS "topics_created_at_idx";--> statement-breakpoint 7 + CREATE INDEX "topics_published_at_idx" ON "topics" USING btree ("published_at");--> statement-breakpoint 8 + -- Replies: drop content_format only (replies keep created_at) 9 + ALTER TABLE "replies" DROP COLUMN IF EXISTS "content_format";
+7
drizzle/meta/_journal.json
··· 78 78 "when": 1772759004891, 79 79 "tag": "0010_mature_madrox", 80 80 "breakpoints": true 81 + }, 82 + { 83 + "idx": 11, 84 + "version": "7", 85 + "when": 1772870400000, 86 + "tag": "0011_lexicon-v030-compat", 87 + "breakpoints": true 81 88 } 82 89 ] 83 90 }
-1
src/db/schema/replies.ts
··· 18 18 rkey: text('rkey').notNull(), 19 19 authorDid: text('author_did').notNull(), 20 20 content: text('content').notNull(), 21 - contentFormat: text('content_format'), 22 21 rootUri: text('root_uri').notNull(), 23 22 rootCid: text('root_cid').notNull(), 24 23 parentUri: text('parent_uri').notNull(),
+3 -3
src/db/schema/topics.ts
··· 19 19 authorDid: text('author_did').notNull(), 20 20 title: text('title').notNull(), 21 21 content: text('content').notNull(), 22 - contentFormat: text('content_format'), 23 22 category: text('category').notNull(), 23 + site: text('site'), 24 24 tags: jsonb('tags').$type<string[]>(), 25 25 communityDid: text('community_did').notNull(), 26 26 cid: text('cid').notNull(), ··· 29 29 reactionCount: integer('reaction_count').notNull().default(0), 30 30 voteCount: integer('vote_count').notNull().default(0), 31 31 lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).notNull().defaultNow(), 32 - createdAt: timestamp('created_at', { withTimezone: true }).notNull(), 32 + publishedAt: timestamp('published_at', { withTimezone: true }).notNull(), 33 33 indexedAt: timestamp('indexed_at', { withTimezone: true }).notNull().defaultNow(), 34 34 isLocked: boolean('is_locked').notNull().default(false), 35 35 isPinned: boolean('is_pinned').notNull().default(false), ··· 56 56 (table) => [ 57 57 index('topics_author_did_idx').on(table.authorDid), 58 58 index('topics_category_idx').on(table.category), 59 - index('topics_created_at_idx').on(table.createdAt), 59 + index('topics_published_at_idx').on(table.publishedAt), 60 60 index('topics_last_activity_at_idx').on(table.lastActivityAt), 61 61 index('topics_community_did_idx').on(table.communityDid), 62 62 index('topics_moderation_status_idx').on(table.moderationStatus),
+4 -4
src/firehose/indexers/reply.ts
··· 47 47 const { root, parent } = record 48 48 const clientCreatedAt = new Date(record.createdAt) 49 49 const createdAt = live ? clampCreatedAt(clientCreatedAt) : clientCreatedAt 50 + const contentValue = typeof record.content === 'string' ? record.content : record.content.value 50 51 51 52 await this.db.transaction(async (tx) => { 52 53 // Compute depth: direct reply to topic = 1, nested = parent_depth + 1 ··· 65 66 uri, 66 67 rkey, 67 68 authorDid: did, 68 - content: sanitizeHtml(record.content.value), 69 - contentFormat: 'markdown', 69 + content: sanitizeHtml(contentValue), 70 70 rootUri: root.uri, 71 71 rootCid: root.cid, 72 72 parentUri: parent.uri, ··· 95 95 96 96 async handleUpdate(params: UpdateParams): Promise<void> { 97 97 const { uri, cid, record } = params 98 + const contentValue = typeof record.content === 'string' ? record.content : record.content.value 98 99 99 100 await this.db 100 101 .update(replies) 101 102 .set({ 102 - content: sanitizeHtml(record.content.value), 103 - contentFormat: 'markdown', 103 + content: sanitizeHtml(contentValue), 104 104 cid, 105 105 labels: record.labels ?? null, 106 106 indexedAt: new Date(),
+12 -10
src/firehose/indexers/topic.ts
··· 31 31 32 32 async handleCreate(params: CreateParams): Promise<void> { 33 33 const { uri, rkey, did, cid, record, live, trustStatus } = params 34 - const clientCreatedAt = new Date(record.publishedAt) 35 - const createdAt = live ? clampCreatedAt(clientCreatedAt) : clientCreatedAt 34 + const clientPublishedAt = new Date(record.publishedAt) 35 + const publishedAt = live ? clampCreatedAt(clientPublishedAt) : clientPublishedAt 36 + const contentValue = typeof record.content === 'string' ? record.content : record.content.value 36 37 37 38 await this.db 38 39 .insert(topics) ··· 41 42 rkey, 42 43 authorDid: did, 43 44 title: sanitizeText(record.title), 44 - content: sanitizeHtml(record.content.value), 45 - contentFormat: 'markdown', 45 + content: sanitizeHtml(contentValue), 46 46 category: record.category, 47 + site: record.site ?? null, 47 48 tags: record.tags ?? null, 48 49 communityDid: record.community, 49 50 cid, 50 51 labels: record.labels ?? null, 51 - createdAt, 52 - lastActivityAt: createdAt, 52 + publishedAt, 53 + lastActivityAt: publishedAt, 53 54 trustStatus, 54 55 }) 55 56 .onConflictDoUpdate({ 56 57 target: topics.uri, 57 58 set: { 58 59 title: sanitizeText(record.title), 59 - content: sanitizeHtml(record.content.value), 60 - contentFormat: 'markdown', 60 + content: sanitizeHtml(contentValue), 61 61 category: record.category, 62 + site: record.site ?? null, 62 63 tags: record.tags ?? null, 63 64 cid, 64 65 labels: record.labels ?? null, ··· 71 72 72 73 async handleUpdate(params: CreateParams): Promise<void> { 73 74 const { uri, cid, record } = params 75 + const contentValue = typeof record.content === 'string' ? record.content : record.content.value 74 76 75 77 await this.db 76 78 .update(topics) 77 79 .set({ 78 80 title: sanitizeText(record.title), 79 - content: sanitizeHtml(record.content.value), 80 - contentFormat: 'markdown', 81 + content: sanitizeHtml(contentValue), 81 82 category: record.category, 83 + site: record.site ?? null, 82 84 tags: record.tags ?? null, 83 85 cid, 84 86 labels: record.labels ?? null,
+2 -5
src/routes/replies.ts
··· 60 60 }, 61 61 }, 62 62 content: { type: 'string' as const }, 63 - contentFormat: { type: ['string', 'null'] as const }, 64 63 rootUri: { type: 'string' as const }, 65 64 rootCid: { type: 'string' as const }, 66 65 parentUri: { type: 'string' as const }, ··· 103 102 function serializeReply(row: typeof replies.$inferSelect) { 104 103 const depth = row.depth 105 104 106 - const isDeleted = row.isAuthorDeleted || row.isModDeleted 107 105 const placeholderContent = row.isModDeleted 108 106 ? '[Removed by moderator]' 109 107 : row.isAuthorDeleted ··· 115 113 rkey: row.rkey, 116 114 authorDid: row.authorDid, 117 115 content: placeholderContent, 118 - contentFormat: isDeleted ? null : (row.contentFormat ?? null), 119 116 rootUri: row.rootUri, 120 117 rootCid: row.rootCid, 121 118 parentUri: row.parentUri, ··· 335 332 336 333 // Build AT Protocol record 337 334 const record: Record<string, unknown> = { 338 - content, 335 + content: { $type: 'forum.barazo.richtext#markdown', value: content }, 339 336 community: topic.communityDid, 340 337 root: { uri: topic.uri, cid: topic.cid }, 341 338 parent: { uri: parentRefUri, cid: parentRefCid }, ··· 842 839 843 840 // Build updated record for PDS 844 841 const updatedRecord: Record<string, unknown> = { 845 - content, 842 + content: { $type: 'forum.barazo.richtext#markdown', value: content }, 846 843 community: replyRow.communityDid, 847 844 root: { uri: replyRow.rootUri, cid: replyRow.rootCid }, 848 845 parent: { uri: replyRow.parentUri, cid: replyRow.parentCid },
+16 -12
src/routes/topics.ts
··· 62 62 }, 63 63 title: { type: 'string' as const }, 64 64 content: { type: 'string' as const }, 65 - contentFormat: { type: ['string', 'null'] as const }, 66 65 category: { type: 'string' as const }, 66 + site: { type: ['string', 'null'] as const }, 67 67 tags: { type: ['array', 'null'] as const, items: { type: 'string' as const } }, 68 68 labels: { 69 69 type: ['object', 'null'] as const, ··· 90 90 pinnedAt: { type: ['string', 'null'] as const, format: 'date-time' as const }, 91 91 categoryMaturityRating: { type: 'string' as const, enum: ['safe', 'mature', 'adult'] }, 92 92 lastActivityAt: { type: 'string' as const, format: 'date-time' as const }, 93 - createdAt: { type: 'string' as const, format: 'date-time' as const }, 93 + publishedAt: { type: 'string' as const, format: 'date-time' as const }, 94 94 indexedAt: { type: 'string' as const, format: 'date-time' as const }, 95 95 }, 96 96 } ··· 117 117 authorDid: row.authorDid, 118 118 title: placeholderTitle, 119 119 content: isDeleted ? '' : row.content, 120 - contentFormat: isDeleted ? null : (row.contentFormat ?? null), 121 120 category: row.category, 121 + site: row.site ?? null, 122 122 tags: row.tags ?? null, 123 123 labels: row.labels ?? null, 124 124 communityDid: row.communityDid, ··· 133 133 pinnedAt: row.pinnedAt?.toISOString() ?? null, 134 134 categoryMaturityRating, 135 135 lastActivityAt: row.lastActivityAt.toISOString(), 136 - createdAt: row.createdAt.toISOString(), 136 + publishedAt: row.publishedAt.toISOString(), 137 137 indexedAt: row.indexedAt.toISOString(), 138 138 } 139 139 } ··· 244 244 category: { type: 'string' }, 245 245 authorHandle: { type: 'string' }, 246 246 moderationStatus: { type: 'string', enum: ['approved', 'held', 'rejected'] }, 247 - createdAt: { type: 'string', format: 'date-time' }, 247 + publishedAt: { type: 'string', format: 'date-time' }, 248 248 }, 249 249 }, 250 250 400: errorResponseSchema, ··· 363 363 // Build AT Protocol record 364 364 const record: Record<string, unknown> = { 365 365 title, 366 - content, 366 + content: { $type: 'forum.barazo.richtext#markdown', value: content }, 367 367 category, 368 368 tags: tags ?? [], 369 369 community: communityDid, 370 - createdAt: now, 370 + publishedAt: now, 371 371 ...(labels ? { labels } : {}), 372 372 } 373 373 ··· 410 410 reactionCount: 0, 411 411 moderationStatus: contentModerationStatus, 412 412 lastActivityAt: new Date(now), 413 - createdAt: new Date(now), 413 + publishedAt: new Date(now), 414 414 indexedAt: new Date(), 415 415 }) 416 416 .onConflictDoUpdate({ ··· 492 492 category, 493 493 authorHandle: user.handle, 494 494 moderationStatus: contentModerationStatus, 495 - createdAt: now, 495 + publishedAt: now, 496 496 }) 497 497 } catch (err: unknown) { 498 498 if (err instanceof Error && 'statusCode' in err) throw err ··· 713 713 714 714 // Time-decay popularity score: 715 715 // score = (reply_count + reaction_count * 0.3) / (age_in_hours + 2) ^ 1.2 716 - const popularityScore = sql`(${topics.replyCount} + ${topics.reactionCount} * 0.3) / POWER(EXTRACT(EPOCH FROM (NOW() - ${topics.createdAt})) / 3600.0 + 2, 1.2)` 716 + const popularityScore = sql`(${topics.replyCount} + ${topics.reactionCount} * 0.3) / POWER(EXTRACT(EPOCH FROM (NOW() - ${topics.publishedAt})) / 3600.0 + 2, 1.2)` 717 717 718 718 // Pinned-first ordering: when browsing a category, both category-pinned 719 719 // and forum-pinned topics float to the top. On the homepage (no category ··· 1134 1134 // Build updated record for PDS 1135 1135 const updatedRecord: Record<string, unknown> = { 1136 1136 title: updates.title ?? topic.title, 1137 - content: updates.content ?? topic.content, 1137 + content: { 1138 + $type: 'forum.barazo.richtext#markdown', 1139 + value: updates.content ?? topic.content, 1140 + }, 1138 1141 category: updates.category ?? topic.category, 1139 1142 tags: updates.tags ?? topic.tags ?? [], 1140 1143 community: topic.communityDid, 1141 - createdAt: topic.createdAt.toISOString(), 1144 + publishedAt: topic.publishedAt.toISOString(), 1145 + ...(topic.site ? { site: topic.site } : {}), 1142 1146 ...(resolvedLabels ? { labels: resolvedLabels } : {}), 1143 1147 } 1144 1148
+1 -1
src/services/behavioral-heuristics.ts
··· 147 147 148 148 try { 149 149 // Fetch recent topics 150 - const topicConditions = [gte(topics.createdAt, windowStart)] 150 + const topicConditions = [gte(topics.publishedAt, windowStart)] 151 151 if (communityId) { 152 152 topicConditions.push(eq(topics.communityDid, communityId)) 153 153 }
+1 -3
src/setup/service.ts
··· 520 520 authorDid: did, 521 521 title: t.title, 522 522 content: t.content, 523 - contentFormat: null, 524 523 category: t.category, 525 524 tags: t.tags, 526 525 communityDid, ··· 529 528 reactionCount: 0, 530 529 voteCount: 0, 531 530 lastActivityAt: now, 532 - createdAt: now, 531 + publishedAt: now, 533 532 indexedAt: now, 534 533 isLocked: false, 535 534 isPinned: i === 0, ··· 552 551 rkey: replyRkey, 553 552 authorDid: did, 554 553 content: t.replyContent, 555 - contentFormat: null, 556 554 rootUri: topicUri, 557 555 rootCid: topicCid, 558 556 parentUri: topicUri,
-1
src/validation/replies.ts
··· 72 72 rkey: z.string(), 73 73 authorDid: z.string(), 74 74 content: z.string(), 75 - contentFormat: z.string().nullable(), 76 75 rootUri: z.string(), 77 76 rootCid: z.string(), 78 77 parentUri: z.string(),
+2 -2
src/validation/topics.ts
··· 91 91 authorDid: z.string(), 92 92 title: z.string(), 93 93 content: z.string(), 94 - contentFormat: z.string().nullable(), 95 94 category: z.string(), 95 + site: z.string().nullable(), 96 96 tags: z.array(z.string()).nullable(), 97 97 labels: z.object({ values: z.array(z.object({ val: z.string() })) }).nullable(), 98 98 communityDid: z.string(), ··· 100 100 replyCount: z.number(), 101 101 reactionCount: z.number(), 102 102 lastActivityAt: z.string(), 103 - createdAt: z.string(), 103 + publishedAt: z.string(), 104 104 indexedAt: z.string(), 105 105 }) 106 106
+31 -15
tests/integration/firehose/record-processing.test.ts
··· 1 1 import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' 2 2 import { eq } from 'drizzle-orm' 3 + 3 4 import { createDb } from '../../../src/db/index.js' 4 5 import type { Database } from '../../../src/db/index.js' 5 6 import { topics } from '../../../src/db/schema/topics.js' ··· 91 92 record: { 92 93 title: 'Integration Test Topic', 93 94 content: { 94 - $type: 'forum.barazo.richtext#markdown', 95 + $type: 'forum.barazo.richtext#markdown' as const, 95 96 value: 'This is a test topic for integration testing.', 96 97 }, 97 98 community: 'did:plc:community', ··· 138 139 record: { 139 140 title: 'Updated Topic Title', 140 141 content: { 141 - $type: 'forum.barazo.richtext#markdown', 142 + $type: 'forum.barazo.richtext#markdown' as const, 142 143 value: 'Updated content for the topic.', 143 144 }, 144 145 community: 'did:plc:community', ··· 204 205 rkey: 'topic1', 205 206 record: { 206 207 title: 'Parent Topic', 207 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Topic for reply tests' }, 208 + content: { 209 + $type: 'forum.barazo.richtext#markdown' as const, 210 + value: 'Topic for reply tests', 211 + }, 208 212 community: 'did:plc:community', 209 213 category: 'general', 210 214 publishedAt: '2026-01-15T10:00:00.000Z', ··· 223 227 collection: 'forum.barazo.topic.reply', 224 228 rkey: 'reply1', 225 229 record: { 226 - content: { $type: 'forum.barazo.richtext#markdown', value: 'This is a reply' }, 230 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'This is a reply' }, 227 231 root: { uri: topicUri, cid: 'bafytopic1' }, 228 232 parent: { uri: topicUri, cid: 'bafytopic1' }, 229 233 community: 'did:plc:community', ··· 261 265 collection: 'forum.barazo.topic.reply', 262 266 rkey: `reply${String(i)}`, 263 267 record: { 264 - content: { $type: 'forum.barazo.richtext#markdown', value: `Reply ${String(i)}` }, 268 + content: { 269 + $type: 'forum.barazo.richtext#markdown' as const, 270 + value: `Reply ${String(i)}`, 271 + }, 265 272 root: { uri: topicUri, cid: 'bafytopic1' }, 266 273 parent: { uri: topicUri, cid: 'bafytopic1' }, 267 274 community: 'did:plc:community', ··· 291 298 rkey: 'topic1', 292 299 record: { 293 300 title: 'Reactable Topic', 294 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Topic for reaction tests' }, 301 + content: { 302 + $type: 'forum.barazo.richtext#markdown' as const, 303 + value: 'Topic for reaction tests', 304 + }, 295 305 community: 'did:plc:community', 296 306 category: 'general', 297 307 publishedAt: '2026-01-15T10:00:00.000Z', ··· 350 360 rkey: 'idem-topic1', 351 361 record: { 352 362 title: 'Idempotent Topic', 353 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Original content' }, 363 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Original content' }, 354 364 community: 'did:plc:community', 355 365 category: 'general', 356 366 publishedAt: '2026-01-15T10:00:00.000Z', ··· 387 397 rkey: 'idem-topic2', 388 398 record: { 389 399 title: 'Topic for replay test', 390 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 400 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 391 401 community: 'did:plc:community', 392 402 category: 'general', 393 403 publishedAt: '2026-01-15T10:00:00.000Z', ··· 404 414 collection: 'forum.barazo.topic.reply', 405 415 rkey: 'idem-reply1', 406 416 record: { 407 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Replay test reply' }, 417 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Replay test reply' }, 408 418 root: { uri: topicUri, cid: 'bafyidem2' }, 409 419 parent: { uri: topicUri, cid: 'bafyidem2' }, 410 420 community: 'did:plc:community', ··· 445 455 rkey: 'edit-del-topic', 446 456 record: { 447 457 title: 'Original Title', 448 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Original content' }, 458 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Original content' }, 449 459 community: 'did:plc:community', 450 460 category: 'general', 451 461 publishedAt: '2026-01-15T10:00:00.000Z', ··· 464 474 rkey: 'edit-del-reply', 465 475 record: { 466 476 content: { 467 - $type: 'forum.barazo.richtext#markdown', 477 + $type: 'forum.barazo.richtext#markdown' as const, 468 478 value: 'Reply referencing original CID', 469 479 }, 470 480 root: { uri: topicUri, cid: originalCid }, ··· 486 496 rkey: 'edit-del-topic', 487 497 record: { 488 498 title: 'Updated Title', 489 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Updated content' }, 499 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Updated content' }, 490 500 community: 'did:plc:community', 491 501 category: 'general', 492 502 publishedAt: '2026-01-15T10:00:00.000Z', ··· 535 545 rkey: 'rapid-topic', 536 546 record: { 537 547 title: 'Ephemeral Topic', 538 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Gone before you know it' }, 548 + content: { 549 + $type: 'forum.barazo.richtext#markdown' as const, 550 + value: 'Gone before you know it', 551 + }, 539 552 community: 'did:plc:community', 540 553 category: 'general', 541 554 publishedAt: '2026-01-15T10:00:00.000Z', ··· 572 585 rkey: 'rapid-topic2', 573 586 record: { 574 587 title: 'Another Ephemeral Topic', 575 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Will be deleted quickly' }, 588 + content: { 589 + $type: 'forum.barazo.richtext#markdown' as const, 590 + value: 'Will be deleted quickly', 591 + }, 576 592 community: 'did:plc:community', 577 593 category: 'general', 578 594 publishedAt: '2026-01-15T10:00:00.000Z', ··· 591 607 rkey: 'rapid-reply1', 592 608 record: { 593 609 content: { 594 - $type: 'forum.barazo.richtext#markdown', 610 + $type: 'forum.barazo.richtext#markdown' as const, 595 611 value: 'Quick reply before deletion', 596 612 }, 597 613 root: { uri: topicUri, cid: 'bafyrapid2' },
-2
tests/unit/db/schema/replies.test.ts
··· 21 21 'rkey', 22 22 'authorDid', 23 23 'content', 24 - 'contentFormat', 25 24 'rootUri', 26 25 'rootCid', 27 26 'parentUri', ··· 56 55 }) 57 56 58 57 it('has nullable optional columns', () => { 59 - expect(columns.contentFormat.notNull).toBe(false) 60 58 expect(columns.labels.notNull).toBe(false) 61 59 }) 62 60
+3 -3
tests/unit/db/schema/topics.test.ts
··· 22 22 'authorDid', 23 23 'title', 24 24 'content', 25 - 'contentFormat', 25 + 'site', 26 26 'category', 27 27 'tags', 28 28 'communityDid', ··· 31 31 'replyCount', 32 32 'reactionCount', 33 33 'lastActivityAt', 34 - 'createdAt', 34 + 'publishedAt', 35 35 'indexedAt', 36 36 // Note: search_vector (tsvector) and embedding (vector) columns exist 37 37 // in the database but are managed outside Drizzle schema (migration 0010). ··· 54 54 }) 55 55 56 56 it('has nullable optional columns', () => { 57 - expect(columns.contentFormat.notNull).toBe(false) 57 + expect(columns.site.notNull).toBe(false) 58 58 expect(columns.tags.notNull).toBe(false) 59 59 expect(columns.labels.notNull).toBe(false) 60 60 })
+12 -12
tests/unit/firehose/handlers/record.test.ts
··· 85 85 rkey: 'abc123', 86 86 record: { 87 87 title: 'Test', 88 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 88 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 89 89 community: 'did:plc:community', 90 90 category: 'general', 91 91 publishedAt: '2026-01-01T00:00:00.000Z', ··· 109 109 collection: 'forum.barazo.topic.reply', 110 110 rkey: 'reply1', 111 111 record: { 112 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Reply' }, 112 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Reply' }, 113 113 root: { uri: 'at://did:plc:test/forum.barazo.topic.post/t1', cid: 'bafyt' }, 114 114 parent: { uri: 'at://did:plc:test/forum.barazo.topic.post/t1', cid: 'bafyt' }, 115 115 community: 'did:plc:community', ··· 157 157 rkey: 'abc123', 158 158 record: { 159 159 title: 'Updated', 160 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Updated content' }, 160 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Updated content' }, 161 161 community: 'did:plc:community', 162 162 category: 'general', 163 163 publishedAt: '2026-01-01T00:00:00.000Z', ··· 241 241 rkey: 'abc123', 242 242 record: { 243 243 title: 'Test', 244 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 244 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 245 245 community: 'did:plc:community', 246 246 category: 'general', 247 247 publishedAt: '2026-01-01T00:00:00.000Z', ··· 267 267 rkey: 'abc123', 268 268 record: { 269 269 title: 'Test', 270 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 270 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 271 271 community: 'did:plc:community', 272 272 category: 'general', 273 273 publishedAt: '2026-01-01T00:00:00.000Z', ··· 295 295 rkey: 'abc123', 296 296 record: { 297 297 title: 'Test', 298 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 298 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 299 299 community: 'did:plc:community', 300 300 category: 'general', 301 301 publishedAt: '2026-01-01T00:00:00.000Z', ··· 322 322 rkey: 'abc123', 323 323 record: { 324 324 title: 'Test', 325 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 325 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 326 326 community: 'did:plc:community', 327 327 category: 'general', 328 328 publishedAt: '2026-01-01T00:00:00.000Z', ··· 357 357 rkey: 'abc123', 358 358 record: { 359 359 title: 'Test', 360 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 360 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 361 361 community: 'did:plc:community', 362 362 category: 'general', 363 363 publishedAt: '2026-01-01T00:00:00.000Z', ··· 391 391 rkey: 'abc123', 392 392 record: { 393 393 title: 'Test', 394 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 394 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 395 395 community: 'did:plc:community', 396 396 category: 'general', 397 397 publishedAt: '2026-01-01T00:00:00.000Z', ··· 416 416 rkey: 'abc123', 417 417 record: { 418 418 title: 'Updated', 419 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Updated content' }, 419 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Updated content' }, 420 420 community: 'did:plc:community', 421 421 category: 'general', 422 422 publishedAt: '2026-01-01T00:00:00.000Z', ··· 446 446 rkey: 'abc123', 447 447 record: { 448 448 title: 'Test', 449 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 449 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 450 450 community: 'did:plc:community', 451 451 category: 'general', 452 452 publishedAt: '2026-01-01T00:00:00.000Z', ··· 580 580 rkey: 'abc123', 581 581 record: { 582 582 title: 'Test', 583 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Content' }, 583 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Content' }, 584 584 community: 'did:plc:community', 585 585 category: 'general', 586 586 publishedAt: '2026-01-01T00:00:00.000Z',
+4 -4
tests/unit/firehose/validation.test.ts
··· 5 5 describe('topic post validation', () => { 6 6 const validTopic = { 7 7 title: 'Test Topic', 8 - content: { $type: 'forum.barazo.richtext#markdown', value: 'Some content here' }, 8 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'Some content here' }, 9 9 community: 'did:plc:abc123', 10 10 category: 'general', 11 11 tags: ['test'], ··· 26 26 it('rejects a topic post with empty content', () => { 27 27 const result = validateRecord('forum.barazo.topic.post', { 28 28 ...validTopic, 29 - content: { $type: 'forum.barazo.richtext#markdown', value: '' }, 29 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: '' }, 30 30 }) 31 31 expect(result.success).toBe(false) 32 32 }) ··· 34 34 35 35 describe('topic reply validation', () => { 36 36 const validReply = { 37 - content: { $type: 'forum.barazo.richtext#markdown', value: 'A reply' }, 37 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'A reply' }, 38 38 root: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, 39 39 parent: { uri: 'at://did:plc:abc/forum.barazo.topic.post/123', cid: 'bafyabc' }, 40 40 community: 'did:plc:abc123', ··· 119 119 it('rejects records exceeding 64KB', () => { 120 120 const oversized = { 121 121 title: 'Test', 122 - content: { $type: 'forum.barazo.richtext#markdown', value: 'x'.repeat(65_537) }, 122 + content: { $type: 'forum.barazo.richtext#markdown' as const, value: 'x'.repeat(65_537) }, 123 123 community: 'did:plc:abc123', 124 124 category: 'general', 125 125 publishedAt: '2026-01-01T00:00:00.000Z',
+1 -3
tests/unit/routes/maturity-filtering.test.ts
··· 105 105 authorDid: TEST_DID, 106 106 title: 'Test Topic', 107 107 content: 'Content here', 108 - contentFormat: null, 109 108 category: 'general', 110 109 tags: [], 111 110 communityDid: 'did:plc:community123', ··· 114 113 replyCount: 0, 115 114 reactionCount: 0, 116 115 lastActivityAt: new Date(TEST_NOW), 117 - createdAt: new Date(TEST_NOW), 116 + publishedAt: new Date(TEST_NOW), 118 117 indexedAt: new Date(TEST_NOW), 119 118 embedding: null, 120 119 ...overrides, ··· 127 126 rkey: 'reply001', 128 127 authorDid: TEST_DID, 129 128 content: 'A reply', 130 - contentFormat: null, 131 129 rootUri: `at://${TEST_DID}/forum.barazo.topic.post/abc123`, 132 130 rootCid: 'bafyreiabc', 133 131 parentUri: `at://${TEST_DID}/forum.barazo.topic.post/abc123`,
+1 -3
tests/unit/routes/moderation.test.ts
··· 160 160 authorDid: OTHER_DID, 161 161 title: 'Test Topic', 162 162 content: 'Test content', 163 - contentFormat: null, 164 163 category: 'general', 165 164 tags: null, 166 165 communityDid: COMMUNITY_DID, ··· 169 168 replyCount: 0, 170 169 reactionCount: 0, 171 170 lastActivityAt: new Date(TEST_NOW), 172 - createdAt: new Date(TEST_NOW), 171 + publishedAt: new Date(TEST_NOW), 173 172 indexedAt: new Date(TEST_NOW), 174 173 isLocked: false, 175 174 isPinned: false, ··· 187 186 rkey: 'reply123', 188 187 authorDid: OTHER_DID, 189 188 content: 'Test reply', 190 - contentFormat: null, 191 189 rootUri: TEST_TOPIC_URI, 192 190 rootCid: 'bafyreitopic123', 193 191 parentUri: TEST_TOPIC_URI,
+9 -61
tests/unit/routes/replies.test.ts
··· 224 224 authorDid: TEST_DID, 225 225 title: 'Test Topic Title', 226 226 content: 'Test topic content goes here', 227 - contentFormat: null, 228 227 category: 'general', 229 228 tags: ['test', 'example'], 230 229 communityDid: 'did:plc:community123', ··· 233 232 replyCount: 0, 234 233 reactionCount: 0, 235 234 lastActivityAt: new Date(TEST_NOW), 236 - createdAt: new Date(TEST_NOW), 235 + publishedAt: new Date(TEST_NOW), 237 236 indexedAt: new Date(TEST_NOW), 238 237 embedding: null, 239 238 ...overrides, ··· 246 245 rkey: TEST_REPLY_RKEY, 247 246 authorDid: TEST_DID, 248 247 content: 'This is a test reply', 249 - contentFormat: null, 250 248 rootUri: TEST_TOPIC_URI, 251 249 rootCid: TEST_TOPIC_CID, 252 250 parentUri: TEST_TOPIC_URI, ··· 382 380 383 381 // Verify record content 384 382 const record = createRecordFn.mock.calls[0]?.[2] as Record<string, unknown> 385 - expect(record.content).toBe('This is my reply to the topic.') 383 + expect(record.content).toEqual({ 384 + $type: 'forum.barazo.richtext#markdown', 385 + value: 'This is my reply to the topic.', 386 + }) 386 387 expect(record.community).toBe('did:plc:community123') 387 388 expect((record.root as Record<string, unknown>).uri).toBe(TEST_TOPIC_URI) 388 389 expect((record.root as Record<string, unknown>).cid).toBe(TEST_TOPIC_CID) ··· 778 779 779 780 expect(response.statusCode).toBe(200) 780 781 const body = response.json<{ 781 - replies: Array<{ uri: string; content: string; contentFormat: string | null }> 782 + replies: Array<{ uri: string; content: string }> 782 783 }>() 783 784 expect(body.replies).toHaveLength(2) 784 785 785 786 // Mod-deleted reply should show placeholder content 786 787 const deletedReply = body.replies.find((r) => r.uri === modDeletedReply.uri) 787 788 expect(deletedReply?.content).toBe('[Removed by moderator]') 788 - expect(deletedReply?.contentFormat).toBeNull() 789 789 790 790 // Normal reply should show original content 791 791 const normal = body.replies.find((r) => r.uri === normalReply.uri) ··· 2109 2109 const authorDeletedReply = sampleReplyRow({ 2110 2110 isAuthorDeleted: true, 2111 2111 content: 'Original content before deletion', 2112 - contentFormat: 'markdown', 2113 2112 }) 2114 2113 selectChain.limit.mockResolvedValueOnce([authorDeletedReply]) 2115 2114 ··· 2121 2120 2122 2121 expect(response.statusCode).toBe(200) 2123 2122 const body = response.json<{ 2124 - replies: Array<{ content: string; contentFormat: string | null }> 2123 + replies: Array<{ content: string }> 2125 2124 }>() 2126 2125 expect(body.replies).toHaveLength(1) 2127 2126 // Author-deleted replies return placeholder content 2128 2127 expect(body.replies[0]?.content).toBe('[Deleted by author]') 2129 - expect(body.replies[0]?.contentFormat).toBeNull() 2130 2128 }) 2131 2129 2132 - it('returns placeholder content for mod-deleted reply and null contentFormat', async () => { 2130 + it('returns placeholder content for mod-deleted reply', async () => { 2133 2131 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 2134 2132 2135 2133 const modDeletedReply = sampleReplyRow({ 2136 2134 isModDeleted: true, 2137 2135 isAuthorDeleted: false, 2138 2136 content: 'Violating content', 2139 - contentFormat: 'markdown', 2140 2137 }) 2141 2138 selectChain.limit.mockResolvedValueOnce([modDeletedReply]) 2142 2139 ··· 2148 2145 2149 2146 expect(response.statusCode).toBe(200) 2150 2147 const body = response.json<{ 2151 - replies: Array<{ content: string; contentFormat: string | null }> 2148 + replies: Array<{ content: string }> 2152 2149 }>() 2153 2150 expect(body.replies).toHaveLength(1) 2154 2151 expect(body.replies[0]?.content).toBe('[Removed by moderator]') 2155 - expect(body.replies[0]?.contentFormat).toBeNull() 2156 - }) 2157 - 2158 - it('preserves contentFormat for non-deleted replies', async () => { 2159 - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 2160 - 2161 - const markdownReply = sampleReplyRow({ 2162 - contentFormat: 'markdown', 2163 - isAuthorDeleted: false, 2164 - isModDeleted: false, 2165 - }) 2166 - selectChain.limit.mockResolvedValueOnce([markdownReply]) 2167 - 2168 - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) 2169 - const response = await app.inject({ 2170 - method: 'GET', 2171 - url: `/api/topics/${encodedTopicUri}/replies`, 2172 - }) 2173 - 2174 - expect(response.statusCode).toBe(200) 2175 - const body = response.json<{ 2176 - replies: Array<{ contentFormat: string | null }> 2177 - }>() 2178 - expect(body.replies).toHaveLength(1) 2179 - expect(body.replies[0]?.contentFormat).toBe('markdown') 2180 - }) 2181 - 2182 - it('returns contentFormat as null when contentFormat is undefined (not deleted)', async () => { 2183 - selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 2184 - 2185 - const noFormatReply = sampleReplyRow({ 2186 - contentFormat: undefined, 2187 - isAuthorDeleted: false, 2188 - isModDeleted: false, 2189 - }) 2190 - selectChain.limit.mockResolvedValueOnce([noFormatReply]) 2191 - 2192 - const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) 2193 - const response = await app.inject({ 2194 - method: 'GET', 2195 - url: `/api/topics/${encodedTopicUri}/replies`, 2196 - }) 2197 - 2198 - expect(response.statusCode).toBe(200) 2199 - const body = response.json<{ 2200 - replies: Array<{ contentFormat: string | null }> 2201 - }>() 2202 - expect(body.replies).toHaveLength(1) 2203 - expect(body.replies[0]?.contentFormat).toBeNull() 2204 2152 }) 2205 2153 2206 2154 it('prioritizes mod-deleted placeholder over author-deleted when both are true', async () => {
+1 -3
tests/unit/routes/topics-replies-integration.test.ts
··· 193 193 authorDid: TEST_DID, 194 194 title: 'Test Topic Title', 195 195 content: 'Test topic content goes here', 196 - contentFormat: null, 197 196 category: 'general', 198 197 tags: ['test', 'example'], 199 198 communityDid: 'did:plc:community123', ··· 202 201 replyCount: 0, 203 202 reactionCount: 0, 204 203 lastActivityAt: new Date(TEST_NOW), 205 - createdAt: new Date(TEST_NOW), 204 + publishedAt: new Date(TEST_NOW), 206 205 indexedAt: new Date(TEST_NOW), 207 206 embedding: null, 208 207 ...overrides, ··· 215 214 rkey: TEST_REPLY_RKEY, 216 215 authorDid: TEST_DID, 217 216 content: 'This is a test reply', 218 - contentFormat: null, 219 217 rootUri: TEST_TOPIC_URI, 220 218 rootCid: TEST_TOPIC_CID, 221 219 parentUri: TEST_TOPIC_URI,
+1 -28
tests/unit/routes/topics.test.ts
··· 217 217 authorDid: TEST_DID, 218 218 title: 'Test Topic Title', 219 219 content: 'Test topic content goes here', 220 - contentFormat: null, 221 220 category: 'general', 222 221 tags: ['test', 'example'], 223 222 communityDid: 'did:plc:community123', ··· 230 229 pinnedScope: null, 231 230 pinnedAt: null, 232 231 lastActivityAt: new Date(TEST_NOW), 233 - createdAt: new Date(TEST_NOW), 232 + publishedAt: new Date(TEST_NOW), 234 233 indexedAt: new Date(TEST_NOW), 235 234 embedding: null, 236 235 ...overrides, ··· 2716 2715 isAuthorDeleted: true, 2717 2716 title: 'Original Title', 2718 2717 content: 'Original content', 2719 - contentFormat: 'markdown', 2720 2718 }), 2721 2719 ] 2722 2720 selectChain.limit.mockResolvedValueOnce(rows) ··· 2731 2729 topics: Array<{ 2732 2730 title: string 2733 2731 content: string 2734 - contentFormat: string | null 2735 2732 isAuthorDeleted: boolean 2736 2733 }> 2737 2734 }>() 2738 2735 expect(body.topics).toHaveLength(1) 2739 2736 expect(body.topics[0]?.title).toBe('[Deleted by author]') 2740 2737 expect(body.topics[0]?.content).toBe('') 2741 - expect(body.topics[0]?.contentFormat).toBeNull() 2742 - }) 2743 - 2744 - it('serializes topics with contentFormat when present', async () => { 2745 - setupMaturityMocks(true) 2746 - 2747 - const rows = [ 2748 - sampleTopicRow({ 2749 - isAuthorDeleted: false, 2750 - contentFormat: 'markdown', 2751 - }), 2752 - ] 2753 - selectChain.limit.mockResolvedValueOnce(rows) 2754 - 2755 - const response = await app.inject({ 2756 - method: 'GET', 2757 - url: '/api/topics', 2758 - }) 2759 - 2760 - expect(response.statusCode).toBe(200) 2761 - const body = response.json<{ 2762 - topics: Array<{ contentFormat: string | null }> 2763 - }>() 2764 - expect(body.topics[0]?.contentFormat).toBe('markdown') 2765 2738 }) 2766 2739 2767 2740 it('serializes topics with null tags as null', async () => {