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

feat: implement 3-level boards hierarchy (ATB-23) (#32)

* docs: design for boards hierarchy restructuring (ATB-23)

- Restructure from 2-level to 3-level traditional BB hierarchy
- Categories become groupings (non-postable)
- Boards become postable areas (new concept)
- Posts link to both boards and forums
- Includes lexicon, schema, API, and indexer changes
- No migration needed (no production data)

* feat(lexicon): add space.atbb.forum.board lexicon

- Board record type with category reference
- Owned by Forum DID, key: tid
- Required: name, category, createdAt
- Optional: description, slug, sortOrder

* feat(lexicon): add board reference to post lexicon

- Posts can now reference boards via boardRef
- Optional field for backwards compatibility
- Keeps forum reference for client flexibility

* feat(db): add boards table for 3-level hierarchy

- Boards belong to categories (categoryId FK)
- Store category URI for out-of-order indexing
- Unique index on (did, rkey) for AT Proto records
- Index on category_id for efficient filtering
- Generated migration with drizzle-kit

* feat(db): add board columns to posts table

- Add board_uri and board_id columns (nullable)
- Indexes for efficient board filtering
- Keeps forum_uri for client flexibility

* feat(indexer): add helper methods for board/category URI lookup

- getBoardIdByUri: resolve board AT URI to database ID
- getCategoryIdByUri: resolve category AT URI to database ID
- Both return null for non-existent records
- Add comprehensive tests using real database

* feat(indexer): add board indexing handlers

- handleBoardCreate/Update/Delete methods
- Resolves category URI to ID before insert
- Skips insert if category not found (logs warning)
- Tests verify indexing and orphan handling

* feat(appview): extract boardUri and boardId in post indexer

- Add test for post creation with board reference
- Update postConfig.toInsertValues to extract boardUri and look up boardId
- Update postConfig.toUpdateValues to extract boardUri
- Board references are optional (gracefully handled with null)
- Uses getBoardIdByUri helper method for FK lookup

Completes Task 7 of ATB-23 boards hierarchy implementation.

* feat(firehose): register board collection handlers

- Firehose now subscribes to space.atbb.forum.board
- Board create/update/delete events route to indexer

* feat(helpers): add getBoardByUri and serializeBoard helpers

- Add getBoardByUri: looks up board by AT-URI, returns CID or null
- Add serializeBoard: converts BigInt → string, Date → ISO string
- Add BoardRow type export for type safety
- Update test-context to clean up boards in afterEach
- Add comprehensive tests for both helpers (4 tests total)

Task 9 of ATB-23 boards hierarchy implementation

* feat(api): add GET /api/boards endpoint

- Returns all boards sorted by category, then board sortOrder
- Defensive 1000 limit to prevent memory exhaustion
- Error handling with structured logging
- Comprehensive test coverage (success, empty, database error cases)

* fix: remove internal fields from serializeBoard output

Align serializeBoard with serializeCategory and serializeForum by
hiding internal AT Protocol fields (rkey, cid) from API responses.

Changes:
- Remove rkey and cid from serializeBoard return value
- Update JSDoc comment to reflect correct response shape
- Add comprehensive serialization tests to boards.test.ts:
- Verify BigInt fields are stringified
- Verify date fields are ISO 8601 strings
- Verify internal fields (rkey, cid) are not exposed
- Verify null optional fields are handled gracefully
- Update helpers-boards.test.ts to match new serialization behavior

All 295 tests pass.

* feat(api): register boards routes in app

- GET /api/boards now accessible
- Follows existing route registration pattern

* fix(api): add stub boards route for test consistency

* feat(api): require boardUri in POST /api/topics

- boardUri is now required (returns 400 if missing)
- Validates board exists before writing to PDS
- Writes both forum and board references to post record
- forumUri always uses configured singleton
- Updated all existing tests to include boardUri
- Removed obsolete custom forumUri test

* feat(api): add GET /api/boards/:id/topics endpoint

- Returns topics (posts with NULL rootPostId) for a board
- Sorted by createdAt DESC (newest first)
- Filters out deleted posts
- Defensive 1000 limit
- Add parseBigIntParam validation for board ID
- Update test context cleanup to include topicsuser DID pattern
- Add posts deletion in boards test beforeEach for test isolation

* feat(api): add GET /api/categories/:id/boards endpoint

- Returns boards for a specific category
- Sorted by board sortOrder
- Defensive 1000 limit

* docs(bruno): add board endpoints and update topic creation

- List All Boards: GET /api/boards
- Get Board Topics: GET /api/boards/:id/topics
- Get Category Boards: GET /api/categories/:id/boards
- Update Create Topic to require boardUri
- Add board_rkey environment variable

* docs: mark ATB-23 complete in project plan

- Boards hierarchy implemented and tested
- All API endpoints functional
- Bruno collections updated

* fix: address PR #32 review feedback (tasks 1-5, 7-9)

Implements code review fixes for boards hierarchy PR:

- Add boardUri/boardId fields to serializePost response (task #1)
- Fix silent failures in indexer FK lookups - now throw errors with
structured logging instead of returning null (task #2)
- Add 404 existence checks to GET /api/boards/:id/topics and
GET /api/categories/:id/boards before querying data (task #3)
- Add comprehensive boardUri validation: type guard, format check,
collection type validation, and ownership verification (task #4)
- Add error logging to getBoardIdByUri and getCategoryIdByUri
helper functions with structured context (task #5)
- Remove redundant catch blocks from helpers - let errors bubble
to route handlers for proper classification (task #7)
- Fix migration file references in project plan document (task #8)
- Fix Bruno API documentation inaccuracies - add missing fields,
remove non-existent fields, document 404 errors (task #9)

Test infrastructure improvements:
- Fix test-context.ts forum insertion order - cleanDatabase now
runs before forum insert to prevent deletion of test data
- Update test expectations for new error behavior:
* indexer now throws on missing FK instead of silent skip
* endpoints now return 404 for non-existent resources
- All 304 tests passing

Remaining tasks: #6 (add 11 test cases), #10 (reclassify db errors)

* test: add missing test cases for boards and topics (task #6)

Adds comprehensive test coverage for board indexer operations and
boardUri validation:

Indexer tests (indexer-boards.test.ts):
- handleBoardUpdate: verifies board updates are indexed correctly
- handleBoardDelete: verifies board deletion removes record

Topics API tests (topics.test.ts):
- Malformed boardUri: returns 400 for invalid AT URI format
- Wrong collection type: returns 400 when boardUri points to category
- Wrong forum DID: returns 400 when boardUri belongs to different forum

All 309 tests passing (5 new tests added).

* fix: reclassify database connection errors as 503 (task #10)

Distinguishes database connection failures from unexpected errors:

Error classification hierarchy:
1. Programming errors (TypeError, ReferenceError) → throw to global handler
2. Network errors (PDS fetch failures, timeouts) → 503 with PDS message
3. Database errors (connection, pool, postgres) → 503 with DB message
4. Unexpected errors (validation, logic bugs) → 500 with "report issue"

Changes:
- Add isDatabaseError() helper to detect DB connection failures
- Update POST /api/topics error handling to check for DB errors
- Return 503 "Database temporarily unavailable" for connection issues
- Update 500 message to "report this issue if it persists"
- Add test for database connection errors returning 503
- Update test for true 500 errors (non-network, non-DB)

All 310 tests passing (1 new test added).

authored by

Malpercio and committed by
GitHub
149037e5 6ae8acde

+3822 -136
+18
apps/appview/drizzle/0002_sturdy_maestro.sql
··· 1 + CREATE TABLE "boards" ( 2 + "id" bigserial PRIMARY KEY NOT NULL, 3 + "did" text NOT NULL, 4 + "rkey" text NOT NULL, 5 + "cid" text NOT NULL, 6 + "name" text NOT NULL, 7 + "description" text, 8 + "slug" text, 9 + "sort_order" integer, 10 + "category_id" bigint, 11 + "category_uri" text NOT NULL, 12 + "created_at" timestamp with time zone NOT NULL, 13 + "indexed_at" timestamp with time zone NOT NULL 14 + ); 15 + --> statement-breakpoint 16 + ALTER TABLE "boards" ADD CONSTRAINT "boards_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 17 + CREATE UNIQUE INDEX "boards_did_rkey_idx" ON "boards" USING btree ("did","rkey");--> statement-breakpoint 18 + CREATE INDEX "boards_category_id_idx" ON "boards" USING btree ("category_id");
+5
apps/appview/drizzle/0003_brief_mariko_yashida.sql
··· 1 + ALTER TABLE "posts" ADD COLUMN "board_uri" text;--> statement-breakpoint 2 + ALTER TABLE "posts" ADD COLUMN "board_id" bigint;--> statement-breakpoint 3 + ALTER TABLE "posts" ADD CONSTRAINT "posts_board_id_boards_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."boards"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 4 + CREATE INDEX "posts_board_id_idx" ON "posts" USING btree ("board_id");--> statement-breakpoint 5 + CREATE INDEX "posts_board_uri_idx" ON "posts" USING btree ("board_uri");
+864
apps/appview/drizzle/meta/0002_snapshot.json
··· 1 + { 2 + "id": "d3732d17-9130-47d8-9eb9-664a768a7a07", 3 + "prevId": "838aff64-8b13-4395-92c7-2c7ce2c12c73", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.boards": { 8 + "name": "boards", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "category_id": { 60 + "name": "category_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "category_uri": { 66 + "name": "category_uri", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "created_at": { 72 + "name": "created_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + }, 77 + "indexed_at": { 78 + "name": "indexed_at", 79 + "type": "timestamp with time zone", 80 + "primaryKey": false, 81 + "notNull": true 82 + } 83 + }, 84 + "indexes": { 85 + "boards_did_rkey_idx": { 86 + "name": "boards_did_rkey_idx", 87 + "columns": [ 88 + { 89 + "expression": "did", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + }, 94 + { 95 + "expression": "rkey", 96 + "isExpression": false, 97 + "asc": true, 98 + "nulls": "last" 99 + } 100 + ], 101 + "isUnique": true, 102 + "concurrently": false, 103 + "method": "btree", 104 + "with": {} 105 + }, 106 + "boards_category_id_idx": { 107 + "name": "boards_category_id_idx", 108 + "columns": [ 109 + { 110 + "expression": "category_id", 111 + "isExpression": false, 112 + "asc": true, 113 + "nulls": "last" 114 + } 115 + ], 116 + "isUnique": false, 117 + "concurrently": false, 118 + "method": "btree", 119 + "with": {} 120 + } 121 + }, 122 + "foreignKeys": { 123 + "boards_category_id_categories_id_fk": { 124 + "name": "boards_category_id_categories_id_fk", 125 + "tableFrom": "boards", 126 + "tableTo": "categories", 127 + "columnsFrom": [ 128 + "category_id" 129 + ], 130 + "columnsTo": [ 131 + "id" 132 + ], 133 + "onDelete": "no action", 134 + "onUpdate": "no action" 135 + } 136 + }, 137 + "compositePrimaryKeys": {}, 138 + "uniqueConstraints": {}, 139 + "policies": {}, 140 + "checkConstraints": {}, 141 + "isRLSEnabled": false 142 + }, 143 + "public.categories": { 144 + "name": "categories", 145 + "schema": "", 146 + "columns": { 147 + "id": { 148 + "name": "id", 149 + "type": "bigserial", 150 + "primaryKey": true, 151 + "notNull": true 152 + }, 153 + "did": { 154 + "name": "did", 155 + "type": "text", 156 + "primaryKey": false, 157 + "notNull": true 158 + }, 159 + "rkey": { 160 + "name": "rkey", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true 164 + }, 165 + "cid": { 166 + "name": "cid", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": true 170 + }, 171 + "name": { 172 + "name": "name", 173 + "type": "text", 174 + "primaryKey": false, 175 + "notNull": true 176 + }, 177 + "description": { 178 + "name": "description", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false 182 + }, 183 + "slug": { 184 + "name": "slug", 185 + "type": "text", 186 + "primaryKey": false, 187 + "notNull": false 188 + }, 189 + "sort_order": { 190 + "name": "sort_order", 191 + "type": "integer", 192 + "primaryKey": false, 193 + "notNull": false 194 + }, 195 + "forum_id": { 196 + "name": "forum_id", 197 + "type": "bigint", 198 + "primaryKey": false, 199 + "notNull": false 200 + }, 201 + "created_at": { 202 + "name": "created_at", 203 + "type": "timestamp with time zone", 204 + "primaryKey": false, 205 + "notNull": true 206 + }, 207 + "indexed_at": { 208 + "name": "indexed_at", 209 + "type": "timestamp with time zone", 210 + "primaryKey": false, 211 + "notNull": true 212 + } 213 + }, 214 + "indexes": { 215 + "categories_did_rkey_idx": { 216 + "name": "categories_did_rkey_idx", 217 + "columns": [ 218 + { 219 + "expression": "did", 220 + "isExpression": false, 221 + "asc": true, 222 + "nulls": "last" 223 + }, 224 + { 225 + "expression": "rkey", 226 + "isExpression": false, 227 + "asc": true, 228 + "nulls": "last" 229 + } 230 + ], 231 + "isUnique": true, 232 + "concurrently": false, 233 + "method": "btree", 234 + "with": {} 235 + } 236 + }, 237 + "foreignKeys": { 238 + "categories_forum_id_forums_id_fk": { 239 + "name": "categories_forum_id_forums_id_fk", 240 + "tableFrom": "categories", 241 + "tableTo": "forums", 242 + "columnsFrom": [ 243 + "forum_id" 244 + ], 245 + "columnsTo": [ 246 + "id" 247 + ], 248 + "onDelete": "no action", 249 + "onUpdate": "no action" 250 + } 251 + }, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "policies": {}, 255 + "checkConstraints": {}, 256 + "isRLSEnabled": false 257 + }, 258 + "public.firehose_cursor": { 259 + "name": "firehose_cursor", 260 + "schema": "", 261 + "columns": { 262 + "service": { 263 + "name": "service", 264 + "type": "text", 265 + "primaryKey": true, 266 + "notNull": true, 267 + "default": "'jetstream'" 268 + }, 269 + "cursor": { 270 + "name": "cursor", 271 + "type": "bigint", 272 + "primaryKey": false, 273 + "notNull": true 274 + }, 275 + "updated_at": { 276 + "name": "updated_at", 277 + "type": "timestamp with time zone", 278 + "primaryKey": false, 279 + "notNull": true 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": {}, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "policies": {}, 287 + "checkConstraints": {}, 288 + "isRLSEnabled": false 289 + }, 290 + "public.forums": { 291 + "name": "forums", 292 + "schema": "", 293 + "columns": { 294 + "id": { 295 + "name": "id", 296 + "type": "bigserial", 297 + "primaryKey": true, 298 + "notNull": true 299 + }, 300 + "did": { 301 + "name": "did", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "rkey": { 307 + "name": "rkey", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true 317 + }, 318 + "name": { 319 + "name": "name", 320 + "type": "text", 321 + "primaryKey": false, 322 + "notNull": true 323 + }, 324 + "description": { 325 + "name": "description", 326 + "type": "text", 327 + "primaryKey": false, 328 + "notNull": false 329 + }, 330 + "indexed_at": { 331 + "name": "indexed_at", 332 + "type": "timestamp with time zone", 333 + "primaryKey": false, 334 + "notNull": true 335 + } 336 + }, 337 + "indexes": { 338 + "forums_did_rkey_idx": { 339 + "name": "forums_did_rkey_idx", 340 + "columns": [ 341 + { 342 + "expression": "did", 343 + "isExpression": false, 344 + "asc": true, 345 + "nulls": "last" 346 + }, 347 + { 348 + "expression": "rkey", 349 + "isExpression": false, 350 + "asc": true, 351 + "nulls": "last" 352 + } 353 + ], 354 + "isUnique": true, 355 + "concurrently": false, 356 + "method": "btree", 357 + "with": {} 358 + } 359 + }, 360 + "foreignKeys": {}, 361 + "compositePrimaryKeys": {}, 362 + "uniqueConstraints": {}, 363 + "policies": {}, 364 + "checkConstraints": {}, 365 + "isRLSEnabled": false 366 + }, 367 + "public.memberships": { 368 + "name": "memberships", 369 + "schema": "", 370 + "columns": { 371 + "id": { 372 + "name": "id", 373 + "type": "bigserial", 374 + "primaryKey": true, 375 + "notNull": true 376 + }, 377 + "did": { 378 + "name": "did", 379 + "type": "text", 380 + "primaryKey": false, 381 + "notNull": true 382 + }, 383 + "rkey": { 384 + "name": "rkey", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true 388 + }, 389 + "cid": { 390 + "name": "cid", 391 + "type": "text", 392 + "primaryKey": false, 393 + "notNull": true 394 + }, 395 + "forum_id": { 396 + "name": "forum_id", 397 + "type": "bigint", 398 + "primaryKey": false, 399 + "notNull": false 400 + }, 401 + "forum_uri": { 402 + "name": "forum_uri", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true 406 + }, 407 + "role": { 408 + "name": "role", 409 + "type": "text", 410 + "primaryKey": false, 411 + "notNull": false 412 + }, 413 + "role_uri": { 414 + "name": "role_uri", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": false 418 + }, 419 + "joined_at": { 420 + "name": "joined_at", 421 + "type": "timestamp with time zone", 422 + "primaryKey": false, 423 + "notNull": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "timestamp with time zone", 428 + "primaryKey": false, 429 + "notNull": true 430 + }, 431 + "indexed_at": { 432 + "name": "indexed_at", 433 + "type": "timestamp with time zone", 434 + "primaryKey": false, 435 + "notNull": true 436 + } 437 + }, 438 + "indexes": { 439 + "memberships_did_rkey_idx": { 440 + "name": "memberships_did_rkey_idx", 441 + "columns": [ 442 + { 443 + "expression": "did", 444 + "isExpression": false, 445 + "asc": true, 446 + "nulls": "last" 447 + }, 448 + { 449 + "expression": "rkey", 450 + "isExpression": false, 451 + "asc": true, 452 + "nulls": "last" 453 + } 454 + ], 455 + "isUnique": true, 456 + "concurrently": false, 457 + "method": "btree", 458 + "with": {} 459 + }, 460 + "memberships_did_idx": { 461 + "name": "memberships_did_idx", 462 + "columns": [ 463 + { 464 + "expression": "did", 465 + "isExpression": false, 466 + "asc": true, 467 + "nulls": "last" 468 + } 469 + ], 470 + "isUnique": false, 471 + "concurrently": false, 472 + "method": "btree", 473 + "with": {} 474 + } 475 + }, 476 + "foreignKeys": { 477 + "memberships_did_users_did_fk": { 478 + "name": "memberships_did_users_did_fk", 479 + "tableFrom": "memberships", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "did" 483 + ], 484 + "columnsTo": [ 485 + "did" 486 + ], 487 + "onDelete": "no action", 488 + "onUpdate": "no action" 489 + }, 490 + "memberships_forum_id_forums_id_fk": { 491 + "name": "memberships_forum_id_forums_id_fk", 492 + "tableFrom": "memberships", 493 + "tableTo": "forums", 494 + "columnsFrom": [ 495 + "forum_id" 496 + ], 497 + "columnsTo": [ 498 + "id" 499 + ], 500 + "onDelete": "no action", 501 + "onUpdate": "no action" 502 + } 503 + }, 504 + "compositePrimaryKeys": {}, 505 + "uniqueConstraints": {}, 506 + "policies": {}, 507 + "checkConstraints": {}, 508 + "isRLSEnabled": false 509 + }, 510 + "public.mod_actions": { 511 + "name": "mod_actions", 512 + "schema": "", 513 + "columns": { 514 + "id": { 515 + "name": "id", 516 + "type": "bigserial", 517 + "primaryKey": true, 518 + "notNull": true 519 + }, 520 + "did": { 521 + "name": "did", 522 + "type": "text", 523 + "primaryKey": false, 524 + "notNull": true 525 + }, 526 + "rkey": { 527 + "name": "rkey", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true 531 + }, 532 + "cid": { 533 + "name": "cid", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "action": { 539 + "name": "action", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "subject_did": { 545 + "name": "subject_did", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": false 549 + }, 550 + "subject_post_uri": { 551 + "name": "subject_post_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_id": { 557 + "name": "forum_id", 558 + "type": "bigint", 559 + "primaryKey": false, 560 + "notNull": false 561 + }, 562 + "reason": { 563 + "name": "reason", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "created_by": { 569 + "name": "created_by", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + }, 574 + "expires_at": { 575 + "name": "expires_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "mod_actions_did_rkey_idx": { 595 + "name": "mod_actions_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + } 615 + }, 616 + "foreignKeys": { 617 + "mod_actions_forum_id_forums_id_fk": { 618 + "name": "mod_actions_forum_id_forums_id_fk", 619 + "tableFrom": "mod_actions", 620 + "tableTo": "forums", 621 + "columnsFrom": [ 622 + "forum_id" 623 + ], 624 + "columnsTo": [ 625 + "id" 626 + ], 627 + "onDelete": "no action", 628 + "onUpdate": "no action" 629 + } 630 + }, 631 + "compositePrimaryKeys": {}, 632 + "uniqueConstraints": {}, 633 + "policies": {}, 634 + "checkConstraints": {}, 635 + "isRLSEnabled": false 636 + }, 637 + "public.posts": { 638 + "name": "posts", 639 + "schema": "", 640 + "columns": { 641 + "id": { 642 + "name": "id", 643 + "type": "bigserial", 644 + "primaryKey": true, 645 + "notNull": true 646 + }, 647 + "did": { 648 + "name": "did", 649 + "type": "text", 650 + "primaryKey": false, 651 + "notNull": true 652 + }, 653 + "rkey": { 654 + "name": "rkey", 655 + "type": "text", 656 + "primaryKey": false, 657 + "notNull": true 658 + }, 659 + "cid": { 660 + "name": "cid", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true 664 + }, 665 + "text": { 666 + "name": "text", 667 + "type": "text", 668 + "primaryKey": false, 669 + "notNull": true 670 + }, 671 + "forum_uri": { 672 + "name": "forum_uri", 673 + "type": "text", 674 + "primaryKey": false, 675 + "notNull": false 676 + }, 677 + "root_post_id": { 678 + "name": "root_post_id", 679 + "type": "bigint", 680 + "primaryKey": false, 681 + "notNull": false 682 + }, 683 + "parent_post_id": { 684 + "name": "parent_post_id", 685 + "type": "bigint", 686 + "primaryKey": false, 687 + "notNull": false 688 + }, 689 + "root_uri": { 690 + "name": "root_uri", 691 + "type": "text", 692 + "primaryKey": false, 693 + "notNull": false 694 + }, 695 + "parent_uri": { 696 + "name": "parent_uri", 697 + "type": "text", 698 + "primaryKey": false, 699 + "notNull": false 700 + }, 701 + "created_at": { 702 + "name": "created_at", 703 + "type": "timestamp with time zone", 704 + "primaryKey": false, 705 + "notNull": true 706 + }, 707 + "indexed_at": { 708 + "name": "indexed_at", 709 + "type": "timestamp with time zone", 710 + "primaryKey": false, 711 + "notNull": true 712 + }, 713 + "deleted": { 714 + "name": "deleted", 715 + "type": "boolean", 716 + "primaryKey": false, 717 + "notNull": true, 718 + "default": false 719 + } 720 + }, 721 + "indexes": { 722 + "posts_did_rkey_idx": { 723 + "name": "posts_did_rkey_idx", 724 + "columns": [ 725 + { 726 + "expression": "did", 727 + "isExpression": false, 728 + "asc": true, 729 + "nulls": "last" 730 + }, 731 + { 732 + "expression": "rkey", 733 + "isExpression": false, 734 + "asc": true, 735 + "nulls": "last" 736 + } 737 + ], 738 + "isUnique": true, 739 + "concurrently": false, 740 + "method": "btree", 741 + "with": {} 742 + }, 743 + "posts_forum_uri_idx": { 744 + "name": "posts_forum_uri_idx", 745 + "columns": [ 746 + { 747 + "expression": "forum_uri", 748 + "isExpression": false, 749 + "asc": true, 750 + "nulls": "last" 751 + } 752 + ], 753 + "isUnique": false, 754 + "concurrently": false, 755 + "method": "btree", 756 + "with": {} 757 + }, 758 + "posts_root_post_id_idx": { 759 + "name": "posts_root_post_id_idx", 760 + "columns": [ 761 + { 762 + "expression": "root_post_id", 763 + "isExpression": false, 764 + "asc": true, 765 + "nulls": "last" 766 + } 767 + ], 768 + "isUnique": false, 769 + "concurrently": false, 770 + "method": "btree", 771 + "with": {} 772 + } 773 + }, 774 + "foreignKeys": { 775 + "posts_did_users_did_fk": { 776 + "name": "posts_did_users_did_fk", 777 + "tableFrom": "posts", 778 + "tableTo": "users", 779 + "columnsFrom": [ 780 + "did" 781 + ], 782 + "columnsTo": [ 783 + "did" 784 + ], 785 + "onDelete": "no action", 786 + "onUpdate": "no action" 787 + }, 788 + "posts_root_post_id_posts_id_fk": { 789 + "name": "posts_root_post_id_posts_id_fk", 790 + "tableFrom": "posts", 791 + "tableTo": "posts", 792 + "columnsFrom": [ 793 + "root_post_id" 794 + ], 795 + "columnsTo": [ 796 + "id" 797 + ], 798 + "onDelete": "no action", 799 + "onUpdate": "no action" 800 + }, 801 + "posts_parent_post_id_posts_id_fk": { 802 + "name": "posts_parent_post_id_posts_id_fk", 803 + "tableFrom": "posts", 804 + "tableTo": "posts", 805 + "columnsFrom": [ 806 + "parent_post_id" 807 + ], 808 + "columnsTo": [ 809 + "id" 810 + ], 811 + "onDelete": "no action", 812 + "onUpdate": "no action" 813 + } 814 + }, 815 + "compositePrimaryKeys": {}, 816 + "uniqueConstraints": {}, 817 + "policies": {}, 818 + "checkConstraints": {}, 819 + "isRLSEnabled": false 820 + }, 821 + "public.users": { 822 + "name": "users", 823 + "schema": "", 824 + "columns": { 825 + "did": { 826 + "name": "did", 827 + "type": "text", 828 + "primaryKey": true, 829 + "notNull": true 830 + }, 831 + "handle": { 832 + "name": "handle", 833 + "type": "text", 834 + "primaryKey": false, 835 + "notNull": false 836 + }, 837 + "indexed_at": { 838 + "name": "indexed_at", 839 + "type": "timestamp with time zone", 840 + "primaryKey": false, 841 + "notNull": true 842 + } 843 + }, 844 + "indexes": {}, 845 + "foreignKeys": {}, 846 + "compositePrimaryKeys": {}, 847 + "uniqueConstraints": {}, 848 + "policies": {}, 849 + "checkConstraints": {}, 850 + "isRLSEnabled": false 851 + } 852 + }, 853 + "enums": {}, 854 + "schemas": {}, 855 + "sequences": {}, 856 + "roles": {}, 857 + "policies": {}, 858 + "views": {}, 859 + "_meta": { 860 + "columns": {}, 861 + "schemas": {}, 862 + "tables": {} 863 + } 864 + }
+919
apps/appview/drizzle/meta/0003_snapshot.json
··· 1 + { 2 + "id": "d3c71ac8-7dbe-4314-913e-2bab776fc4fb", 3 + "prevId": "d3732d17-9130-47d8-9eb9-664a768a7a07", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.boards": { 8 + "name": "boards", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "category_id": { 60 + "name": "category_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "category_uri": { 66 + "name": "category_uri", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "created_at": { 72 + "name": "created_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + }, 77 + "indexed_at": { 78 + "name": "indexed_at", 79 + "type": "timestamp with time zone", 80 + "primaryKey": false, 81 + "notNull": true 82 + } 83 + }, 84 + "indexes": { 85 + "boards_did_rkey_idx": { 86 + "name": "boards_did_rkey_idx", 87 + "columns": [ 88 + { 89 + "expression": "did", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + }, 94 + { 95 + "expression": "rkey", 96 + "isExpression": false, 97 + "asc": true, 98 + "nulls": "last" 99 + } 100 + ], 101 + "isUnique": true, 102 + "concurrently": false, 103 + "method": "btree", 104 + "with": {} 105 + }, 106 + "boards_category_id_idx": { 107 + "name": "boards_category_id_idx", 108 + "columns": [ 109 + { 110 + "expression": "category_id", 111 + "isExpression": false, 112 + "asc": true, 113 + "nulls": "last" 114 + } 115 + ], 116 + "isUnique": false, 117 + "concurrently": false, 118 + "method": "btree", 119 + "with": {} 120 + } 121 + }, 122 + "foreignKeys": { 123 + "boards_category_id_categories_id_fk": { 124 + "name": "boards_category_id_categories_id_fk", 125 + "tableFrom": "boards", 126 + "tableTo": "categories", 127 + "columnsFrom": [ 128 + "category_id" 129 + ], 130 + "columnsTo": [ 131 + "id" 132 + ], 133 + "onDelete": "no action", 134 + "onUpdate": "no action" 135 + } 136 + }, 137 + "compositePrimaryKeys": {}, 138 + "uniqueConstraints": {}, 139 + "policies": {}, 140 + "checkConstraints": {}, 141 + "isRLSEnabled": false 142 + }, 143 + "public.categories": { 144 + "name": "categories", 145 + "schema": "", 146 + "columns": { 147 + "id": { 148 + "name": "id", 149 + "type": "bigserial", 150 + "primaryKey": true, 151 + "notNull": true 152 + }, 153 + "did": { 154 + "name": "did", 155 + "type": "text", 156 + "primaryKey": false, 157 + "notNull": true 158 + }, 159 + "rkey": { 160 + "name": "rkey", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true 164 + }, 165 + "cid": { 166 + "name": "cid", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": true 170 + }, 171 + "name": { 172 + "name": "name", 173 + "type": "text", 174 + "primaryKey": false, 175 + "notNull": true 176 + }, 177 + "description": { 178 + "name": "description", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false 182 + }, 183 + "slug": { 184 + "name": "slug", 185 + "type": "text", 186 + "primaryKey": false, 187 + "notNull": false 188 + }, 189 + "sort_order": { 190 + "name": "sort_order", 191 + "type": "integer", 192 + "primaryKey": false, 193 + "notNull": false 194 + }, 195 + "forum_id": { 196 + "name": "forum_id", 197 + "type": "bigint", 198 + "primaryKey": false, 199 + "notNull": false 200 + }, 201 + "created_at": { 202 + "name": "created_at", 203 + "type": "timestamp with time zone", 204 + "primaryKey": false, 205 + "notNull": true 206 + }, 207 + "indexed_at": { 208 + "name": "indexed_at", 209 + "type": "timestamp with time zone", 210 + "primaryKey": false, 211 + "notNull": true 212 + } 213 + }, 214 + "indexes": { 215 + "categories_did_rkey_idx": { 216 + "name": "categories_did_rkey_idx", 217 + "columns": [ 218 + { 219 + "expression": "did", 220 + "isExpression": false, 221 + "asc": true, 222 + "nulls": "last" 223 + }, 224 + { 225 + "expression": "rkey", 226 + "isExpression": false, 227 + "asc": true, 228 + "nulls": "last" 229 + } 230 + ], 231 + "isUnique": true, 232 + "concurrently": false, 233 + "method": "btree", 234 + "with": {} 235 + } 236 + }, 237 + "foreignKeys": { 238 + "categories_forum_id_forums_id_fk": { 239 + "name": "categories_forum_id_forums_id_fk", 240 + "tableFrom": "categories", 241 + "tableTo": "forums", 242 + "columnsFrom": [ 243 + "forum_id" 244 + ], 245 + "columnsTo": [ 246 + "id" 247 + ], 248 + "onDelete": "no action", 249 + "onUpdate": "no action" 250 + } 251 + }, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "policies": {}, 255 + "checkConstraints": {}, 256 + "isRLSEnabled": false 257 + }, 258 + "public.firehose_cursor": { 259 + "name": "firehose_cursor", 260 + "schema": "", 261 + "columns": { 262 + "service": { 263 + "name": "service", 264 + "type": "text", 265 + "primaryKey": true, 266 + "notNull": true, 267 + "default": "'jetstream'" 268 + }, 269 + "cursor": { 270 + "name": "cursor", 271 + "type": "bigint", 272 + "primaryKey": false, 273 + "notNull": true 274 + }, 275 + "updated_at": { 276 + "name": "updated_at", 277 + "type": "timestamp with time zone", 278 + "primaryKey": false, 279 + "notNull": true 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": {}, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "policies": {}, 287 + "checkConstraints": {}, 288 + "isRLSEnabled": false 289 + }, 290 + "public.forums": { 291 + "name": "forums", 292 + "schema": "", 293 + "columns": { 294 + "id": { 295 + "name": "id", 296 + "type": "bigserial", 297 + "primaryKey": true, 298 + "notNull": true 299 + }, 300 + "did": { 301 + "name": "did", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "rkey": { 307 + "name": "rkey", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true 317 + }, 318 + "name": { 319 + "name": "name", 320 + "type": "text", 321 + "primaryKey": false, 322 + "notNull": true 323 + }, 324 + "description": { 325 + "name": "description", 326 + "type": "text", 327 + "primaryKey": false, 328 + "notNull": false 329 + }, 330 + "indexed_at": { 331 + "name": "indexed_at", 332 + "type": "timestamp with time zone", 333 + "primaryKey": false, 334 + "notNull": true 335 + } 336 + }, 337 + "indexes": { 338 + "forums_did_rkey_idx": { 339 + "name": "forums_did_rkey_idx", 340 + "columns": [ 341 + { 342 + "expression": "did", 343 + "isExpression": false, 344 + "asc": true, 345 + "nulls": "last" 346 + }, 347 + { 348 + "expression": "rkey", 349 + "isExpression": false, 350 + "asc": true, 351 + "nulls": "last" 352 + } 353 + ], 354 + "isUnique": true, 355 + "concurrently": false, 356 + "method": "btree", 357 + "with": {} 358 + } 359 + }, 360 + "foreignKeys": {}, 361 + "compositePrimaryKeys": {}, 362 + "uniqueConstraints": {}, 363 + "policies": {}, 364 + "checkConstraints": {}, 365 + "isRLSEnabled": false 366 + }, 367 + "public.memberships": { 368 + "name": "memberships", 369 + "schema": "", 370 + "columns": { 371 + "id": { 372 + "name": "id", 373 + "type": "bigserial", 374 + "primaryKey": true, 375 + "notNull": true 376 + }, 377 + "did": { 378 + "name": "did", 379 + "type": "text", 380 + "primaryKey": false, 381 + "notNull": true 382 + }, 383 + "rkey": { 384 + "name": "rkey", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true 388 + }, 389 + "cid": { 390 + "name": "cid", 391 + "type": "text", 392 + "primaryKey": false, 393 + "notNull": true 394 + }, 395 + "forum_id": { 396 + "name": "forum_id", 397 + "type": "bigint", 398 + "primaryKey": false, 399 + "notNull": false 400 + }, 401 + "forum_uri": { 402 + "name": "forum_uri", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true 406 + }, 407 + "role": { 408 + "name": "role", 409 + "type": "text", 410 + "primaryKey": false, 411 + "notNull": false 412 + }, 413 + "role_uri": { 414 + "name": "role_uri", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": false 418 + }, 419 + "joined_at": { 420 + "name": "joined_at", 421 + "type": "timestamp with time zone", 422 + "primaryKey": false, 423 + "notNull": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "timestamp with time zone", 428 + "primaryKey": false, 429 + "notNull": true 430 + }, 431 + "indexed_at": { 432 + "name": "indexed_at", 433 + "type": "timestamp with time zone", 434 + "primaryKey": false, 435 + "notNull": true 436 + } 437 + }, 438 + "indexes": { 439 + "memberships_did_rkey_idx": { 440 + "name": "memberships_did_rkey_idx", 441 + "columns": [ 442 + { 443 + "expression": "did", 444 + "isExpression": false, 445 + "asc": true, 446 + "nulls": "last" 447 + }, 448 + { 449 + "expression": "rkey", 450 + "isExpression": false, 451 + "asc": true, 452 + "nulls": "last" 453 + } 454 + ], 455 + "isUnique": true, 456 + "concurrently": false, 457 + "method": "btree", 458 + "with": {} 459 + }, 460 + "memberships_did_idx": { 461 + "name": "memberships_did_idx", 462 + "columns": [ 463 + { 464 + "expression": "did", 465 + "isExpression": false, 466 + "asc": true, 467 + "nulls": "last" 468 + } 469 + ], 470 + "isUnique": false, 471 + "concurrently": false, 472 + "method": "btree", 473 + "with": {} 474 + } 475 + }, 476 + "foreignKeys": { 477 + "memberships_did_users_did_fk": { 478 + "name": "memberships_did_users_did_fk", 479 + "tableFrom": "memberships", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "did" 483 + ], 484 + "columnsTo": [ 485 + "did" 486 + ], 487 + "onDelete": "no action", 488 + "onUpdate": "no action" 489 + }, 490 + "memberships_forum_id_forums_id_fk": { 491 + "name": "memberships_forum_id_forums_id_fk", 492 + "tableFrom": "memberships", 493 + "tableTo": "forums", 494 + "columnsFrom": [ 495 + "forum_id" 496 + ], 497 + "columnsTo": [ 498 + "id" 499 + ], 500 + "onDelete": "no action", 501 + "onUpdate": "no action" 502 + } 503 + }, 504 + "compositePrimaryKeys": {}, 505 + "uniqueConstraints": {}, 506 + "policies": {}, 507 + "checkConstraints": {}, 508 + "isRLSEnabled": false 509 + }, 510 + "public.mod_actions": { 511 + "name": "mod_actions", 512 + "schema": "", 513 + "columns": { 514 + "id": { 515 + "name": "id", 516 + "type": "bigserial", 517 + "primaryKey": true, 518 + "notNull": true 519 + }, 520 + "did": { 521 + "name": "did", 522 + "type": "text", 523 + "primaryKey": false, 524 + "notNull": true 525 + }, 526 + "rkey": { 527 + "name": "rkey", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true 531 + }, 532 + "cid": { 533 + "name": "cid", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "action": { 539 + "name": "action", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "subject_did": { 545 + "name": "subject_did", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": false 549 + }, 550 + "subject_post_uri": { 551 + "name": "subject_post_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_id": { 557 + "name": "forum_id", 558 + "type": "bigint", 559 + "primaryKey": false, 560 + "notNull": false 561 + }, 562 + "reason": { 563 + "name": "reason", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "created_by": { 569 + "name": "created_by", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + }, 574 + "expires_at": { 575 + "name": "expires_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "mod_actions_did_rkey_idx": { 595 + "name": "mod_actions_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + } 615 + }, 616 + "foreignKeys": { 617 + "mod_actions_forum_id_forums_id_fk": { 618 + "name": "mod_actions_forum_id_forums_id_fk", 619 + "tableFrom": "mod_actions", 620 + "tableTo": "forums", 621 + "columnsFrom": [ 622 + "forum_id" 623 + ], 624 + "columnsTo": [ 625 + "id" 626 + ], 627 + "onDelete": "no action", 628 + "onUpdate": "no action" 629 + } 630 + }, 631 + "compositePrimaryKeys": {}, 632 + "uniqueConstraints": {}, 633 + "policies": {}, 634 + "checkConstraints": {}, 635 + "isRLSEnabled": false 636 + }, 637 + "public.posts": { 638 + "name": "posts", 639 + "schema": "", 640 + "columns": { 641 + "id": { 642 + "name": "id", 643 + "type": "bigserial", 644 + "primaryKey": true, 645 + "notNull": true 646 + }, 647 + "did": { 648 + "name": "did", 649 + "type": "text", 650 + "primaryKey": false, 651 + "notNull": true 652 + }, 653 + "rkey": { 654 + "name": "rkey", 655 + "type": "text", 656 + "primaryKey": false, 657 + "notNull": true 658 + }, 659 + "cid": { 660 + "name": "cid", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true 664 + }, 665 + "text": { 666 + "name": "text", 667 + "type": "text", 668 + "primaryKey": false, 669 + "notNull": true 670 + }, 671 + "forum_uri": { 672 + "name": "forum_uri", 673 + "type": "text", 674 + "primaryKey": false, 675 + "notNull": false 676 + }, 677 + "board_uri": { 678 + "name": "board_uri", 679 + "type": "text", 680 + "primaryKey": false, 681 + "notNull": false 682 + }, 683 + "board_id": { 684 + "name": "board_id", 685 + "type": "bigint", 686 + "primaryKey": false, 687 + "notNull": false 688 + }, 689 + "root_post_id": { 690 + "name": "root_post_id", 691 + "type": "bigint", 692 + "primaryKey": false, 693 + "notNull": false 694 + }, 695 + "parent_post_id": { 696 + "name": "parent_post_id", 697 + "type": "bigint", 698 + "primaryKey": false, 699 + "notNull": false 700 + }, 701 + "root_uri": { 702 + "name": "root_uri", 703 + "type": "text", 704 + "primaryKey": false, 705 + "notNull": false 706 + }, 707 + "parent_uri": { 708 + "name": "parent_uri", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": false 712 + }, 713 + "created_at": { 714 + "name": "created_at", 715 + "type": "timestamp with time zone", 716 + "primaryKey": false, 717 + "notNull": true 718 + }, 719 + "indexed_at": { 720 + "name": "indexed_at", 721 + "type": "timestamp with time zone", 722 + "primaryKey": false, 723 + "notNull": true 724 + }, 725 + "deleted": { 726 + "name": "deleted", 727 + "type": "boolean", 728 + "primaryKey": false, 729 + "notNull": true, 730 + "default": false 731 + } 732 + }, 733 + "indexes": { 734 + "posts_did_rkey_idx": { 735 + "name": "posts_did_rkey_idx", 736 + "columns": [ 737 + { 738 + "expression": "did", 739 + "isExpression": false, 740 + "asc": true, 741 + "nulls": "last" 742 + }, 743 + { 744 + "expression": "rkey", 745 + "isExpression": false, 746 + "asc": true, 747 + "nulls": "last" 748 + } 749 + ], 750 + "isUnique": true, 751 + "concurrently": false, 752 + "method": "btree", 753 + "with": {} 754 + }, 755 + "posts_forum_uri_idx": { 756 + "name": "posts_forum_uri_idx", 757 + "columns": [ 758 + { 759 + "expression": "forum_uri", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": false, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "posts_board_id_idx": { 771 + "name": "posts_board_id_idx", 772 + "columns": [ 773 + { 774 + "expression": "board_id", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "posts_board_uri_idx": { 786 + "name": "posts_board_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "board_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + }, 800 + "posts_root_post_id_idx": { 801 + "name": "posts_root_post_id_idx", 802 + "columns": [ 803 + { 804 + "expression": "root_post_id", 805 + "isExpression": false, 806 + "asc": true, 807 + "nulls": "last" 808 + } 809 + ], 810 + "isUnique": false, 811 + "concurrently": false, 812 + "method": "btree", 813 + "with": {} 814 + } 815 + }, 816 + "foreignKeys": { 817 + "posts_did_users_did_fk": { 818 + "name": "posts_did_users_did_fk", 819 + "tableFrom": "posts", 820 + "tableTo": "users", 821 + "columnsFrom": [ 822 + "did" 823 + ], 824 + "columnsTo": [ 825 + "did" 826 + ], 827 + "onDelete": "no action", 828 + "onUpdate": "no action" 829 + }, 830 + "posts_board_id_boards_id_fk": { 831 + "name": "posts_board_id_boards_id_fk", 832 + "tableFrom": "posts", 833 + "tableTo": "boards", 834 + "columnsFrom": [ 835 + "board_id" 836 + ], 837 + "columnsTo": [ 838 + "id" 839 + ], 840 + "onDelete": "no action", 841 + "onUpdate": "no action" 842 + }, 843 + "posts_root_post_id_posts_id_fk": { 844 + "name": "posts_root_post_id_posts_id_fk", 845 + "tableFrom": "posts", 846 + "tableTo": "posts", 847 + "columnsFrom": [ 848 + "root_post_id" 849 + ], 850 + "columnsTo": [ 851 + "id" 852 + ], 853 + "onDelete": "no action", 854 + "onUpdate": "no action" 855 + }, 856 + "posts_parent_post_id_posts_id_fk": { 857 + "name": "posts_parent_post_id_posts_id_fk", 858 + "tableFrom": "posts", 859 + "tableTo": "posts", 860 + "columnsFrom": [ 861 + "parent_post_id" 862 + ], 863 + "columnsTo": [ 864 + "id" 865 + ], 866 + "onDelete": "no action", 867 + "onUpdate": "no action" 868 + } 869 + }, 870 + "compositePrimaryKeys": {}, 871 + "uniqueConstraints": {}, 872 + "policies": {}, 873 + "checkConstraints": {}, 874 + "isRLSEnabled": false 875 + }, 876 + "public.users": { 877 + "name": "users", 878 + "schema": "", 879 + "columns": { 880 + "did": { 881 + "name": "did", 882 + "type": "text", 883 + "primaryKey": true, 884 + "notNull": true 885 + }, 886 + "handle": { 887 + "name": "handle", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "indexed_at": { 893 + "name": "indexed_at", 894 + "type": "timestamp with time zone", 895 + "primaryKey": false, 896 + "notNull": true 897 + } 898 + }, 899 + "indexes": {}, 900 + "foreignKeys": {}, 901 + "compositePrimaryKeys": {}, 902 + "uniqueConstraints": {}, 903 + "policies": {}, 904 + "checkConstraints": {}, 905 + "isRLSEnabled": false 906 + } 907 + }, 908 + "enums": {}, 909 + "schemas": {}, 910 + "sequences": {}, 911 + "roles": {}, 912 + "policies": {}, 913 + "views": {}, 914 + "_meta": { 915 + "columns": {}, 916 + "schemas": {}, 917 + "tables": {} 918 + } 919 + }
+14
apps/appview/drizzle/meta/_journal.json
··· 15 15 "when": 1770466250786, 16 16 "tag": "0001_daily_power_pack", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "7", 22 + "when": 1771026420747, 23 + "tag": "0002_sturdy_maestro", 24 + "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "7", 29 + "when": 1771027321303, 30 + "tag": "0003_brief_mariko_yashida", 31 + "breakpoints": true 18 32 } 19 33 ] 20 34 }
+93
apps/appview/src/lib/__tests__/indexer-board-helpers.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Indexer } from "../indexer.js"; 3 + import { createTestContext, type TestContext } from "./test-context.js"; 4 + import { boards, categories, forums } from "@atbb/db"; 5 + import { eq } from "drizzle-orm"; 6 + 7 + describe("Indexer - Board Helpers", () => { 8 + let ctx: TestContext; 9 + let indexer: Indexer; 10 + 11 + beforeEach(async () => { 12 + ctx = await createTestContext(); 13 + indexer = new Indexer(ctx.db); 14 + }); 15 + 16 + afterEach(async () => { 17 + // Clean up in correct FK order: boards -> categories -> forums 18 + await ctx.db.delete(boards).where(eq(boards.did, "did:plc:test-forum")); 19 + await ctx.cleanup(); 20 + }); 21 + 22 + it("getBoardIdByUri returns board ID for valid URI", async () => { 23 + // Get the forum ID created by createTestContext 24 + const [forum] = await ctx.db 25 + .select({ id: forums.id }) 26 + .from(forums) 27 + .where(eq(forums.did, "did:plc:test-forum")) 28 + .limit(1); 29 + 30 + // Insert test category 31 + const [category] = await ctx.db.insert(categories).values({ 32 + did: "did:plc:test-forum", 33 + rkey: "cat1", 34 + cid: "bafycat", 35 + name: "Test Category", 36 + forumId: forum.id, 37 + createdAt: new Date(), 38 + indexedAt: new Date(), 39 + }).returning(); 40 + 41 + // Insert test board 42 + const [board] = await ctx.db.insert(boards).values({ 43 + did: "did:plc:test-forum", 44 + rkey: "board1", 45 + cid: "bafyboard", 46 + name: "Test Board", 47 + categoryId: category.id, 48 + categoryUri: `at://did:plc:test-forum/space.atbb.forum.category/cat1`, 49 + createdAt: new Date(), 50 + indexedAt: new Date(), 51 + }).returning(); 52 + 53 + const uri = "at://did:plc:test-forum/space.atbb.forum.board/board1"; 54 + const boardId = await (indexer as any).getBoardIdByUri(uri, ctx.db); 55 + expect(boardId).toBe(board.id); 56 + }); 57 + 58 + it("getBoardIdByUri returns null for non-existent board", async () => { 59 + const uri = "at://did:plc:test-forum/space.atbb.forum.board/nonexistent"; 60 + const boardId = await (indexer as any).getBoardIdByUri(uri, ctx.db); 61 + expect(boardId).toBeNull(); 62 + }); 63 + 64 + it("getCategoryIdByUri returns category ID for valid URI", async () => { 65 + // Get the forum ID created by createTestContext 66 + const [forum] = await ctx.db 67 + .select({ id: forums.id }) 68 + .from(forums) 69 + .where(eq(forums.did, "did:plc:test-forum")) 70 + .limit(1); 71 + 72 + // Insert test category 73 + const [category] = await ctx.db.insert(categories).values({ 74 + did: "did:plc:test-forum", 75 + rkey: "cat2", 76 + cid: "bafycat2", 77 + name: "Test Category 2", 78 + forumId: forum.id, 79 + createdAt: new Date(), 80 + indexedAt: new Date(), 81 + }).returning(); 82 + 83 + const uri = "at://did:plc:test-forum/space.atbb.forum.category/cat2"; 84 + const categoryId = await (indexer as any).getCategoryIdByUri(uri, ctx.db); 85 + expect(categoryId).toBe(category.id); 86 + }); 87 + 88 + it("getCategoryIdByUri returns null for non-existent category", async () => { 89 + const uri = "at://did:plc:test-forum/space.atbb.forum.category/nonexistent"; 90 + const categoryId = await (indexer as any).getCategoryIdByUri(uri, ctx.db); 91 + expect(categoryId).toBeNull(); 92 + }); 93 + });
+252
apps/appview/src/lib/__tests__/indexer-boards.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Indexer } from "../indexer.js"; 3 + import { createTestContext, type TestContext } from "./test-context.js"; 4 + import { boards, categories, forums } from "@atbb/db"; 5 + import { eq } from "drizzle-orm"; 6 + import type { 7 + CommitCreateEvent, 8 + CommitUpdateEvent, 9 + CommitDeleteEvent, 10 + } from "@skyware/jetstream"; 11 + 12 + describe("Indexer - Board Handlers", () => { 13 + let ctx: TestContext; 14 + let indexer: Indexer; 15 + 16 + let testCategoryId: bigint; 17 + 18 + beforeEach(async () => { 19 + ctx = await createTestContext(); 20 + indexer = new Indexer(ctx.db); 21 + 22 + // Get the forum ID created by createTestContext 23 + const [forum] = await ctx.db 24 + .select({ id: forums.id }) 25 + .from(forums) 26 + .where(eq(forums.did, "did:plc:test-forum")) 27 + .limit(1); 28 + 29 + // Insert test category and save the ID 30 + const [category] = await ctx.db.insert(categories).values({ 31 + did: "did:plc:test-forum", 32 + rkey: "cat1", 33 + cid: "bafycat", 34 + name: "Test Category", 35 + forumId: forum.id, 36 + createdAt: new Date(), 37 + indexedAt: new Date(), 38 + }).returning(); 39 + 40 + testCategoryId = category.id; 41 + }); 42 + 43 + afterEach(async () => { 44 + // Clean up in correct FK order: boards -> categories -> forums 45 + await ctx.db.delete(boards).where(eq(boards.did, "did:plc:test-forum")); 46 + await ctx.cleanup(); 47 + }); 48 + 49 + it("handleBoardCreate indexes board record", async () => { 50 + const event: CommitCreateEvent<"space.atbb.forum.board"> = { 51 + kind: "commit", 52 + commit: { 53 + rev: "abc123", 54 + operation: "create", 55 + collection: "space.atbb.forum.board", 56 + rkey: "board1", 57 + record: { 58 + $type: "space.atbb.forum.board", 59 + name: "General Discussion", 60 + description: "Talk about anything", 61 + slug: "general", 62 + sortOrder: 1, 63 + category: { 64 + category: { 65 + uri: "at://did:plc:test-forum/space.atbb.forum.category/cat1", 66 + cid: "bafycat", 67 + }, 68 + }, 69 + createdAt: "2026-02-13T00:00:00Z", 70 + } as any, 71 + cid: "bafyboard", 72 + }, 73 + did: "did:plc:test-forum", 74 + time_us: 1000000, 75 + }; 76 + 77 + await indexer.handleBoardCreate(event); 78 + 79 + const [board] = await ctx.db 80 + .select() 81 + .from(boards) 82 + .where(eq(boards.rkey, "board1")); 83 + 84 + expect(board).toBeDefined(); 85 + expect(board.name).toBe("General Discussion"); 86 + expect(board.slug).toBe("general"); 87 + expect(board.sortOrder).toBe(1); 88 + expect(board.categoryId).toBe(testCategoryId); 89 + expect(board.categoryUri).toBe( 90 + "at://did:plc:test-forum/space.atbb.forum.category/cat1" 91 + ); 92 + }); 93 + 94 + it("handleBoardCreate throws when category not found", async () => { 95 + const event: CommitCreateEvent<"space.atbb.forum.board"> = { 96 + kind: "commit", 97 + commit: { 98 + rev: "abc123", 99 + operation: "create", 100 + collection: "space.atbb.forum.board", 101 + rkey: "board2", 102 + record: { 103 + $type: "space.atbb.forum.board", 104 + name: "Orphan Board", 105 + category: { 106 + category: { 107 + uri: "at://did:plc:test-forum/space.atbb.forum.category/nonexistent", 108 + cid: "bafynonexistent", 109 + }, 110 + }, 111 + createdAt: "2026-02-13T00:00:00Z", 112 + } as any, 113 + cid: "bafyboard2", 114 + }, 115 + did: "did:plc:test-forum", 116 + time_us: 1000000, 117 + }; 118 + 119 + await expect(indexer.handleBoardCreate(event)).rejects.toThrow( 120 + "Category not found: at://did:plc:test-forum/space.atbb.forum.category/nonexistent" 121 + ); 122 + 123 + const results = await ctx.db.select().from(boards); 124 + expect(results).toHaveLength(0); 125 + }); 126 + 127 + it("handleBoardUpdate updates existing board", async () => { 128 + // First create a board 129 + const createEvent: CommitCreateEvent<"space.atbb.forum.board"> = { 130 + kind: "commit", 131 + commit: { 132 + rev: "abc123", 133 + operation: "create", 134 + collection: "space.atbb.forum.board", 135 + rkey: "board1", 136 + record: { 137 + $type: "space.atbb.forum.board", 138 + name: "General Discussion", 139 + description: "Talk about anything", 140 + slug: "general", 141 + sortOrder: 1, 142 + category: { 143 + category: { 144 + uri: "at://did:plc:test-forum/space.atbb.forum.category/cat1", 145 + cid: "bafycat", 146 + }, 147 + }, 148 + createdAt: "2026-02-13T00:00:00Z", 149 + } as any, 150 + cid: "bafyboard", 151 + }, 152 + did: "did:plc:test-forum", 153 + time_us: 1000000, 154 + }; 155 + 156 + await indexer.handleBoardCreate(createEvent); 157 + 158 + // Now update it 159 + const updateEvent: CommitUpdateEvent<"space.atbb.forum.board"> = { 160 + kind: "commit", 161 + commit: { 162 + rev: "def456", 163 + operation: "update", 164 + collection: "space.atbb.forum.board", 165 + rkey: "board1", 166 + record: { 167 + $type: "space.atbb.forum.board", 168 + name: "Updated Board Name", 169 + description: "Updated description", 170 + slug: "updated-general", 171 + sortOrder: 2, 172 + category: { 173 + category: { 174 + uri: "at://did:plc:test-forum/space.atbb.forum.category/cat1", 175 + cid: "bafycat", 176 + }, 177 + }, 178 + createdAt: "2026-02-13T00:00:00Z", 179 + } as any, 180 + cid: "bafyboard-updated", 181 + }, 182 + did: "did:plc:test-forum", 183 + time_us: 2000000, 184 + }; 185 + 186 + await indexer.handleBoardUpdate(updateEvent); 187 + 188 + const [board] = await ctx.db 189 + .select() 190 + .from(boards) 191 + .where(eq(boards.rkey, "board1")); 192 + 193 + expect(board).toBeDefined(); 194 + expect(board.name).toBe("Updated Board Name"); 195 + expect(board.description).toBe("Updated description"); 196 + expect(board.slug).toBe("updated-general"); 197 + expect(board.sortOrder).toBe(2); 198 + expect(board.cid).toBe("bafyboard-updated"); 199 + }); 200 + 201 + it("handleBoardDelete removes board", async () => { 202 + // First create a board 203 + const createEvent: CommitCreateEvent<"space.atbb.forum.board"> = { 204 + kind: "commit", 205 + commit: { 206 + rev: "abc123", 207 + operation: "create", 208 + collection: "space.atbb.forum.board", 209 + rkey: "board1", 210 + record: { 211 + $type: "space.atbb.forum.board", 212 + name: "General Discussion", 213 + category: { 214 + category: { 215 + uri: "at://did:plc:test-forum/space.atbb.forum.category/cat1", 216 + cid: "bafycat", 217 + }, 218 + }, 219 + createdAt: "2026-02-13T00:00:00Z", 220 + } as any, 221 + cid: "bafyboard", 222 + }, 223 + did: "did:plc:test-forum", 224 + time_us: 1000000, 225 + }; 226 + 227 + await indexer.handleBoardCreate(createEvent); 228 + 229 + // Verify it exists 230 + let results = await ctx.db.select().from(boards); 231 + expect(results).toHaveLength(1); 232 + 233 + // Now delete it 234 + const deleteEvent: CommitDeleteEvent<"space.atbb.forum.board"> = { 235 + kind: "commit", 236 + commit: { 237 + rev: "ghi789", 238 + operation: "delete", 239 + collection: "space.atbb.forum.board", 240 + rkey: "board1", 241 + }, 242 + did: "did:plc:test-forum", 243 + time_us: 3000000, 244 + }; 245 + 246 + await indexer.handleBoardDelete(deleteEvent); 247 + 248 + // Verify it's gone 249 + results = await ctx.db.select().from(boards); 250 + expect(results).toHaveLength(0); 251 + }); 252 + });
+70
apps/appview/src/lib/__tests__/indexer.test.ts
··· 115 115 expect(mockDb.insert).toHaveBeenCalled(); 116 116 }); 117 117 118 + it("should handle post creation with board reference", async () => { 119 + // Track what values are inserted 120 + let insertedValues: any = null; 121 + 122 + const mockBoardId = BigInt(123); 123 + const mockDbWithTracking = createMockDb(); 124 + mockDbWithTracking.transaction = vi.fn().mockImplementation(async (callback) => { 125 + const txContext = { 126 + insert: vi.fn().mockImplementation((_table: any) => { 127 + return { 128 + values: vi.fn().mockImplementation((vals: any) => { 129 + insertedValues = vals; 130 + return Promise.resolve(undefined); 131 + }), 132 + }; 133 + }), 134 + select: vi.fn().mockReturnValue({ 135 + from: vi.fn().mockReturnValue({ 136 + where: vi.fn().mockReturnValue({ 137 + limit: vi.fn().mockResolvedValue([{ id: mockBoardId }]), 138 + }), 139 + }), 140 + }), 141 + update: vi.fn(), 142 + delete: vi.fn(), 143 + }; 144 + return await callback(txContext); 145 + }); 146 + 147 + const indexer = new Indexer(mockDbWithTracking); 148 + const boardUri = "at://did:plc:forum/space.atbb.forum.board/board1"; 149 + 150 + const event: CommitCreateEvent<"space.atbb.post"> = { 151 + did: "did:plc:test123", 152 + time_us: 1234567890, 153 + kind: "commit", 154 + commit: { 155 + rev: "abc", 156 + operation: "create", 157 + collection: "space.atbb.post", 158 + rkey: "post1", 159 + cid: "cid123", 160 + record: { 161 + $type: "space.atbb.post", 162 + text: "Hello world in a board", 163 + forum: { 164 + forum: { 165 + uri: "at://did:plc:forum/space.atbb.forum.forum/self", 166 + cid: "cidForum", 167 + }, 168 + }, 169 + board: { 170 + board: { 171 + uri: boardUri, 172 + cid: "cidBoard", 173 + }, 174 + }, 175 + createdAt: "2024-01-01T00:00:00Z", 176 + } as any, 177 + }, 178 + }; 179 + 180 + await indexer.handlePostCreate(event); 181 + 182 + // Verify the insert values include boardUri and boardId 183 + expect(insertedValues).toBeDefined(); 184 + expect(insertedValues.boardUri).toBe(boardUri); 185 + expect(insertedValues.boardId).toBe(mockBoardId); 186 + }); 187 + 118 188 it("should handle post creation with reply references", async () => { 119 189 120 190
+38 -19
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 2 import { drizzle } from "drizzle-orm/postgres-js"; 3 3 import postgres from "postgres"; 4 - import { forums, posts, users, categories, memberships } from "@atbb/db"; 4 + import { forums, posts, users, categories, memberships, boards } 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"; 8 8 9 9 export interface TestContext extends AppContext { 10 10 cleanup: () => Promise<void>; 11 + cleanDatabase: () => Promise<void>; 11 12 } 12 13 13 14 export interface TestContextOptions { ··· 36 37 const sql = postgres(config.databaseUrl); 37 38 const db = drizzle(sql, { schema }); 38 39 39 - // Insert test forum unless emptyDb is true 40 - if (!options.emptyDb) { 41 - await db 42 - .insert(forums) 43 - .values({ 44 - did: config.forumDid, 45 - rkey: "self", 46 - cid: "bafytest", 47 - name: "Test Forum", 48 - description: "A test forum", 49 - indexedAt: new Date(), 50 - }) 51 - .onConflictDoNothing(); 52 - } 53 - 54 40 // Create stub OAuth dependencies (unused in read-path tests) 55 41 const stubFirehose = { 56 42 start: () => Promise.resolve(), ··· 63 49 const stubCookieSessionStore = { destroy: () => {} } as any; 64 50 const stubForumAgent = null; // Mock ForumAgent is null by default (can be overridden in tests) 65 51 52 + const cleanDatabase = async () => { 53 + // Aggressive cleanup - delete ALL test data for forum DID 54 + // This ensures a clean slate even if previous runs failed 55 + await db.delete(posts).where(eq(posts.did, config.forumDid)).catch(() => {}); 56 + await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 57 + await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 58 + await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 59 + await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 60 + await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 61 + await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 62 + }; 63 + 64 + // Clean database before creating test data to ensure clean state 65 + await cleanDatabase(); 66 + 67 + // Insert test forum unless emptyDb is true 68 + // No need for onConflictDoNothing since cleanDatabase ensures clean state 69 + if (!options.emptyDb) { 70 + await db.insert(forums).values({ 71 + did: config.forumDid, 72 + rkey: "self", 73 + cid: "bafytest", 74 + name: "Test Forum", 75 + description: "A test forum", 76 + indexedAt: new Date(), 77 + }); 78 + } 79 + 66 80 return { 67 81 db, 68 82 config, ··· 72 86 oauthSessionStore: stubOAuthSessionStore, 73 87 cookieSessionStore: stubCookieSessionStore, 74 88 forumAgent: stubForumAgent, 89 + cleanDatabase, 75 90 cleanup: async () => { 76 - // Clean up test data (order matters due to FKs: posts -> memberships -> users -> categories -> forums) 91 + // Clean up test data (order matters due to FKs: posts -> memberships -> users -> boards -> categories -> forums) 77 92 // Delete all test-specific DIDs (including dynamically generated ones) 78 93 const testDidPattern = or( 79 94 eq(posts.did, "did:plc:test-user"), 95 + eq(posts.did, "did:plc:topicsuser"), 80 96 like(posts.did, "did:plc:test-%"), 81 97 like(posts.did, "did:plc:duptest-%"), 82 98 like(posts.did, "did:plc:create-%"), ··· 86 102 87 103 const testMembershipPattern = or( 88 104 eq(memberships.did, "did:plc:test-user"), 105 + eq(memberships.did, "did:plc:topicsuser"), 89 106 like(memberships.did, "did:plc:test-%"), 90 107 like(memberships.did, "did:plc:duptest-%"), 91 108 like(memberships.did, "did:plc:create-%"), ··· 95 112 96 113 const testUserPattern = or( 97 114 eq(users.did, "did:plc:test-user"), 115 + eq(users.did, "did:plc:topicsuser"), 98 116 like(users.did, "did:plc:test-%"), 99 117 like(users.did, "did:plc:duptest-%"), 100 118 like(users.did, "did:plc:create-%"), ··· 102 120 ); 103 121 await db.delete(users).where(testUserPattern); 104 122 105 - // Delete categories before forums (FK constraint) 123 + // Delete boards, categories, and forums in order (FK constraints) 124 + await db.delete(boards).where(eq(boards.did, config.forumDid)); 106 125 await db.delete(categories).where(eq(categories.did, config.forumDid)); 107 126 await db.delete(forums).where(eq(forums.did, config.forumDid)); 108 127 // Close postgres connection to prevent leaks 109 128 await sql.end(); 110 129 }, 111 - } as AppContext & { cleanup: () => Promise<void> }; 130 + } as TestContext; 112 131 }
+6
apps/appview/src/lib/firehose.ts
··· 104 104 onDelete: this.createWrappedHandler("handleCategoryDelete"), 105 105 }) 106 106 .register({ 107 + collection: "space.atbb.forum.board", 108 + onCreate: this.createWrappedHandler("handleBoardCreate"), 109 + onUpdate: this.createWrappedHandler("handleBoardUpdate"), 110 + onDelete: this.createWrappedHandler("handleBoardDelete"), 111 + }) 112 + .register({ 107 113 collection: "space.atbb.membership", 108 114 onCreate: this.createWrappedHandler("handleMembershipCreate"), 109 115 onUpdate: this.createWrappedHandler("handleMembershipUpdate"),
+188 -6
apps/appview/src/lib/indexer.ts
··· 8 8 posts, 9 9 forums, 10 10 categories, 11 + boards, 11 12 users, 12 13 memberships, 13 14 modActions, ··· 18 19 SpaceAtbbPost as Post, 19 20 SpaceAtbbForumForum as Forum, 20 21 SpaceAtbbForumCategory as Category, 22 + SpaceAtbbForumBoard as Board, 21 23 SpaceAtbbMembership as Membership, 22 24 SpaceAtbbModAction as ModAction, 23 25 } from "@atbb/lexicon"; ··· 83 85 parentId = await this.getPostIdByUri(record.reply.parent.uri, tx); 84 86 } 85 87 88 + // Look up board ID if board reference exists 89 + let boardId: bigint | null = null; 90 + if (record.board?.board.uri) { 91 + boardId = await this.getBoardIdByUri(record.board.board.uri, tx); 92 + if (!boardId) { 93 + console.error("Failed to index post: board not found", { 94 + operation: "Post CREATE", 95 + postDid: event.did, 96 + postRkey: event.commit.rkey, 97 + boardUri: record.board.board.uri, 98 + errorId: "POST_BOARD_MISSING", 99 + }); 100 + throw new Error(`Board not found: ${record.board.board.uri}`); 101 + } 102 + } 103 + 86 104 return { 87 105 did: event.did, 88 106 rkey: event.commit.rkey, 89 107 cid: event.commit.cid, 90 108 text: record.text, 91 109 forumUri: record.forum?.forum.uri ?? null, 110 + boardUri: record.board?.board.uri ?? null, 111 + boardId, 92 112 rootPostId: rootId, 93 113 rootUri: record.reply?.root.uri ?? null, 94 114 parentPostId: parentId, ··· 97 117 indexedAt: new Date(), 98 118 }; 99 119 }, 100 - toUpdateValues: async (event, record) => ({ 101 - cid: event.commit.cid, 102 - text: record.text, 103 - forumUri: record.forum?.forum.uri ?? null, 104 - indexedAt: new Date(), 105 - }), 120 + toUpdateValues: async (event, record, tx) => { 121 + // Look up board ID if board reference exists 122 + let boardId: bigint | null = null; 123 + if (record.board?.board.uri) { 124 + boardId = await this.getBoardIdByUri(record.board.board.uri, tx); 125 + if (!boardId) { 126 + console.error("Failed to index post: board not found", { 127 + operation: "Post UPDATE", 128 + postDid: event.did, 129 + postRkey: event.commit.rkey, 130 + boardUri: record.board.board.uri, 131 + errorId: "POST_BOARD_MISSING", 132 + }); 133 + throw new Error(`Board not found: ${record.board.board.uri}`); 134 + } 135 + } 136 + 137 + return { 138 + cid: event.commit.cid, 139 + text: record.text, 140 + forumUri: record.forum?.forum.uri ?? null, 141 + boardUri: record.board?.board.uri ?? null, 142 + boardId, 143 + indexedAt: new Date(), 144 + }; 145 + }, 106 146 }; 107 147 108 148 private forumConfig: CollectionConfig<Forum.Record> = { ··· 177 217 }, 178 218 }; 179 219 220 + private boardConfig: CollectionConfig<Board.Record> = { 221 + name: "Board", 222 + table: boards, 223 + deleteStrategy: "hard", 224 + toInsertValues: async (event, record, tx) => { 225 + // Boards are owned by Forum DID 226 + const categoryId = await this.getCategoryIdByUri( 227 + record.category.category.uri, 228 + tx 229 + ); 230 + 231 + if (!categoryId) { 232 + console.error("Failed to index board: category not found", { 233 + operation: "Board CREATE", 234 + boardDid: event.did, 235 + boardRkey: event.commit.rkey, 236 + categoryUri: record.category.category.uri, 237 + errorId: "BOARD_CATEGORY_MISSING", 238 + }); 239 + throw new Error(`Category not found: ${record.category.category.uri}`); 240 + } 241 + 242 + return { 243 + did: event.did, 244 + rkey: event.commit.rkey, 245 + cid: event.commit.cid, 246 + name: record.name, 247 + description: record.description ?? null, 248 + slug: record.slug ?? null, 249 + sortOrder: record.sortOrder ?? null, 250 + categoryId, 251 + categoryUri: record.category.category.uri, 252 + createdAt: new Date(record.createdAt), 253 + indexedAt: new Date(), 254 + }; 255 + }, 256 + toUpdateValues: async (event, record, tx) => { 257 + const categoryId = await this.getCategoryIdByUri( 258 + record.category.category.uri, 259 + tx 260 + ); 261 + 262 + if (!categoryId) { 263 + console.error("Failed to index board: category not found", { 264 + operation: "Board UPDATE", 265 + boardDid: event.did, 266 + boardRkey: event.commit.rkey, 267 + categoryUri: record.category.category.uri, 268 + errorId: "BOARD_CATEGORY_MISSING", 269 + }); 270 + throw new Error(`Category not found: ${record.category.category.uri}`); 271 + } 272 + 273 + return { 274 + cid: event.commit.cid, 275 + name: record.name, 276 + description: record.description ?? null, 277 + slug: record.slug ?? null, 278 + sortOrder: record.sortOrder ?? null, 279 + categoryId, 280 + categoryUri: record.category.category.uri, 281 + indexedAt: new Date(), 282 + }; 283 + }, 284 + }; 285 + 180 286 private membershipConfig: CollectionConfig<Membership.Record> = { 181 287 name: "Membership", 182 288 table: memberships, ··· 486 592 await this.genericDelete(this.categoryConfig, event); 487 593 } 488 594 595 + // ── Board Handlers ────────────────────────────────────── 596 + 597 + async handleBoardCreate(event: CommitCreateEvent<"space.atbb.forum.board">) { 598 + await this.genericCreate(this.boardConfig, event); 599 + } 600 + 601 + async handleBoardUpdate(event: CommitUpdateEvent<"space.atbb.forum.board">) { 602 + await this.genericUpdate(this.boardConfig, event); 603 + } 604 + 605 + async handleBoardDelete(event: CommitDeleteEvent<"space.atbb.forum.board">) { 606 + await this.genericDelete(this.boardConfig, event); 607 + } 608 + 489 609 // ── Membership Handlers ───────────────────────────────── 490 610 491 611 async handleMembershipCreate( ··· 629 749 .limit(1); 630 750 631 751 return result.length > 0 ? result[0].id : null; 752 + } 753 + 754 + /** 755 + * Look up board ID by AT URI (at://did/collection/rkey) 756 + * @param uri - AT URI of the board 757 + * @param dbOrTx - Database instance or transaction 758 + */ 759 + private async getBoardIdByUri( 760 + uri: string, 761 + dbOrTx: DbOrTransaction = this.db 762 + ): Promise<bigint | null> { 763 + const parsed = parseAtUri(uri); 764 + if (!parsed) return null; 765 + 766 + try { 767 + const [result] = await dbOrTx 768 + .select({ id: boards.id }) 769 + .from(boards) 770 + .where(and(eq(boards.did, parsed.did), eq(boards.rkey, parsed.rkey))) 771 + .limit(1); 772 + return result?.id ?? null; 773 + } catch (error) { 774 + console.error("Database error in getBoardIdByUri", { 775 + operation: "getBoardIdByUri", 776 + uri, 777 + did: parsed.did, 778 + rkey: parsed.rkey, 779 + error: error instanceof Error ? error.message : String(error), 780 + }); 781 + throw error; 782 + } 783 + } 784 + 785 + /** 786 + * Look up category ID by AT URI (at://did/collection/rkey) 787 + * @param uri - AT URI of the category 788 + * @param dbOrTx - Database instance or transaction 789 + */ 790 + private async getCategoryIdByUri( 791 + uri: string, 792 + dbOrTx: DbOrTransaction = this.db 793 + ): Promise<bigint | null> { 794 + const parsed = parseAtUri(uri); 795 + if (!parsed) return null; 796 + 797 + try { 798 + const [result] = await dbOrTx 799 + .select({ id: categories.id }) 800 + .from(categories) 801 + .where(and(eq(categories.did, parsed.did), eq(categories.rkey, parsed.rkey))) 802 + .limit(1); 803 + return result?.id ?? null; 804 + } catch (error) { 805 + console.error("Database error in getCategoryIdByUri", { 806 + operation: "getCategoryIdByUri", 807 + uri, 808 + did: parsed.did, 809 + rkey: parsed.rkey, 810 + error: error instanceof Error ? error.message : String(error), 811 + }); 812 + throw error; 813 + } 632 814 } 633 815 }
+324
apps/appview/src/routes/__tests__/boards.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { createBoardsRoutes } from "../boards.js"; 4 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 + import { boards, categories, forums, posts, users } from "@atbb/db"; 6 + import { eq } from "drizzle-orm"; 7 + 8 + describe("GET /api/boards", () => { 9 + let ctx: TestContext; 10 + let app: Hono; 11 + let cleanedUp = false; 12 + 13 + beforeEach(async () => { 14 + ctx = await createTestContext(); 15 + app = new Hono().route("/api/boards", createBoardsRoutes(ctx)); 16 + cleanedUp = false; 17 + 18 + // Clear existing data to ensure test isolation (FK order: posts first, then boards, then categories) 19 + await ctx.db.delete(posts); 20 + await ctx.db.delete(boards).where(eq(boards.did, ctx.config.forumDid)); 21 + await ctx.db.delete(categories).where(eq(categories.did, ctx.config.forumDid)); 22 + 23 + // Get the forum ID from the test forum 24 + const [testForum] = await ctx.db 25 + .select() 26 + .from(forums) 27 + .where(eq(forums.did, ctx.config.forumDid)) 28 + .limit(1); 29 + 30 + // Create categories 31 + const [cat1] = await ctx.db.insert(categories).values({ 32 + did: ctx.config.forumDid, 33 + rkey: "cat1", 34 + cid: "bafycat1", 35 + name: "General", 36 + forumId: testForum.id, 37 + sortOrder: 1, 38 + createdAt: new Date(), 39 + indexedAt: new Date(), 40 + }).returning(); 41 + 42 + const [cat2] = await ctx.db.insert(categories).values({ 43 + did: ctx.config.forumDid, 44 + rkey: "cat2", 45 + cid: "bafycat2", 46 + name: "Technical", 47 + forumId: testForum.id, 48 + sortOrder: 2, 49 + createdAt: new Date(), 50 + indexedAt: new Date(), 51 + }).returning(); 52 + 53 + // Create boards 54 + await ctx.db.insert(boards).values([ 55 + { 56 + did: ctx.config.forumDid, 57 + rkey: "board1", 58 + cid: "bafyboard1", 59 + name: "Announcements", 60 + sortOrder: 1, 61 + categoryId: cat1.id, 62 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 63 + createdAt: new Date(), 64 + indexedAt: new Date(), 65 + }, 66 + { 67 + did: ctx.config.forumDid, 68 + rkey: "board2", 69 + cid: "bafyboard2", 70 + name: "Off-Topic", 71 + sortOrder: 2, 72 + categoryId: cat1.id, 73 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 74 + createdAt: new Date(), 75 + indexedAt: new Date(), 76 + }, 77 + { 78 + did: ctx.config.forumDid, 79 + rkey: "board3", 80 + cid: "bafyboard3", 81 + name: "API Development", 82 + sortOrder: 1, 83 + categoryId: cat2.id, 84 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat2`, 85 + createdAt: new Date(), 86 + indexedAt: new Date(), 87 + }, 88 + ]); 89 + }); 90 + 91 + afterEach(async () => { 92 + if (!cleanedUp) { 93 + await ctx.cleanup(); 94 + } 95 + }); 96 + 97 + it("returns all boards grouped by category", async () => { 98 + const res = await app.request("/api/boards"); 99 + expect(res.status).toBe(200); 100 + 101 + const data = await res.json(); 102 + expect(data.boards).toHaveLength(3); 103 + expect(data.boards[0].name).toBe("Announcements"); 104 + expect(data.boards[1].name).toBe("Off-Topic"); 105 + expect(data.boards[2].name).toBe("API Development"); 106 + }); 107 + 108 + it("returns empty array when no boards exist", async () => { 109 + // Clear boards 110 + await ctx.db.delete(boards); 111 + 112 + const res = await app.request("/api/boards"); 113 + expect(res.status).toBe(200); 114 + 115 + const data = await res.json(); 116 + expect(data.boards).toEqual([]); 117 + }); 118 + 119 + it("returns 500 on database error", async () => { 120 + // Close the database connection to simulate a database error 121 + await ctx.cleanup(); 122 + cleanedUp = true; 123 + 124 + const res = await app.request("/api/boards"); 125 + expect(res.status).toBe(500); 126 + 127 + const data = await res.json(); 128 + expect(data.error).toBe( 129 + "Failed to retrieve boards. Please try again later." 130 + ); 131 + }); 132 + 133 + it("serializes each board with correct types", async () => { 134 + const res = await app.request("/api/boards"); 135 + const body = await res.json(); 136 + 137 + expect(body.boards.length).toBeGreaterThan(0); 138 + const board = body.boards[0]; 139 + 140 + // Verify BigInt fields are stringified 141 + expect(typeof board.id).toBe("string"); 142 + expect(board.id).toMatch(/^\d+$/); 143 + 144 + if (board.categoryId !== null) { 145 + expect(typeof board.categoryId).toBe("string"); 146 + expect(board.categoryId).toMatch(/^\d+$/); 147 + } 148 + 149 + // Verify date fields are ISO strings 150 + expect(typeof board.createdAt).toBe("string"); 151 + expect(board.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); 152 + expect(typeof board.indexedAt).toBe("string"); 153 + 154 + // Verify structure 155 + expect(board).toHaveProperty("name"); 156 + expect(board).toHaveProperty("description"); 157 + expect(board).toHaveProperty("slug"); 158 + expect(board).toHaveProperty("sortOrder"); 159 + expect(board).toHaveProperty("categoryUri"); 160 + }); 161 + 162 + it("does not leak internal fields (rkey, cid)", async () => { 163 + const res = await app.request("/api/boards"); 164 + const body = await res.json(); 165 + 166 + if (body.boards.length > 0) { 167 + const board = body.boards[0]; 168 + expect(board).not.toHaveProperty("rkey"); 169 + expect(board).not.toHaveProperty("cid"); 170 + } 171 + }); 172 + 173 + it("handles null optional fields gracefully", async () => { 174 + const res = await app.request("/api/boards"); 175 + const body = await res.json(); 176 + 177 + if (body.boards.length > 0) { 178 + const board = body.boards[0]; 179 + // Verify nullable fields are either null or the correct type 180 + expect( 181 + board.description === null || typeof board.description === "string" 182 + ).toBe(true); 183 + expect(board.slug === null || typeof board.slug === "string").toBe(true); 184 + expect( 185 + board.sortOrder === null || typeof board.sortOrder === "number" 186 + ).toBe(true); 187 + expect(board.categoryId === null || typeof board.categoryId === "string").toBe( 188 + true 189 + ); 190 + } 191 + }); 192 + }); 193 + 194 + describe("GET /api/boards/:id/topics", () => { 195 + let ctx: TestContext; 196 + let app: Hono; 197 + let cleanedUp = false; 198 + let boardId: bigint; 199 + 200 + beforeEach(async () => { 201 + ctx = await createTestContext(); 202 + app = new Hono().route("/api/boards", createBoardsRoutes(ctx)); 203 + cleanedUp = false; 204 + 205 + // Get the forum ID from the test forum 206 + const [testForum] = await ctx.db 207 + .select() 208 + .from(forums) 209 + .where(eq(forums.did, ctx.config.forumDid)) 210 + .limit(1); 211 + 212 + // Create a category 213 + const [cat] = await ctx.db.insert(categories).values({ 214 + did: ctx.config.forumDid, 215 + rkey: "cat1", 216 + cid: "bafycat1", 217 + name: "General", 218 + forumId: testForum.id, 219 + sortOrder: 1, 220 + createdAt: new Date(), 221 + indexedAt: new Date(), 222 + }).returning(); 223 + 224 + // Create a board 225 + const [board] = await ctx.db.insert(boards).values({ 226 + did: ctx.config.forumDid, 227 + rkey: "board1", 228 + cid: "bafyboard1", 229 + name: "Test Board", 230 + sortOrder: 1, 231 + categoryId: cat.id, 232 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 233 + createdAt: new Date(), 234 + indexedAt: new Date(), 235 + }).returning(); 236 + 237 + boardId = board.id; 238 + 239 + // Create user - use unique DID to avoid collision with other tests 240 + await ctx.db.insert(users).values({ 241 + did: "did:plc:topicsuser", 242 + handle: "topicsuser.test", 243 + indexedAt: new Date(), 244 + }); 245 + 246 + // Create posts (topics) in the board 247 + await ctx.db.insert(posts).values([ 248 + { 249 + did: "did:plc:topicsuser", 250 + rkey: "post1", 251 + cid: "bafypost1", 252 + text: "First topic", 253 + boardId: boardId, 254 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 255 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 256 + createdAt: new Date("2026-02-13T10:00:00Z"), 257 + indexedAt: new Date(), 258 + }, 259 + { 260 + did: "did:plc:topicsuser", 261 + rkey: "post2", 262 + cid: "bafypost2", 263 + text: "Second topic", 264 + boardId: boardId, 265 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 266 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 267 + createdAt: new Date("2026-02-13T11:00:00Z"), 268 + indexedAt: new Date(), 269 + }, 270 + ]); 271 + }); 272 + 273 + afterEach(async () => { 274 + if (!cleanedUp) { 275 + await ctx.cleanup(); 276 + } 277 + }); 278 + 279 + it("returns topics for board", async () => { 280 + const res = await app.request(`/api/boards/${boardId}/topics`); 281 + expect(res.status).toBe(200); 282 + 283 + const data = await res.json(); 284 + expect(data.topics).toHaveLength(2); 285 + expect(data.topics[0].text).toBe("Second topic"); // Newest first 286 + expect(data.topics[1].text).toBe("First topic"); 287 + }); 288 + 289 + it("returns 404 for non-existent board", async () => { 290 + const res = await app.request(`/api/boards/999999/topics`); 291 + expect(res.status).toBe(404); 292 + 293 + const data = await res.json(); 294 + expect(data.error).toBe("Board not found"); 295 + }); 296 + 297 + it("returns 400 for invalid board ID", async () => { 298 + const res = await app.request("/api/boards/invalid/topics"); 299 + expect(res.status).toBe(400); 300 + 301 + const data = await res.json(); 302 + expect(data.error).toBe("Invalid board ID format"); 303 + }); 304 + 305 + it("filters out deleted posts", async () => { 306 + await ctx.db.insert(posts).values({ 307 + did: "did:plc:topicsuser", 308 + rkey: "post3", 309 + cid: "bafypost3", 310 + text: "Deleted topic", 311 + boardId: boardId, 312 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 313 + deleted: true, 314 + createdAt: new Date("2026-02-13T12:00:00Z"), 315 + indexedAt: new Date(), 316 + }); 317 + 318 + const res = await app.request(`/api/boards/${boardId}/topics`); 319 + expect(res.status).toBe(200); 320 + 321 + const data = await res.json(); 322 + expect(data.topics).toHaveLength(2); // Still 2, not 3 323 + }); 324 + });
+110
apps/appview/src/routes/__tests__/categories.test.ts
··· 117 117 // Note: GET //:id/topics endpoint removed 118 118 // Reason: posts table has no categoryUri field for filtering 119 119 // Tests removed - endpoint will be re-added when schema supports category-to-post association 120 + 121 + describe("GET /:id/boards", () => { 122 + let ctx: TestContext; 123 + let app: Hono; 124 + 125 + beforeEach(async () => { 126 + ctx = await createTestContext(); 127 + app = new Hono().route("/", createCategoriesRoutes(ctx)); 128 + }); 129 + 130 + afterEach(async () => { 131 + await ctx.cleanup(); 132 + }); 133 + 134 + it("returns boards for category", async () => { 135 + // Get the forum ID from the test forum 136 + const [testForum] = await ctx.db 137 + .select() 138 + .from(forums) 139 + .where(eq(forums.did, ctx.config.forumDid)) 140 + .limit(1); 141 + 142 + // Insert a test category 143 + const [cat1] = await ctx.db 144 + .insert(categories) 145 + .values({ 146 + did: ctx.config.forumDid, 147 + rkey: "cat1", 148 + cid: "bafycat1", 149 + name: "General", 150 + forumId: testForum.id, 151 + sortOrder: 1, 152 + createdAt: new Date(), 153 + indexedAt: new Date(), 154 + }) 155 + .returning(); 156 + 157 + // Insert boards for this category 158 + const { boards } = await import("@atbb/db"); 159 + await ctx.db.insert(boards).values([ 160 + { 161 + did: ctx.config.forumDid, 162 + rkey: "board1", 163 + cid: "bafyboard1", 164 + name: "Announcements", 165 + sortOrder: 1, 166 + categoryId: cat1.id, 167 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 168 + createdAt: new Date(), 169 + indexedAt: new Date(), 170 + }, 171 + { 172 + did: ctx.config.forumDid, 173 + rkey: "board2", 174 + cid: "bafyboard2", 175 + name: "Off-Topic", 176 + sortOrder: 2, 177 + categoryId: cat1.id, 178 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 179 + createdAt: new Date(), 180 + indexedAt: new Date(), 181 + }, 182 + ]); 183 + 184 + const res = await app.request(`/${cat1.id}/boards`); 185 + expect(res.status).toBe(200); 186 + 187 + const data = await res.json(); 188 + expect(data.boards).toHaveLength(2); 189 + expect(data.boards[0].name).toBe("Announcements"); 190 + expect(data.boards[1].name).toBe("Off-Topic"); 191 + }); 192 + 193 + it("returns empty array for category with no boards", async () => { 194 + // Get the forum ID from the test forum 195 + const [testForum] = await ctx.db 196 + .select() 197 + .from(forums) 198 + .where(eq(forums.did, ctx.config.forumDid)) 199 + .limit(1); 200 + 201 + // Insert a category with no boards 202 + const [cat2] = await ctx.db 203 + .insert(categories) 204 + .values({ 205 + did: ctx.config.forumDid, 206 + rkey: "cat2", 207 + cid: "bafycat2", 208 + name: "Empty Category", 209 + forumId: testForum.id, 210 + createdAt: new Date(), 211 + indexedAt: new Date(), 212 + }) 213 + .returning(); 214 + 215 + const res = await app.request(`/${cat2.id}/boards`); 216 + expect(res.status).toBe(200); 217 + 218 + const data = await res.json(); 219 + expect(data.boards).toEqual([]); 220 + }); 221 + 222 + it("returns 400 for invalid category ID", async () => { 223 + const res = await app.request("/invalid/boards"); 224 + expect(res.status).toBe(400); 225 + 226 + const data = await res.json(); 227 + expect(data.error).toBe("Invalid category ID format"); 228 + }); 229 + });
+110
apps/appview/src/routes/__tests__/helpers-boards.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 + import { getBoardByUri, serializeBoard, type BoardRow } from "../helpers.js"; 4 + import { boards } from "@atbb/db"; 5 + 6 + describe("getBoardByUri", () => { 7 + let ctx: TestContext; 8 + 9 + beforeEach(async () => { 10 + ctx = await createTestContext(); 11 + }); 12 + 13 + afterEach(async () => { 14 + await ctx.cleanup(); 15 + }); 16 + 17 + it("returns CID for valid board URI", async () => { 18 + // Insert a test board 19 + await ctx.db.insert(boards).values({ 20 + did: ctx.config.forumDid, 21 + rkey: "3lbk9board", 22 + cid: "bafyboard", 23 + name: "General", 24 + description: "General discussion board", 25 + slug: "general", 26 + sortOrder: 1, 27 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/3lbk8cat`, 28 + createdAt: new Date(), 29 + indexedAt: new Date(), 30 + }).onConflictDoNothing(); 31 + 32 + const boardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/3lbk9board`; 33 + 34 + const result = await getBoardByUri(ctx.db, boardUri); 35 + 36 + expect(result).not.toBeNull(); 37 + expect(result?.cid).toBe("bafyboard"); 38 + }); 39 + 40 + it("returns null for non-existent board", async () => { 41 + const nonExistentUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/nonexistent`; 42 + 43 + const result = await getBoardByUri(ctx.db, nonExistentUri); 44 + 45 + expect(result).toBeNull(); 46 + }); 47 + }); 48 + 49 + describe("serializeBoard", () => { 50 + const baseDate = new Date("2025-01-15T12:00:00.000Z"); 51 + 52 + const makeBoard = (overrides?: Partial<BoardRow>): BoardRow => ({ 53 + id: 1n, 54 + did: "did:plc:forum", 55 + rkey: "3lbk9board", 56 + cid: "bafyboard", 57 + name: "General Discussion", 58 + description: "A place for general conversation", 59 + slug: "general", 60 + sortOrder: 1, 61 + categoryId: 10n, 62 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/3lbk8cat", 63 + createdAt: baseDate, 64 + indexedAt: baseDate, 65 + ...overrides, 66 + }); 67 + 68 + it("serializes board with all fields", () => { 69 + const board = makeBoard(); 70 + 71 + const result = serializeBoard(board); 72 + 73 + expect(result).toEqual({ 74 + id: "1", 75 + did: "did:plc:forum", 76 + name: "General Discussion", 77 + description: "A place for general conversation", 78 + slug: "general", 79 + sortOrder: 1, 80 + categoryId: "10", 81 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/3lbk8cat", 82 + createdAt: "2025-01-15T12:00:00.000Z", 83 + indexedAt: "2025-01-15T12:00:00.000Z", 84 + }); 85 + }); 86 + 87 + it("serializes board with null optional fields", () => { 88 + const board = makeBoard({ 89 + description: null, 90 + slug: null, 91 + sortOrder: null, 92 + categoryId: null, 93 + }); 94 + 95 + const result = serializeBoard(board); 96 + 97 + expect(result).toEqual({ 98 + id: "1", 99 + did: "did:plc:forum", 100 + name: "General Discussion", 101 + description: null, 102 + slug: null, 103 + sortOrder: null, 104 + categoryId: null, 105 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/3lbk8cat", 106 + createdAt: "2025-01-15T12:00:00.000Z", 107 + indexedAt: "2025-01-15T12:00:00.000Z", 108 + }); 109 + }); 110 + });
+8
apps/appview/src/routes/__tests__/helpers.test.ts
··· 264 264 cid: "bafytopic", 265 265 text: "Hello, forum!", 266 266 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 267 + boardUri: null, 268 + boardId: null, 267 269 rootPostId: null, 268 270 parentPostId: null, 269 271 rootUri: null, ··· 281 283 cid: "bafyreply", 282 284 text: "Great topic!", 283 285 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 286 + boardUri: null, 287 + boardId: null, 284 288 rootPostId: 1n, 285 289 parentPostId: 1n, 286 290 rootUri: "at://did:plc:topic-author/space.atbb.post/3lbk7topic", ··· 310 314 rkey: "3lbk7topic", 311 315 text: "Hello, forum!", 312 316 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 317 + boardUri: null, 318 + boardId: null, 313 319 parentPostId: null, 314 320 createdAt: "2025-01-15T12:00:00.000Z", 315 321 author: { ··· 331 337 rkey: "3lbk8reply", 332 338 text: "Great topic!", 333 339 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 340 + boardUri: null, 341 + boardId: null, 334 342 parentPostId: "1", 335 343 createdAt: "2025-01-15T12:00:00.000Z", 336 344 author: {
+219 -25
apps/appview/src/routes/__tests__/topics.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import type { Variables } from "../../types.js"; 4 4 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 + import { forums, categories, boards } from "@atbb/db"; 6 + import { eq } from "drizzle-orm"; 5 7 6 8 // Mock requireAuth at the module level 7 9 let mockPutRecord: ReturnType<typeof vi.fn>; ··· 49 51 describe("POST /api/topics", () => { 50 52 let ctx: TestContext; 51 53 let app: Hono<{ Variables: Variables }>; 54 + let testBoardUri: string; 52 55 53 56 beforeEach(async () => { 54 57 ctx = await createTestContext(); 55 58 59 + // Create test board for all tests 60 + const [forum] = await ctx.db 61 + .select() 62 + .from(forums) 63 + .where(eq(forums.rkey, "self")) 64 + .limit(1); 65 + 66 + // Insert or get existing category 67 + let category = (await ctx.db.insert(categories).values({ 68 + did: ctx.config.forumDid, 69 + rkey: "test-cat", 70 + cid: "bafycat", 71 + name: "Test Category", 72 + forumId: forum.id, 73 + createdAt: new Date(), 74 + indexedAt: new Date(), 75 + }).onConflictDoNothing().returning())[0]; 76 + 77 + // If conflict, fetch existing category 78 + if (!category) { 79 + [category] = await ctx.db 80 + .select() 81 + .from(categories) 82 + .where(eq(categories.rkey, "test-cat")) 83 + .limit(1); 84 + } 85 + 86 + // Insert or skip existing board 87 + await ctx.db.insert(boards).values({ 88 + did: ctx.config.forumDid, 89 + rkey: "test-board", 90 + cid: "bafyboard", 91 + name: "Test Board", 92 + categoryId: category.id, 93 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 94 + createdAt: new Date(), 95 + indexedAt: new Date(), 96 + }).onConflictDoNothing(); 97 + 98 + testBoardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/test-board`; 99 + 56 100 // Mock putRecord to track calls 57 101 mockPutRecord = vi.fn(async () => ({ 58 102 data: { ··· 89 133 const res = await app.request("/api/topics", { 90 134 method: "POST", 91 135 headers: { "Content-Type": "application/json" }, 92 - body: JSON.stringify({ text: "Hello, atBB!" }), 136 + body: JSON.stringify({ text: "Hello, atBB!", boardUri: testBoardUri }), 93 137 }); 94 138 95 139 expect(res.status).toBe(201); ··· 103 147 const res = await app.request("/api/topics", { 104 148 method: "POST", 105 149 headers: { "Content-Type": "application/json" }, 106 - body: JSON.stringify({ text: " " }), 150 + body: JSON.stringify({ text: " ", boardUri: testBoardUri }), 107 151 }); 108 152 109 153 expect(res.status).toBe(400); ··· 115 159 const res = await app.request("/api/topics", { 116 160 method: "POST", 117 161 headers: { "Content-Type": "application/json" }, 118 - body: JSON.stringify({ text: "a".repeat(301) }), 162 + body: JSON.stringify({ text: "a".repeat(301), boardUri: testBoardUri }), 119 163 }); 120 164 121 165 expect(res.status).toBe(400); ··· 127 171 const res = await app.request("/api/topics", { 128 172 method: "POST", 129 173 headers: { "Content-Type": "application/json" }, 130 - body: JSON.stringify({ text: "Test topic" }), 174 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 131 175 }); 132 176 133 177 expect(res.status).toBe(201); ··· 145 189 ); 146 190 }); 147 191 148 - it("returns 404 when custom forum does not exist", async () => { 149 - const res = await app.request("/api/topics", { 150 - method: "POST", 151 - headers: { "Content-Type": "application/json" }, 152 - body: JSON.stringify({ 153 - text: "Test", 154 - forumUri: "at://did:plc:nonexistent/space.atbb.forum.forum/self", 155 - }), 156 - }); 157 - 158 - expect(res.status).toBe(404); 159 - const data = await res.json(); 160 - expect(data.error).toContain("Forum not found"); 161 - }); 162 - 163 192 // Critical Issue #1: Test type guard for validatePostText 164 193 it("returns 400 when text is missing", async () => { 165 194 const res = await app.request("/api/topics", { ··· 217 246 const res = await app.request("/api/topics", { 218 247 method: "POST", 219 248 headers: { "Content-Type": "application/json" }, 220 - body: JSON.stringify({ text: "Test topic" }), 249 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 221 250 }); 222 251 223 252 expect(res.status).toBe(503); ··· 231 260 const res = await app.request("/api/topics", { 232 261 method: "POST", 233 262 headers: { "Content-Type": "application/json" }, 234 - body: JSON.stringify({ text: "Test topic" }), 263 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 235 264 }); 236 265 237 266 expect(res.status).toBe(503); ··· 245 274 const res = await app.request("/api/topics", { 246 275 method: "POST", 247 276 headers: { "Content-Type": "application/json" }, 248 - body: JSON.stringify({ text: "Test topic" }), 277 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 249 278 }); 250 279 251 280 expect(res.status).toBe(503); ··· 260 289 const res = await app.request("/api/topics", { 261 290 method: "POST", 262 291 headers: { "Content-Type": "application/json" }, 263 - body: JSON.stringify({ text: "Test topic" }), 292 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 264 293 }); 265 294 266 295 expect(res.status).toBe(500); ··· 268 297 expect(data.error).toContain("Failed to create topic"); 269 298 }); 270 299 271 - it("returns 500 for unexpected database errors", async () => { 300 + it("returns 503 for database connection errors", async () => { 272 301 mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 273 302 274 303 const res = await app.request("/api/topics", { 275 304 method: "POST", 276 305 headers: { "Content-Type": "application/json" }, 277 - body: JSON.stringify({ text: "Test topic" }), 306 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 307 + }); 308 + 309 + expect(res.status).toBe(503); 310 + const data = await res.json(); 311 + expect(data.error).toContain("Database temporarily unavailable"); 312 + }); 313 + 314 + it("returns 500 for unexpected non-network/non-database errors", async () => { 315 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected validation failure")); 316 + 317 + const res = await app.request("/api/topics", { 318 + method: "POST", 319 + headers: { "Content-Type": "application/json" }, 320 + body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 278 321 }); 279 322 280 323 expect(res.status).toBe(500); 281 324 const data = await res.json(); 282 325 expect(data.error).toContain("Failed to create topic"); 326 + expect(data.error).toContain("report this issue"); 327 + }); 328 + 329 + it("POST /api/topics creates topic with board reference", async () => { 330 + // Get the forum that was created in test context 331 + const [forum] = await ctx.db 332 + .select() 333 + .from(forums) 334 + .where(eq(forums.rkey, "self")) 335 + .limit(1); 336 + 337 + // Setup: create category 338 + const [category] = await ctx.db.insert(categories).values({ 339 + did: ctx.config.forumDid, 340 + rkey: "cat1", 341 + cid: "bafycat", 342 + name: "Category", 343 + forumId: forum.id, 344 + createdAt: new Date(), 345 + indexedAt: new Date(), 346 + }).returning(); 347 + 348 + await ctx.db.insert(boards).values({ 349 + did: ctx.config.forumDid, 350 + rkey: "board1", 351 + cid: "bafyboard", 352 + name: "General", 353 + categoryId: category.id, 354 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 355 + createdAt: new Date(), 356 + indexedAt: new Date(), 357 + }); 358 + 359 + const boardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`; 360 + 361 + const res = await app.request("/api/topics", { 362 + method: "POST", 363 + headers: { 364 + "Content-Type": "application/json", 365 + }, 366 + body: JSON.stringify({ 367 + text: "Test topic with board", 368 + boardUri, 369 + }), 370 + }); 371 + 372 + expect(res.status).toBe(201); 373 + const data = await res.json(); 374 + expect(data.uri).toBeDefined(); 375 + expect(data.cid).toBeDefined(); 376 + 377 + // Verify putRecord was called with board reference 378 + const calls = mockPutRecord.mock.calls; 379 + expect(calls[calls.length - 1][0].record).toMatchObject({ 380 + text: "Test topic with board", 381 + forum: { 382 + forum: { 383 + uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 384 + }, 385 + }, 386 + board: { 387 + board: { 388 + uri: boardUri, 389 + cid: "bafyboard", 390 + }, 391 + }, 392 + }); 393 + }); 394 + 395 + it("POST /api/topics returns 400 when boardUri missing", async () => { 396 + const res = await app.request("/api/topics", { 397 + method: "POST", 398 + headers: { 399 + "Content-Type": "application/json", 400 + }, 401 + body: JSON.stringify({ 402 + text: "Topic without board", 403 + }), 404 + }); 405 + 406 + expect(res.status).toBe(400); 407 + const data = await res.json(); 408 + expect(data.error).toBe("boardUri is required"); 409 + }); 410 + 411 + it("POST /api/topics returns 404 when board not found", async () => { 412 + const res = await app.request("/api/topics", { 413 + method: "POST", 414 + headers: { 415 + "Content-Type": "application/json", 416 + }, 417 + body: JSON.stringify({ 418 + text: "Topic with invalid board", 419 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/nonexistent`, 420 + }), 421 + }); 422 + 423 + expect(res.status).toBe(404); 424 + const data = await res.json(); 425 + expect(data.error).toBe("Board not found"); 426 + }); 427 + 428 + it("POST /api/topics returns 400 for malformed boardUri", async () => { 429 + const res = await app.request("/api/topics", { 430 + method: "POST", 431 + headers: { 432 + "Content-Type": "application/json", 433 + }, 434 + body: JSON.stringify({ 435 + text: "Test topic", 436 + boardUri: "not-a-valid-uri", 437 + }), 438 + }); 439 + 440 + expect(res.status).toBe(400); 441 + const data = await res.json(); 442 + expect(data.error).toBe("Invalid boardUri format"); 443 + }); 444 + 445 + it("POST /api/topics returns 400 when boardUri points to wrong collection type", async () => { 446 + const res = await app.request("/api/topics", { 447 + method: "POST", 448 + headers: { 449 + "Content-Type": "application/json", 450 + }, 451 + body: JSON.stringify({ 452 + text: "Test topic", 453 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 454 + }), 455 + }); 456 + 457 + expect(res.status).toBe(400); 458 + const data = await res.json(); 459 + expect(data.error).toBe("boardUri must reference a board"); 460 + }); 461 + 462 + it("POST /api/topics returns 400 when boardUri belongs to different forum", async () => { 463 + const res = await app.request("/api/topics", { 464 + method: "POST", 465 + headers: { 466 + "Content-Type": "application/json", 467 + }, 468 + body: JSON.stringify({ 469 + text: "Test topic", 470 + boardUri: "at://did:plc:different-forum/space.atbb.forum.board/some-board", 471 + }), 472 + }); 473 + 474 + expect(res.status).toBe(400); 475 + const data = await res.json(); 476 + expect(data.error).toBe("boardUri must belong to this forum"); 283 477 }); 284 478 });
+95
apps/appview/src/routes/boards.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import { boards, categories, posts, users } from "@atbb/db"; 4 + import { asc, eq, and, desc, isNull } from "drizzle-orm"; 5 + import { serializeBoard, parseBigIntParam, serializePost } from "./helpers.js"; 6 + 7 + /** 8 + * Factory function that creates board routes with access to app context. 9 + */ 10 + export function createBoardsRoutes(ctx: AppContext) { 11 + return new Hono() 12 + .get("/", async (c) => { 13 + try { 14 + const allBoards = await ctx.db 15 + .select() 16 + .from(boards) 17 + .leftJoin(categories, eq(boards.categoryId, categories.id)) 18 + .orderBy(asc(categories.sortOrder), asc(boards.sortOrder)) 19 + .limit(1000); // Defensive limit 20 + 21 + return c.json({ 22 + boards: allBoards.map(({ boards: board }) => serializeBoard(board)), 23 + }); 24 + } catch (error) { 25 + console.error("Failed to query boards", { 26 + operation: "GET /api/boards", 27 + error: error instanceof Error ? error.message : String(error), 28 + }); 29 + 30 + return c.json( 31 + { 32 + error: "Failed to retrieve boards. Please try again later.", 33 + }, 34 + 500 35 + ); 36 + } 37 + }) 38 + .get("/:id/topics", async (c) => { 39 + const { id } = c.req.param(); 40 + 41 + const boardId = parseBigIntParam(id); 42 + if (boardId === null) { 43 + return c.json({ error: "Invalid board ID format" }, 400); 44 + } 45 + 46 + try { 47 + // Check if board exists 48 + const [board] = await ctx.db 49 + .select() 50 + .from(boards) 51 + .where(eq(boards.id, boardId)) 52 + .limit(1); 53 + 54 + if (!board) { 55 + return c.json({ error: "Board not found" }, 404); 56 + } 57 + 58 + const topicResults = await ctx.db 59 + .select({ 60 + post: posts, 61 + author: users, 62 + }) 63 + .from(posts) 64 + .leftJoin(users, eq(posts.did, users.did)) 65 + .where( 66 + and( 67 + eq(posts.boardId, boardId), 68 + isNull(posts.rootPostId), // Topics only (not replies) 69 + eq(posts.deleted, false) 70 + ) 71 + ) 72 + .orderBy(desc(posts.createdAt)) 73 + .limit(1000); // Defensive limit 74 + 75 + return c.json({ 76 + topics: topicResults.map(({ post, author }) => 77 + serializePost(post, author) 78 + ), 79 + }); 80 + } catch (error) { 81 + console.error("Failed to query board topics", { 82 + operation: "GET /api/boards/:id/topics", 83 + boardId: id, 84 + error: error instanceof Error ? error.message : String(error), 85 + }); 86 + 87 + return c.json( 88 + { 89 + error: "Failed to retrieve topics. Please try again later.", 90 + }, 91 + 500 92 + ); 93 + } 94 + }); 95 + }
+72 -25
apps/appview/src/routes/categories.ts
··· 1 1 import { Hono } from "hono"; 2 2 import type { AppContext } from "../lib/app-context.js"; 3 - import { categories } from "@atbb/db"; 4 - import { serializeCategory } from "./helpers.js"; 3 + import { categories, boards } from "@atbb/db"; 4 + import { eq, asc } from "drizzle-orm"; 5 + import { serializeCategory, serializeBoard, parseBigIntParam } from "./helpers.js"; 5 6 6 7 /** 7 8 * Factory function that creates category routes with access to app context. ··· 13 14 * TODO: Add categoryUri field to posts schema + update indexer (ATB-12 or later) 14 15 */ 15 16 export function createCategoriesRoutes(ctx: AppContext) { 16 - return new Hono().get("/", async (c) => { 17 - try { 18 - const allCategories = await ctx.db 19 - .select() 20 - .from(categories) 21 - .orderBy(categories.sortOrder) 22 - .limit(1000); // Defensive limit 17 + return new Hono() 18 + .get("/", async (c) => { 19 + try { 20 + const allCategories = await ctx.db 21 + .select() 22 + .from(categories) 23 + .orderBy(categories.sortOrder) 24 + .limit(1000); // Defensive limit 25 + 26 + return c.json({ 27 + categories: allCategories.map(serializeCategory), 28 + }); 29 + } catch (error) { 30 + console.error("Failed to query categories", { 31 + operation: "GET /api/categories", 32 + error: error instanceof Error ? error.message : String(error), 33 + }); 34 + 35 + return c.json( 36 + { 37 + error: "Failed to retrieve categories. Please try again later.", 38 + }, 39 + 500 40 + ); 41 + } 42 + }) 43 + .get("/:id/boards", async (c) => { 44 + const { id } = c.req.param(); 45 + 46 + const categoryId = parseBigIntParam(id); 47 + if (categoryId === null) { 48 + return c.json({ error: "Invalid category ID format" }, 400); 49 + } 50 + 51 + try { 52 + // Check if category exists 53 + const [category] = await ctx.db 54 + .select() 55 + .from(categories) 56 + .where(eq(categories.id, categoryId)) 57 + .limit(1); 58 + 59 + if (!category) { 60 + return c.json({ error: "Category not found" }, 404); 61 + } 23 62 24 - return c.json({ 25 - categories: allCategories.map(serializeCategory), 26 - }); 27 - } catch (error) { 28 - console.error("Failed to query categories", { 29 - operation: "GET /api/categories", 30 - error: error instanceof Error ? error.message : String(error), 31 - }); 63 + const categoryBoards = await ctx.db 64 + .select() 65 + .from(boards) 66 + .where(eq(boards.categoryId, categoryId)) 67 + .orderBy(asc(boards.sortOrder)) 68 + .limit(1000); // Defensive limit 32 69 33 - return c.json( 34 - { 35 - error: "Failed to retrieve categories. Please try again later.", 36 - }, 37 - 500 38 - ); 39 - } 40 - }); 70 + return c.json({ 71 + boards: categoryBoards.map(serializeBoard), 72 + }); 73 + } catch (error) { 74 + console.error("Failed to query category boards", { 75 + operation: "GET /api/categories/:id/boards", 76 + categoryId: id, 77 + error: error instanceof Error ? error.message : String(error), 78 + }); 79 + 80 + return c.json( 81 + { 82 + error: "Failed to retrieve boards. Please try again later.", 83 + }, 84 + 500 85 + ); 86 + } 87 + }); 41 88 }
+106 -36
apps/appview/src/routes/helpers.ts
··· 1 - import { users, forums, posts, categories } from "@atbb/db"; 1 + import { users, forums, posts, categories, boards } 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"; ··· 110 110 111 111 const { did, rkey } = parsed; 112 112 113 - try { 114 - const [forum] = await db 115 - .select({ 116 - did: forums.did, 117 - rkey: forums.rkey, 118 - cid: forums.cid, 119 - }) 120 - .from(forums) 121 - .where(and(eq(forums.did, did), eq(forums.rkey, rkey))) 122 - .limit(1); 113 + const [forum] = await db 114 + .select({ 115 + did: forums.did, 116 + rkey: forums.rkey, 117 + cid: forums.cid, 118 + }) 119 + .from(forums) 120 + .where(and(eq(forums.did, did), eq(forums.rkey, rkey))) 121 + .limit(1); 123 122 124 - return forum ?? null; 125 - } catch (error) { 126 - console.error("Failed to query forum by URI", { 127 - operation: "getForumByUri", 128 - uri, 129 - did, 130 - rkey, 131 - error: error instanceof Error ? error.message : String(error), 132 - }); 133 - throw error; 134 - } 123 + return forum ?? null; 135 124 } 136 125 137 126 /** ··· 153 142 ); 154 143 } 155 144 145 + /** 146 + * Check if an Error represents a database connection failure 147 + * (connection refused, timeout, pool exhausted, network errors). 148 + * These errors indicate temporary unavailability - user should retry. 149 + */ 150 + export function isDatabaseError(error: Error): boolean { 151 + const msg = error.message.toLowerCase(); 152 + return [ 153 + "connection", 154 + "econnrefused", 155 + "timeout", 156 + "pool", 157 + "postgres", 158 + "database", 159 + ].some((k) => msg.includes(k)); 160 + } 161 + 156 162 export type PostRow = typeof posts.$inferSelect; 157 163 158 164 /** ··· 178 184 return new Map(); 179 185 } 180 186 181 - try { 182 - const results = await db 183 - .select() 184 - .from(posts) 185 - .where(and(inArray(posts.id, ids), eq(posts.deleted, false))); 187 + const results = await db 188 + .select() 189 + .from(posts) 190 + .where(and(inArray(posts.id, ids), eq(posts.deleted, false))); 186 191 187 - return new Map(results.map((post) => [post.id, post])); 188 - } catch (error) { 189 - console.error("Failed to query posts by IDs", { 190 - operation: "getPostsByIds", 191 - ids: ids.map(String), 192 - error: error instanceof Error ? error.message : String(error), 193 - }); 194 - throw error; 195 - } 192 + return new Map(results.map((post) => [post.id, post])); 196 193 } 197 194 198 195 /** ··· 241 238 * "rkey": "3lbk7...", 242 239 * "text": "Post content", 243 240 * "forumUri": "at://..." | null, 241 + * "boardUri": "at://..." | null, 242 + * "boardId": "456" | null, // BigInt → string 244 243 * "parentPostId": "123" | null, // BigInt → string 245 244 * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 246 245 * "author": { "did": "did:plc:...", "handle": "user.test" } | null ··· 254 253 rkey: post.rkey, 255 254 text: post.text, 256 255 forumUri: post.forumUri ?? null, 256 + boardUri: post.boardUri ?? null, 257 + boardId: serializeBigInt(post.boardId), 257 258 parentPostId: serializeBigInt(post.parentPostId), 258 259 createdAt: serializeDate(post.createdAt), 259 260 author: serializeAuthor(author), ··· 317 318 indexedAt: serializeDate(forum.indexedAt), 318 319 }; 319 320 } 321 + 322 + /** 323 + * Type helper for board rows from database queries 324 + */ 325 + export type BoardRow = typeof boards.$inferSelect; 326 + 327 + /** 328 + * Look up board by AT-URI. 329 + * Returns null if board doesn't exist. 330 + * 331 + * @param db Database instance 332 + * @param uri AT-URI like "at://did:plc:abc/space.atbb.forum.board/3lbk9board" 333 + */ 334 + export async function getBoardByUri( 335 + db: Database, 336 + uri: string 337 + ): Promise<{ cid: string } | null> { 338 + const parsed = parseAtUri(uri); 339 + if (!parsed) { 340 + return null; 341 + } 342 + 343 + const { did, rkey } = parsed; 344 + 345 + const [board] = await db 346 + .select({ 347 + cid: boards.cid, 348 + }) 349 + .from(boards) 350 + .where(and(eq(boards.did, did), eq(boards.rkey, rkey))) 351 + .limit(1); 352 + 353 + return board ?? null; 354 + } 355 + 356 + /** 357 + * Serialize a board row for API responses. 358 + * Produces the JSON shape used in GET /api/boards. 359 + * 360 + * @returns Response shape: 361 + * ```json 362 + * { 363 + * "id": "1234", // BigInt → string 364 + * "did": "did:plc:...", 365 + * "name": "General Discussion", 366 + * "description": "A place for..." | null, 367 + * "slug": "general" | null, 368 + * "sortOrder": 1 | null, 369 + * "categoryId": "10" | null, // BigInt → string 370 + * "categoryUri": "at://..." , 371 + * "createdAt": "2025-01-15T12:00:00.000Z", // ISO 8601 372 + * "indexedAt": "2025-01-15T12:00:00.000Z" // ISO 8601 373 + * } 374 + * ``` 375 + */ 376 + export function serializeBoard(board: BoardRow) { 377 + return { 378 + id: serializeBigInt(board.id), 379 + did: board.did, 380 + name: board.name, 381 + description: board.description, 382 + slug: board.slug, 383 + sortOrder: board.sortOrder, 384 + categoryId: serializeBigInt(board.categoryId), 385 + categoryUri: board.categoryUri, 386 + createdAt: serializeDate(board.createdAt), 387 + indexedAt: serializeDate(board.indexedAt), 388 + }; 389 + }
+7
apps/appview/src/routes/index.ts
··· 3 3 import { healthRoutes, createHealthRoutes } from "./health.js"; 4 4 import { createForumRoutes } from "./forum.js"; 5 5 import { createCategoriesRoutes } from "./categories.js"; 6 + import { createBoardsRoutes } from "./boards.js"; 6 7 import { createTopicsRoutes } from "./topics.js"; 7 8 import { createPostsRoutes } from "./posts.js"; 8 9 import { createAuthRoutes } from "./auth.js"; ··· 17 18 .route("/auth", createAuthRoutes(ctx)) 18 19 .route("/forum", createForumRoutes(ctx)) 19 20 .route("/categories", createCategoriesRoutes(ctx)) 21 + .route("/boards", createBoardsRoutes(ctx)) 20 22 .route("/topics", createTopicsRoutes(ctx)) 21 23 .route("/posts", createPostsRoutes(ctx)); 22 24 } ··· 34 36 c.json({ categories: [] }) 35 37 ); 36 38 39 + const stubBoardsRoutes = new Hono().get("/", (c) => 40 + c.json({ boards: [] }) 41 + ); 42 + 37 43 const stubTopicsRoutes = new Hono() 38 44 .get("/:id", (c) => { 39 45 const { id } = c.req.param(); ··· 49 55 .route("/healthz", healthRoutes) 50 56 .route("/forum", stubForumRoutes) 51 57 .route("/categories", stubCategoriesRoutes) 58 + .route("/boards", stubBoardsRoutes) 52 59 .route("/topics", stubTopicsRoutes) 53 60 .route("/posts", stubPostsRoutes);
+48 -6
apps/appview/src/routes/topics.ts
··· 4 4 import { eq, and, asc } from "drizzle-orm"; 5 5 import { TID } from "@atproto/common-web"; 6 6 import { requireAuth } from "../middleware/auth.js"; 7 + import { parseAtUri } from "../lib/at-uri.js"; 7 8 import { 8 9 parseBigIntParam, 9 10 serializePost, 10 11 validatePostText, 11 12 getForumByUri, 13 + getBoardByUri, 12 14 isProgrammingError, 13 15 isNetworkError, 16 + isDatabaseError, 14 17 } from "./helpers.js"; 15 18 16 19 /** ··· 91 94 return c.json({ error: "Invalid JSON in request body" }, 400); 92 95 } 93 96 94 - const { text, forumUri: customForumUri } = body; 97 + const { text, boardUri } = body; 95 98 96 99 // Validate text 97 100 const validation = validatePostText(text); ··· 99 102 return c.json({ error: validation.error }, 400); 100 103 } 101 104 105 + // Validate boardUri is required 106 + if (typeof boardUri !== "string" || !boardUri.trim()) { 107 + return c.json({ error: "boardUri is required" }, 400); 108 + } 109 + 110 + // Validate boardUri format 111 + const parsedBoardUri = parseAtUri(boardUri); 112 + if (!parsedBoardUri) { 113 + return c.json({ error: "Invalid boardUri format" }, 400); 114 + } 115 + 116 + // Validate collection type 117 + if (!parsedBoardUri.collection.startsWith("space.atbb.forum.board")) { 118 + return c.json({ error: "boardUri must reference a board" }, 400); 119 + } 120 + 121 + // Validate ownership 122 + if (parsedBoardUri.did !== ctx.config.forumDid) { 123 + return c.json({ error: "boardUri must belong to this forum" }, 400); 124 + } 125 + 102 126 try { 103 - // Resolve forum URI (default to singleton forum) 104 - const forumUri = 105 - customForumUri ?? 106 - `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 127 + // Always use the configured singleton forum 128 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 107 129 108 130 // Look up forum to get CID 109 131 const forum = await getForumByUri(ctx.db, forumUri); ··· 111 133 return c.json({ error: "Forum not found" }, 404); 112 134 } 113 135 136 + // Look up board to get CID 137 + const board = await getBoardByUri(ctx.db, boardUri); 138 + if (!board) { 139 + return c.json({ error: "Board not found" }, 404); 140 + } 141 + 114 142 // Generate TID for rkey 115 143 const rkey = TID.nextStr(); 116 144 ··· 124 152 text: validation.trimmed!, 125 153 forum: { 126 154 forum: { uri: forumUri, cid: forum.cid }, 155 + }, 156 + board: { 157 + board: { uri: boardUri, cid: board.cid }, 127 158 }, 128 159 createdAt: new Date().toISOString(), 129 160 }, ··· 165 196 ); 166 197 } 167 198 199 + // Database connection errors - temporary, user should retry 200 + if (error instanceof Error && isDatabaseError(error)) { 201 + return c.json( 202 + { 203 + error: "Database temporarily unavailable. Please try again later.", 204 + }, 205 + 503 206 + ); 207 + } 208 + 209 + // Unexpected errors - may indicate bugs, should be investigated 168 210 return c.json( 169 211 { 170 - error: "Failed to create topic. Please try again later.", 212 + error: "Failed to create topic. Please report this issue if it persists.", 171 213 }, 172 214 500 173 215 );
+44
bruno/AppView API/Boards/Get Board Topics.bru
··· 1 + meta { 2 + name: Get Board Topics 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/boards/1/topics 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.topics: isDefined 14 + } 15 + 16 + docs { 17 + Returns topics (posts with NULL root) for a specific board, sorted by creation time descending. 18 + 19 + Path parameters: 20 + - id: Board ID (numeric) 21 + 22 + Returns: 23 + { 24 + "topics": [ 25 + { 26 + "id": "123", 27 + "did": "did:plc:...", 28 + "rkey": "3lbk7...", 29 + "text": "Topic text", 30 + "forumUri": "at://did:plc:.../space.atbb.forum.forum/self", 31 + "boardUri": "at://did:plc:.../space.atbb.forum.board/...", 32 + "boardId": "456", 33 + "parentPostId": null, 34 + "createdAt": "2026-02-13T00:00:00.000Z", 35 + "author": { "did": "...", "handle": "..." } | null 36 + } 37 + ] 38 + } 39 + 40 + Error codes: 41 + - 400: Invalid board ID format 42 + - 404: Board not found 43 + - 500: Server error 44 + }
+39
bruno/AppView API/Boards/List All Boards.bru
··· 1 + meta { 2 + name: List All Boards 3 + type: http 4 + seq: 1 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/boards 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.boards: isDefined 14 + } 15 + 16 + docs { 17 + Returns all boards across all categories, sorted by category sortOrder then board sortOrder. 18 + 19 + Returns: 20 + { 21 + "boards": [ 22 + { 23 + "id": "1", 24 + "did": "did:plc:forum", 25 + "name": "Board name", 26 + "description": "Board description" | null, 27 + "slug": "board-slug" | null, 28 + "sortOrder": 1 | null, 29 + "categoryId": "10", 30 + "categoryUri": "at://did:plc:forum/space.atbb.forum.category/rkey", 31 + "createdAt": "2026-02-13T00:00:00.000Z", 32 + "indexedAt": "2026-02-13T00:00:00.000Z" 33 + } 34 + ] 35 + } 36 + 37 + Error codes: 38 + - 500: Server error 39 + }
+40
bruno/AppView API/Categories/Get Category Boards.bru
··· 1 + meta { 2 + name: Get Category Boards 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/categories/1/boards 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.boards: isDefined 14 + } 15 + 16 + docs { 17 + Returns boards for a specific category, sorted by sortOrder. 18 + 19 + Path parameters: 20 + - id: Category ID (numeric) 21 + 22 + Returns: 23 + { 24 + "boards": [ 25 + { 26 + "id": "1", 27 + "name": "Board name", 28 + "description": "Board description" | null, 29 + "slug": "board-slug" | null, 30 + "sortOrder": 1 | null, 31 + "categoryUri": "at://did:plc:forum/space.atbb.forum.category/rkey", 32 + "createdAt": "2026-02-13T00:00:00.000Z" 33 + } 34 + ] 35 + } 36 + 37 + Error codes: 38 + - 400: Invalid category ID format 39 + - 500: Server error 40 + }
+20 -18
bruno/AppView API/Topics/Create Topic.bru
··· 14 14 15 15 body:json { 16 16 { 17 - "text": "This is a new topic", 18 - "forumUri": "at://{{forum_did}}/space.atbb.forum.forum/self" 17 + "text": "My new topic", 18 + "boardUri": "at://{{forum_did}}/space.atbb.forum.board/{{board_rkey}}" 19 19 } 20 20 } 21 21 ··· 23 23 res.status: eq 201 24 24 res.body.uri: isDefined 25 25 res.body.cid: isDefined 26 - res.body.rkey: isDefined 27 26 } 28 27 29 28 docs { 30 - Create a new topic (thread starter post). 29 + Creates a new topic (thread starter post) in a board. 30 + 31 + Requires authentication via session cookie. 31 32 32 - Required body: 33 - { 34 - "text": "Post text (1-10000 chars, trimmed)", 35 - "forumUri": "at://did:plc:.../space.atbb.forum.forum/self" (optional, defaults to singleton forum) 36 - } 33 + Body parameters: 34 + - text: string (required) - Topic text content (1-3000 chars, max 300 graphemes) 35 + - boardUri: string (required) - AT URI of the board (at://did/space.atbb.forum.board/rkey) 37 36 38 37 Returns: 39 38 { 40 - "uri": "at://did:plc:.../space.atbb.post/...", 41 - "cid": "...", 42 - "rkey": "..." 39 + "uri": "at://did:plc:user/space.atbb.post/rkey", 40 + "cid": "bafyrei...", 41 + "rkey": "3k..." 43 42 } 44 43 45 - Requires authentication (valid session cookie). 44 + The post record written to PDS includes: 45 + - forum reference (singleton, auto-configured) 46 + - board reference (from boardUri parameter) 46 47 47 - Returns 400 for invalid text. 48 - Returns 401 if not authenticated. 49 - Returns 404 if forum not found. 50 - Returns 503 if PDS unreachable (network error). 51 - Returns 500 for server errors. 48 + Error codes: 49 + - 400: Invalid input (missing text, invalid boardUri, malformed JSON) 50 + - 401: Unauthorized (not authenticated) 51 + - 404: Board not found 52 + - 503: Unable to reach PDS (network error, retry later) 53 + - 500: Server error 52 54 }
+1
bruno/environments/dev.bru
··· 4 4 forum_did: did:plc:example 5 5 user_handle: user.bsky.social 6 6 topic_id: 1 7 + board_rkey: 3k2a7b 7 8 }
+1
bruno/environments/local.bru
··· 4 4 forum_did: did:plc:example 5 5 user_handle: user.bsky.social 6 6 topic_id: 1 7 + board_rkey: 3k2a7b 7 8 }
+13 -1
docs/atproto-forum-plan.md
··· 157 157 - Comprehensive error handling with try-catch on all database queries 158 158 - Global error handler in create-app.ts for unhandled errors 159 159 - Helper functions for serialization (serializeBigInt, serializeDate, serializeAuthor, parseBigIntParam) 160 - - **Note:** `GET /api/categories/:id/topics` endpoint removed - posts table lacks categoryUri field for filtering (deferred to ATB-12 or later when schema supports category-to-post association) 161 160 - [x] API endpoints (write path — proxy to user's PDS) — **DONE** (ATB-12): 162 161 - `POST /api/topics` — create `space.atbb.post` record with `forumRef` but no `reply` ref. Validates text (1-300 graphemes), writes to user's PDS via OAuth agent, returns {uri, cid, rkey} with 201 status. Fire-and-forget design (firehose indexes asynchronously). (`apps/appview/src/routes/topics.ts:13-119`) 163 162 - `POST /api/posts` — create `space.atbb.post` record with both `forumRef` and `reply` ref. Validates text, parses rootPostId/parentPostId, validates parent belongs to same thread, writes to user's PDS, returns {uri, cid, rkey}. (`apps/appview/src/routes/posts.ts:13-119`) 164 163 - Helper functions for validation: `validatePostText()` (1-300 graphemes using `@atproto/api` UnicodeString, with type guard for non-string input), `getForumByUri()`, `getPostsByIds()` (bulk lookup with Map), `validateReplyParent()` (thread boundary validation). (`apps/appview/src/routes/helpers.ts:65-190`) 165 164 - Error handling: Type guards prevent crashes, JSON parsing wrapped in try-catch (400 for malformed), catch blocks re-throw TypeError/ReferenceError (don't swallow programming bugs), network errors (503) vs server errors (500) properly classified. No silent data fabrication (returns null). 166 165 - Tests: 16 integration tests for POST /api/topics (includes 5 PDS error scenarios), 14 integration tests for POST /api/posts (includes 5 PDS error scenarios), 16 unit tests for helpers. **134 total appview tests passing** (29 new tests for ATB-12). Three comprehensive review rounds completed. 166 + - [x] **ATB-23: Boards Hierarchy** (EXPANDED from "add categoryUri column") — **Complete:** 2026-02-14 167 + - Implemented 3-level hierarchy: Forum → Categories → Boards → Topics 168 + - Added `space.atbb.forum.board` lexicon (YAML + generated types) 169 + - Schema changes: `boards` table (bigserial id, did, rkey, cid, name, description, categoryUri, sortOrder, indexed_at), added `boardUri` and `boardId` columns to `posts` table with foreign key constraint, migrations `0002_sturdy_maestro.sql` (boards table) and `0003_brief_mariko_yashida.sql` (posts columns) 170 + - Posts now link to boards (primary) and forums (redundant for backward compatibility) 171 + - New API endpoints with comprehensive tests: 172 + - `GET /api/boards` — list all boards across all categories 173 + - `GET /api/boards/:id/topics` — list topics for a specific board 174 + - `GET /api/categories/:id/boards` — list boards within a category 175 + - Updated `POST /api/topics` to require `boardUri` (validates board exists) 176 + - Indexer handles `space.atbb.forum.board` records from firehose 177 + - Bruno collections updated with new board endpoints 178 + - Files: `packages/lexicon/lexicons/space/atbb/forum/board.yaml`, `apps/appview/drizzle/0002_sturdy_maestro.sql`, `apps/appview/drizzle/0003_brief_mariko_yashida.sql`, `apps/appview/src/routes/boards.ts`, `apps/appview/src/routes/__tests__/boards.test.ts` 167 179 168 180 #### Phase 2: Auth & Membership (Week 5–6) 169 181 - [x] Implement AT Proto OAuth flow (user login via their PDS) — **Complete:** OAuth 2.1 implementation using `@atproto/oauth-client-node` library with PKCE flow, state validation, automatic token refresh, and DPoP. Supports any AT Protocol PDS (not limited to bsky.social). Routes in `apps/appview/src/routes/auth.ts` (ATB-14)
+30
packages/db/src/schema.ts
··· 49 49 ] 50 50 ); 51 51 52 + // ── boards ────────────────────────────────────────────── 53 + // Board (subforum) definitions within categories, owned by Forum DID. 54 + export const boards = pgTable( 55 + "boards", 56 + { 57 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 58 + did: text("did").notNull(), 59 + rkey: text("rkey").notNull(), 60 + cid: text("cid").notNull(), 61 + name: text("name").notNull(), 62 + description: text("description"), 63 + slug: text("slug"), 64 + sortOrder: integer("sort_order"), 65 + categoryId: bigint("category_id", { mode: "bigint" }).references( 66 + () => categories.id 67 + ), 68 + categoryUri: text("category_uri").notNull(), 69 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 70 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 71 + }, 72 + (table) => [ 73 + uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey), 74 + index("boards_category_id_idx").on(table.categoryId), 75 + ] 76 + ); 77 + 52 78 // ── users ─────────────────────────────────────────────── 53 79 // Known AT Proto identities. Populated when any record 54 80 // from a DID is indexed. DID is the primary key. ··· 101 127 cid: text("cid").notNull(), 102 128 text: text("text").notNull(), 103 129 forumUri: text("forum_uri"), 130 + boardUri: text("board_uri"), 131 + boardId: bigint("board_id", { mode: "bigint" }).references(() => boards.id), 104 132 rootPostId: bigint("root_post_id", { mode: "bigint" }).references( 105 133 (): any => posts.id 106 134 ), ··· 116 144 (table) => [ 117 145 uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 118 146 index("posts_forum_uri_idx").on(table.forumUri), 147 + index("posts_board_id_idx").on(table.boardId), 148 + index("posts_board_uri_idx").on(table.boardUri), 119 149 index("posts_root_post_id_idx").on(table.rootPostId), 120 150 ] 121 151 );
+57
packages/lexicon/lexicons/space/atbb/forum/board.yaml
··· 1 + # yaml-language-server: $schema=https://boat.kelinci.net/lexicon-document.json 2 + --- 3 + lexicon: 1 4 + id: space.atbb.forum.board 5 + defs: 6 + main: 7 + type: record 8 + description: >- 9 + A board (subforum) within a category. 10 + Owned by the Forum DID. 11 + key: tid 12 + record: 13 + type: object 14 + required: 15 + - name 16 + - category 17 + - createdAt 18 + properties: 19 + name: 20 + type: string 21 + maxLength: 300 22 + maxGraphemes: 100 23 + description: >- 24 + Display name of the board. 25 + description: 26 + type: string 27 + maxLength: 3000 28 + maxGraphemes: 300 29 + description: >- 30 + A short description for the board. 31 + slug: 32 + type: string 33 + maxLength: 100 34 + description: >- 35 + URL-friendly identifier for the board. 36 + Must be lowercase alphanumeric with hyphens. 37 + sortOrder: 38 + type: integer 39 + minimum: 0 40 + description: >- 41 + Numeric sort position. Lower values appear first. 42 + category: 43 + type: ref 44 + ref: "#categoryRef" 45 + createdAt: 46 + type: string 47 + format: datetime 48 + description: >- 49 + Timestamp when this board was created. 50 + categoryRef: 51 + type: object 52 + required: 53 + - category 54 + properties: 55 + category: 56 + type: ref 57 + ref: com.atproto.repo.strongRef
+11
packages/lexicon/lexicons/space/atbb/post.yaml
··· 24 24 forum: 25 25 type: ref 26 26 ref: "#forumRef" 27 + board: 28 + type: ref 29 + ref: "#boardRef" 27 30 reply: 28 31 type: ref 29 32 ref: "#replyRef" ··· 38 41 - forum 39 42 properties: 40 43 forum: 44 + type: ref 45 + ref: com.atproto.repo.strongRef 46 + boardRef: 47 + type: object 48 + required: 49 + - board 50 + properties: 51 + board: 41 52 type: ref 42 53 ref: com.atproto.repo.strongRef 43 54 replyRef: