WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(appview): theme read API endpoints (ATB-55) (#86)

* docs: add ATB-55 theme read API design doc

Records approved design for themes table, theme_policies table,
theme_policy_available_themes join table, firehose indexer configs,
and GET /api/themes + GET /api/themes/:rkey + GET /api/theme-policy endpoints.

* docs: add ATB-55 theme API implementation plan

* feat(db): add themes, theme_policies, theme_policy_available_themes tables

Generate Postgres (0013) and SQLite (0001) migrations for the three new
theme tables. Build @atbb/db to verify schema compiles correctly.

* feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)

* feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)

* docs(bruno): add Themes API collection (ATB-55)

authored by

Malpercio and committed by
GitHub
3f6a5d9a 3ff848fb

+5207 -2
+35
apps/appview/drizzle-sqlite/0001_add_theme_tables.sql
··· 1 + CREATE TABLE `theme_policies` ( 2 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 + `did` text NOT NULL, 4 + `rkey` text NOT NULL, 5 + `cid` text NOT NULL, 6 + `default_light_theme_uri` text NOT NULL, 7 + `default_dark_theme_uri` text NOT NULL, 8 + `allow_user_choice` integer NOT NULL, 9 + `indexed_at` integer NOT NULL 10 + ); 11 + --> statement-breakpoint 12 + CREATE UNIQUE INDEX `theme_policies_did_rkey_idx` ON `theme_policies` (`did`,`rkey`);--> statement-breakpoint 13 + CREATE TABLE `theme_policy_available_themes` ( 14 + `policy_id` integer NOT NULL, 15 + `theme_uri` text NOT NULL, 16 + `theme_cid` text NOT NULL, 17 + PRIMARY KEY(`policy_id`, `theme_uri`), 18 + FOREIGN KEY (`policy_id`) REFERENCES `theme_policies`(`id`) ON UPDATE no action ON DELETE cascade 19 + ); 20 + --> statement-breakpoint 21 + CREATE TABLE `themes` ( 22 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 23 + `did` text NOT NULL, 24 + `rkey` text NOT NULL, 25 + `cid` text NOT NULL, 26 + `name` text NOT NULL, 27 + `color_scheme` text NOT NULL, 28 + `tokens` text NOT NULL, 29 + `css_overrides` text, 30 + `font_urls` text, 31 + `created_at` integer NOT NULL, 32 + `indexed_at` integer NOT NULL 33 + ); 34 + --> statement-breakpoint 35 + CREATE UNIQUE INDEX `themes_did_rkey_idx` ON `themes` (`did`,`rkey`);
+1396
apps/appview/drizzle-sqlite/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "8aa48416-5eb6-492c-8206-e1f32f752f5a", 5 + "prevId": "84f6d742-677b-4159-86e3-9c5abadccec5", 6 + "tables": { 7 + "backfill_errors": { 8 + "name": "backfill_errors", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "did": { 25 + "name": "did", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "collection": { 32 + "name": "collection", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "error_message": { 39 + "name": "error_message", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": true, 43 + "autoincrement": false 44 + }, 45 + "created_at": { 46 + "name": "created_at", 47 + "type": "integer", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + } 52 + }, 53 + "indexes": { 54 + "backfill_errors_backfill_id_idx": { 55 + "name": "backfill_errors_backfill_id_idx", 56 + "columns": [ 57 + "backfill_id" 58 + ], 59 + "isUnique": false 60 + } 61 + }, 62 + "foreignKeys": { 63 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 64 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 65 + "tableFrom": "backfill_errors", 66 + "tableTo": "backfill_progress", 67 + "columnsFrom": [ 68 + "backfill_id" 69 + ], 70 + "columnsTo": [ 71 + "id" 72 + ], 73 + "onDelete": "no action", 74 + "onUpdate": "no action" 75 + } 76 + }, 77 + "compositePrimaryKeys": {}, 78 + "uniqueConstraints": {}, 79 + "checkConstraints": {} 80 + }, 81 + "backfill_progress": { 82 + "name": "backfill_progress", 83 + "columns": { 84 + "id": { 85 + "name": "id", 86 + "type": "integer", 87 + "primaryKey": true, 88 + "notNull": true, 89 + "autoincrement": true 90 + }, 91 + "status": { 92 + "name": "status", 93 + "type": "text", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "backfill_type": { 99 + "name": "backfill_type", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "last_processed_did": { 106 + "name": "last_processed_did", 107 + "type": "text", 108 + "primaryKey": false, 109 + "notNull": false, 110 + "autoincrement": false 111 + }, 112 + "dids_total": { 113 + "name": "dids_total", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "autoincrement": false, 118 + "default": 0 119 + }, 120 + "dids_processed": { 121 + "name": "dids_processed", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": true, 125 + "autoincrement": false, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "autoincrement": false, 134 + "default": 0 135 + }, 136 + "started_at": { 137 + "name": "started_at", 138 + "type": "integer", 139 + "primaryKey": false, 140 + "notNull": true, 141 + "autoincrement": false 142 + }, 143 + "completed_at": { 144 + "name": "completed_at", 145 + "type": "integer", 146 + "primaryKey": false, 147 + "notNull": false, 148 + "autoincrement": false 149 + }, 150 + "error_message": { 151 + "name": "error_message", 152 + "type": "text", 153 + "primaryKey": false, 154 + "notNull": false, 155 + "autoincrement": false 156 + } 157 + }, 158 + "indexes": {}, 159 + "foreignKeys": {}, 160 + "compositePrimaryKeys": {}, 161 + "uniqueConstraints": {}, 162 + "checkConstraints": {} 163 + }, 164 + "boards": { 165 + "name": "boards", 166 + "columns": { 167 + "id": { 168 + "name": "id", 169 + "type": "integer", 170 + "primaryKey": true, 171 + "notNull": true, 172 + "autoincrement": true 173 + }, 174 + "did": { 175 + "name": "did", 176 + "type": "text", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false 180 + }, 181 + "rkey": { 182 + "name": "rkey", 183 + "type": "text", 184 + "primaryKey": false, 185 + "notNull": true, 186 + "autoincrement": false 187 + }, 188 + "cid": { 189 + "name": "cid", 190 + "type": "text", 191 + "primaryKey": false, 192 + "notNull": true, 193 + "autoincrement": false 194 + }, 195 + "name": { 196 + "name": "name", 197 + "type": "text", 198 + "primaryKey": false, 199 + "notNull": true, 200 + "autoincrement": false 201 + }, 202 + "description": { 203 + "name": "description", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false, 207 + "autoincrement": false 208 + }, 209 + "slug": { 210 + "name": "slug", 211 + "type": "text", 212 + "primaryKey": false, 213 + "notNull": false, 214 + "autoincrement": false 215 + }, 216 + "sort_order": { 217 + "name": "sort_order", 218 + "type": "integer", 219 + "primaryKey": false, 220 + "notNull": false, 221 + "autoincrement": false 222 + }, 223 + "category_id": { 224 + "name": "category_id", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": false, 228 + "autoincrement": false 229 + }, 230 + "category_uri": { 231 + "name": "category_uri", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "created_at": { 238 + "name": "created_at", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "indexed_at": { 245 + "name": "indexed_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + } 251 + }, 252 + "indexes": { 253 + "boards_did_rkey_idx": { 254 + "name": "boards_did_rkey_idx", 255 + "columns": [ 256 + "did", 257 + "rkey" 258 + ], 259 + "isUnique": true 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + "category_id" 265 + ], 266 + "isUnique": false 267 + } 268 + }, 269 + "foreignKeys": { 270 + "boards_category_id_categories_id_fk": { 271 + "name": "boards_category_id_categories_id_fk", 272 + "tableFrom": "boards", 273 + "tableTo": "categories", 274 + "columnsFrom": [ 275 + "category_id" 276 + ], 277 + "columnsTo": [ 278 + "id" 279 + ], 280 + "onDelete": "no action", 281 + "onUpdate": "no action" 282 + } 283 + }, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "checkConstraints": {} 287 + }, 288 + "categories": { 289 + "name": "categories", 290 + "columns": { 291 + "id": { 292 + "name": "id", 293 + "type": "integer", 294 + "primaryKey": true, 295 + "notNull": true, 296 + "autoincrement": true 297 + }, 298 + "did": { 299 + "name": "did", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": true, 303 + "autoincrement": false 304 + }, 305 + "rkey": { 306 + "name": "rkey", 307 + "type": "text", 308 + "primaryKey": false, 309 + "notNull": true, 310 + "autoincrement": false 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "autoincrement": false 318 + }, 319 + "name": { 320 + "name": "name", 321 + "type": "text", 322 + "primaryKey": false, 323 + "notNull": true, 324 + "autoincrement": false 325 + }, 326 + "description": { 327 + "name": "description", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false 332 + }, 333 + "slug": { 334 + "name": "slug", 335 + "type": "text", 336 + "primaryKey": false, 337 + "notNull": false, 338 + "autoincrement": false 339 + }, 340 + "sort_order": { 341 + "name": "sort_order", 342 + "type": "integer", 343 + "primaryKey": false, 344 + "notNull": false, 345 + "autoincrement": false 346 + }, 347 + "forum_id": { 348 + "name": "forum_id", 349 + "type": "integer", 350 + "primaryKey": false, 351 + "notNull": false, 352 + "autoincrement": false 353 + }, 354 + "created_at": { 355 + "name": "created_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "indexed_at": { 362 + "name": "indexed_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + "did", 374 + "rkey" 375 + ], 376 + "isUnique": true 377 + } 378 + }, 379 + "foreignKeys": { 380 + "categories_forum_id_forums_id_fk": { 381 + "name": "categories_forum_id_forums_id_fk", 382 + "tableFrom": "categories", 383 + "tableTo": "forums", 384 + "columnsFrom": [ 385 + "forum_id" 386 + ], 387 + "columnsTo": [ 388 + "id" 389 + ], 390 + "onDelete": "no action", 391 + "onUpdate": "no action" 392 + } 393 + }, 394 + "compositePrimaryKeys": {}, 395 + "uniqueConstraints": {}, 396 + "checkConstraints": {} 397 + }, 398 + "firehose_cursor": { 399 + "name": "firehose_cursor", 400 + "columns": { 401 + "service": { 402 + "name": "service", 403 + "type": "text", 404 + "primaryKey": true, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'jetstream'" 408 + }, 409 + "cursor": { 410 + "name": "cursor", 411 + "type": "integer", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false 415 + }, 416 + "updated_at": { 417 + "name": "updated_at", 418 + "type": "integer", 419 + "primaryKey": false, 420 + "notNull": true, 421 + "autoincrement": false 422 + } 423 + }, 424 + "indexes": {}, 425 + "foreignKeys": {}, 426 + "compositePrimaryKeys": {}, 427 + "uniqueConstraints": {}, 428 + "checkConstraints": {} 429 + }, 430 + "forums": { 431 + "name": "forums", 432 + "columns": { 433 + "id": { 434 + "name": "id", 435 + "type": "integer", 436 + "primaryKey": true, 437 + "notNull": true, 438 + "autoincrement": true 439 + }, 440 + "did": { 441 + "name": "did", 442 + "type": "text", 443 + "primaryKey": false, 444 + "notNull": true, 445 + "autoincrement": false 446 + }, 447 + "rkey": { 448 + "name": "rkey", 449 + "type": "text", 450 + "primaryKey": false, 451 + "notNull": true, 452 + "autoincrement": false 453 + }, 454 + "cid": { 455 + "name": "cid", 456 + "type": "text", 457 + "primaryKey": false, 458 + "notNull": true, 459 + "autoincrement": false 460 + }, 461 + "name": { 462 + "name": "name", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "autoincrement": false 467 + }, 468 + "description": { 469 + "name": "description", 470 + "type": "text", 471 + "primaryKey": false, 472 + "notNull": false, 473 + "autoincrement": false 474 + }, 475 + "indexed_at": { 476 + "name": "indexed_at", 477 + "type": "integer", 478 + "primaryKey": false, 479 + "notNull": true, 480 + "autoincrement": false 481 + } 482 + }, 483 + "indexes": { 484 + "forums_did_rkey_idx": { 485 + "name": "forums_did_rkey_idx", 486 + "columns": [ 487 + "did", 488 + "rkey" 489 + ], 490 + "isUnique": true 491 + } 492 + }, 493 + "foreignKeys": {}, 494 + "compositePrimaryKeys": {}, 495 + "uniqueConstraints": {}, 496 + "checkConstraints": {} 497 + }, 498 + "memberships": { 499 + "name": "memberships", 500 + "columns": { 501 + "id": { 502 + "name": "id", 503 + "type": "integer", 504 + "primaryKey": true, 505 + "notNull": true, 506 + "autoincrement": true 507 + }, 508 + "did": { 509 + "name": "did", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": true, 513 + "autoincrement": false 514 + }, 515 + "rkey": { 516 + "name": "rkey", 517 + "type": "text", 518 + "primaryKey": false, 519 + "notNull": true, 520 + "autoincrement": false 521 + }, 522 + "cid": { 523 + "name": "cid", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true, 527 + "autoincrement": false 528 + }, 529 + "forum_id": { 530 + "name": "forum_id", 531 + "type": "integer", 532 + "primaryKey": false, 533 + "notNull": false, 534 + "autoincrement": false 535 + }, 536 + "forum_uri": { 537 + "name": "forum_uri", 538 + "type": "text", 539 + "primaryKey": false, 540 + "notNull": true, 541 + "autoincrement": false 542 + }, 543 + "role": { 544 + "name": "role", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": false, 548 + "autoincrement": false 549 + }, 550 + "role_uri": { 551 + "name": "role_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false, 555 + "autoincrement": false 556 + }, 557 + "joined_at": { 558 + "name": "joined_at", 559 + "type": "integer", 560 + "primaryKey": false, 561 + "notNull": false, 562 + "autoincrement": false 563 + }, 564 + "created_at": { 565 + "name": "created_at", 566 + "type": "integer", 567 + "primaryKey": false, 568 + "notNull": true, 569 + "autoincrement": false 570 + }, 571 + "indexed_at": { 572 + "name": "indexed_at", 573 + "type": "integer", 574 + "primaryKey": false, 575 + "notNull": true, 576 + "autoincrement": false 577 + } 578 + }, 579 + "indexes": { 580 + "memberships_did_rkey_idx": { 581 + "name": "memberships_did_rkey_idx", 582 + "columns": [ 583 + "did", 584 + "rkey" 585 + ], 586 + "isUnique": true 587 + }, 588 + "memberships_did_idx": { 589 + "name": "memberships_did_idx", 590 + "columns": [ 591 + "did" 592 + ], 593 + "isUnique": false 594 + } 595 + }, 596 + "foreignKeys": { 597 + "memberships_did_users_did_fk": { 598 + "name": "memberships_did_users_did_fk", 599 + "tableFrom": "memberships", 600 + "tableTo": "users", 601 + "columnsFrom": [ 602 + "did" 603 + ], 604 + "columnsTo": [ 605 + "did" 606 + ], 607 + "onDelete": "no action", 608 + "onUpdate": "no action" 609 + }, 610 + "memberships_forum_id_forums_id_fk": { 611 + "name": "memberships_forum_id_forums_id_fk", 612 + "tableFrom": "memberships", 613 + "tableTo": "forums", 614 + "columnsFrom": [ 615 + "forum_id" 616 + ], 617 + "columnsTo": [ 618 + "id" 619 + ], 620 + "onDelete": "no action", 621 + "onUpdate": "no action" 622 + } 623 + }, 624 + "compositePrimaryKeys": {}, 625 + "uniqueConstraints": {}, 626 + "checkConstraints": {} 627 + }, 628 + "mod_actions": { 629 + "name": "mod_actions", 630 + "columns": { 631 + "id": { 632 + "name": "id", 633 + "type": "integer", 634 + "primaryKey": true, 635 + "notNull": true, 636 + "autoincrement": true 637 + }, 638 + "did": { 639 + "name": "did", 640 + "type": "text", 641 + "primaryKey": false, 642 + "notNull": true, 643 + "autoincrement": false 644 + }, 645 + "rkey": { 646 + "name": "rkey", 647 + "type": "text", 648 + "primaryKey": false, 649 + "notNull": true, 650 + "autoincrement": false 651 + }, 652 + "cid": { 653 + "name": "cid", 654 + "type": "text", 655 + "primaryKey": false, 656 + "notNull": true, 657 + "autoincrement": false 658 + }, 659 + "action": { 660 + "name": "action", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true, 664 + "autoincrement": false 665 + }, 666 + "subject_did": { 667 + "name": "subject_did", 668 + "type": "text", 669 + "primaryKey": false, 670 + "notNull": false, 671 + "autoincrement": false 672 + }, 673 + "subject_post_uri": { 674 + "name": "subject_post_uri", 675 + "type": "text", 676 + "primaryKey": false, 677 + "notNull": false, 678 + "autoincrement": false 679 + }, 680 + "forum_id": { 681 + "name": "forum_id", 682 + "type": "integer", 683 + "primaryKey": false, 684 + "notNull": false, 685 + "autoincrement": false 686 + }, 687 + "reason": { 688 + "name": "reason", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": false, 692 + "autoincrement": false 693 + }, 694 + "created_by": { 695 + "name": "created_by", 696 + "type": "text", 697 + "primaryKey": false, 698 + "notNull": true, 699 + "autoincrement": false 700 + }, 701 + "expires_at": { 702 + "name": "expires_at", 703 + "type": "integer", 704 + "primaryKey": false, 705 + "notNull": false, 706 + "autoincrement": false 707 + }, 708 + "created_at": { 709 + "name": "created_at", 710 + "type": "integer", 711 + "primaryKey": false, 712 + "notNull": true, 713 + "autoincrement": false 714 + }, 715 + "indexed_at": { 716 + "name": "indexed_at", 717 + "type": "integer", 718 + "primaryKey": false, 719 + "notNull": true, 720 + "autoincrement": false 721 + } 722 + }, 723 + "indexes": { 724 + "mod_actions_did_rkey_idx": { 725 + "name": "mod_actions_did_rkey_idx", 726 + "columns": [ 727 + "did", 728 + "rkey" 729 + ], 730 + "isUnique": true 731 + }, 732 + "mod_actions_subject_did_idx": { 733 + "name": "mod_actions_subject_did_idx", 734 + "columns": [ 735 + "subject_did" 736 + ], 737 + "isUnique": false 738 + }, 739 + "mod_actions_subject_post_uri_idx": { 740 + "name": "mod_actions_subject_post_uri_idx", 741 + "columns": [ 742 + "subject_post_uri" 743 + ], 744 + "isUnique": false 745 + } 746 + }, 747 + "foreignKeys": { 748 + "mod_actions_forum_id_forums_id_fk": { 749 + "name": "mod_actions_forum_id_forums_id_fk", 750 + "tableFrom": "mod_actions", 751 + "tableTo": "forums", 752 + "columnsFrom": [ 753 + "forum_id" 754 + ], 755 + "columnsTo": [ 756 + "id" 757 + ], 758 + "onDelete": "no action", 759 + "onUpdate": "no action" 760 + } 761 + }, 762 + "compositePrimaryKeys": {}, 763 + "uniqueConstraints": {}, 764 + "checkConstraints": {} 765 + }, 766 + "posts": { 767 + "name": "posts", 768 + "columns": { 769 + "id": { 770 + "name": "id", 771 + "type": "integer", 772 + "primaryKey": true, 773 + "notNull": true, 774 + "autoincrement": true 775 + }, 776 + "did": { 777 + "name": "did", 778 + "type": "text", 779 + "primaryKey": false, 780 + "notNull": true, 781 + "autoincrement": false 782 + }, 783 + "rkey": { 784 + "name": "rkey", 785 + "type": "text", 786 + "primaryKey": false, 787 + "notNull": true, 788 + "autoincrement": false 789 + }, 790 + "cid": { 791 + "name": "cid", 792 + "type": "text", 793 + "primaryKey": false, 794 + "notNull": true, 795 + "autoincrement": false 796 + }, 797 + "title": { 798 + "name": "title", 799 + "type": "text", 800 + "primaryKey": false, 801 + "notNull": false, 802 + "autoincrement": false 803 + }, 804 + "text": { 805 + "name": "text", 806 + "type": "text", 807 + "primaryKey": false, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "forum_uri": { 812 + "name": "forum_uri", 813 + "type": "text", 814 + "primaryKey": false, 815 + "notNull": false, 816 + "autoincrement": false 817 + }, 818 + "board_uri": { 819 + "name": "board_uri", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": false, 823 + "autoincrement": false 824 + }, 825 + "board_id": { 826 + "name": "board_id", 827 + "type": "integer", 828 + "primaryKey": false, 829 + "notNull": false, 830 + "autoincrement": false 831 + }, 832 + "root_post_id": { 833 + "name": "root_post_id", 834 + "type": "integer", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false 838 + }, 839 + "parent_post_id": { 840 + "name": "parent_post_id", 841 + "type": "integer", 842 + "primaryKey": false, 843 + "notNull": false, 844 + "autoincrement": false 845 + }, 846 + "root_uri": { 847 + "name": "root_uri", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false, 851 + "autoincrement": false 852 + }, 853 + "parent_uri": { 854 + "name": "parent_uri", 855 + "type": "text", 856 + "primaryKey": false, 857 + "notNull": false, 858 + "autoincrement": false 859 + }, 860 + "created_at": { 861 + "name": "created_at", 862 + "type": "integer", 863 + "primaryKey": false, 864 + "notNull": true, 865 + "autoincrement": false 866 + }, 867 + "indexed_at": { 868 + "name": "indexed_at", 869 + "type": "integer", 870 + "primaryKey": false, 871 + "notNull": true, 872 + "autoincrement": false 873 + }, 874 + "banned_by_mod": { 875 + "name": "banned_by_mod", 876 + "type": "integer", 877 + "primaryKey": false, 878 + "notNull": true, 879 + "autoincrement": false, 880 + "default": false 881 + }, 882 + "deleted_by_user": { 883 + "name": "deleted_by_user", 884 + "type": "integer", 885 + "primaryKey": false, 886 + "notNull": true, 887 + "autoincrement": false, 888 + "default": false 889 + } 890 + }, 891 + "indexes": { 892 + "posts_did_rkey_idx": { 893 + "name": "posts_did_rkey_idx", 894 + "columns": [ 895 + "did", 896 + "rkey" 897 + ], 898 + "isUnique": true 899 + }, 900 + "posts_forum_uri_idx": { 901 + "name": "posts_forum_uri_idx", 902 + "columns": [ 903 + "forum_uri" 904 + ], 905 + "isUnique": false 906 + }, 907 + "posts_board_id_idx": { 908 + "name": "posts_board_id_idx", 909 + "columns": [ 910 + "board_id" 911 + ], 912 + "isUnique": false 913 + }, 914 + "posts_board_uri_idx": { 915 + "name": "posts_board_uri_idx", 916 + "columns": [ 917 + "board_uri" 918 + ], 919 + "isUnique": false 920 + }, 921 + "posts_root_post_id_idx": { 922 + "name": "posts_root_post_id_idx", 923 + "columns": [ 924 + "root_post_id" 925 + ], 926 + "isUnique": false 927 + } 928 + }, 929 + "foreignKeys": { 930 + "posts_did_users_did_fk": { 931 + "name": "posts_did_users_did_fk", 932 + "tableFrom": "posts", 933 + "tableTo": "users", 934 + "columnsFrom": [ 935 + "did" 936 + ], 937 + "columnsTo": [ 938 + "did" 939 + ], 940 + "onDelete": "no action", 941 + "onUpdate": "no action" 942 + }, 943 + "posts_board_id_boards_id_fk": { 944 + "name": "posts_board_id_boards_id_fk", 945 + "tableFrom": "posts", 946 + "tableTo": "boards", 947 + "columnsFrom": [ 948 + "board_id" 949 + ], 950 + "columnsTo": [ 951 + "id" 952 + ], 953 + "onDelete": "no action", 954 + "onUpdate": "no action" 955 + }, 956 + "posts_root_post_id_posts_id_fk": { 957 + "name": "posts_root_post_id_posts_id_fk", 958 + "tableFrom": "posts", 959 + "tableTo": "posts", 960 + "columnsFrom": [ 961 + "root_post_id" 962 + ], 963 + "columnsTo": [ 964 + "id" 965 + ], 966 + "onDelete": "no action", 967 + "onUpdate": "no action" 968 + }, 969 + "posts_parent_post_id_posts_id_fk": { 970 + "name": "posts_parent_post_id_posts_id_fk", 971 + "tableFrom": "posts", 972 + "tableTo": "posts", 973 + "columnsFrom": [ 974 + "parent_post_id" 975 + ], 976 + "columnsTo": [ 977 + "id" 978 + ], 979 + "onDelete": "no action", 980 + "onUpdate": "no action" 981 + } 982 + }, 983 + "compositePrimaryKeys": {}, 984 + "uniqueConstraints": {}, 985 + "checkConstraints": {} 986 + }, 987 + "role_permissions": { 988 + "name": "role_permissions", 989 + "columns": { 990 + "role_id": { 991 + "name": "role_id", 992 + "type": "integer", 993 + "primaryKey": false, 994 + "notNull": true, 995 + "autoincrement": false 996 + }, 997 + "permission": { 998 + "name": "permission", 999 + "type": "text", 1000 + "primaryKey": false, 1001 + "notNull": true, 1002 + "autoincrement": false 1003 + } 1004 + }, 1005 + "indexes": {}, 1006 + "foreignKeys": { 1007 + "role_permissions_role_id_roles_id_fk": { 1008 + "name": "role_permissions_role_id_roles_id_fk", 1009 + "tableFrom": "role_permissions", 1010 + "tableTo": "roles", 1011 + "columnsFrom": [ 1012 + "role_id" 1013 + ], 1014 + "columnsTo": [ 1015 + "id" 1016 + ], 1017 + "onDelete": "cascade", 1018 + "onUpdate": "no action" 1019 + } 1020 + }, 1021 + "compositePrimaryKeys": { 1022 + "role_permissions_role_id_permission_pk": { 1023 + "columns": [ 1024 + "role_id", 1025 + "permission" 1026 + ], 1027 + "name": "role_permissions_role_id_permission_pk" 1028 + } 1029 + }, 1030 + "uniqueConstraints": {}, 1031 + "checkConstraints": {} 1032 + }, 1033 + "roles": { 1034 + "name": "roles", 1035 + "columns": { 1036 + "id": { 1037 + "name": "id", 1038 + "type": "integer", 1039 + "primaryKey": true, 1040 + "notNull": true, 1041 + "autoincrement": true 1042 + }, 1043 + "did": { 1044 + "name": "did", 1045 + "type": "text", 1046 + "primaryKey": false, 1047 + "notNull": true, 1048 + "autoincrement": false 1049 + }, 1050 + "rkey": { 1051 + "name": "rkey", 1052 + "type": "text", 1053 + "primaryKey": false, 1054 + "notNull": true, 1055 + "autoincrement": false 1056 + }, 1057 + "cid": { 1058 + "name": "cid", 1059 + "type": "text", 1060 + "primaryKey": false, 1061 + "notNull": true, 1062 + "autoincrement": false 1063 + }, 1064 + "name": { 1065 + "name": "name", 1066 + "type": "text", 1067 + "primaryKey": false, 1068 + "notNull": true, 1069 + "autoincrement": false 1070 + }, 1071 + "description": { 1072 + "name": "description", 1073 + "type": "text", 1074 + "primaryKey": false, 1075 + "notNull": false, 1076 + "autoincrement": false 1077 + }, 1078 + "priority": { 1079 + "name": "priority", 1080 + "type": "integer", 1081 + "primaryKey": false, 1082 + "notNull": true, 1083 + "autoincrement": false 1084 + }, 1085 + "created_at": { 1086 + "name": "created_at", 1087 + "type": "integer", 1088 + "primaryKey": false, 1089 + "notNull": true, 1090 + "autoincrement": false 1091 + }, 1092 + "indexed_at": { 1093 + "name": "indexed_at", 1094 + "type": "integer", 1095 + "primaryKey": false, 1096 + "notNull": true, 1097 + "autoincrement": false 1098 + } 1099 + }, 1100 + "indexes": { 1101 + "roles_did_rkey_idx": { 1102 + "name": "roles_did_rkey_idx", 1103 + "columns": [ 1104 + "did", 1105 + "rkey" 1106 + ], 1107 + "isUnique": true 1108 + }, 1109 + "roles_did_idx": { 1110 + "name": "roles_did_idx", 1111 + "columns": [ 1112 + "did" 1113 + ], 1114 + "isUnique": false 1115 + }, 1116 + "roles_did_name_idx": { 1117 + "name": "roles_did_name_idx", 1118 + "columns": [ 1119 + "did", 1120 + "name" 1121 + ], 1122 + "isUnique": false 1123 + } 1124 + }, 1125 + "foreignKeys": {}, 1126 + "compositePrimaryKeys": {}, 1127 + "uniqueConstraints": {}, 1128 + "checkConstraints": {} 1129 + }, 1130 + "theme_policies": { 1131 + "name": "theme_policies", 1132 + "columns": { 1133 + "id": { 1134 + "name": "id", 1135 + "type": "integer", 1136 + "primaryKey": true, 1137 + "notNull": true, 1138 + "autoincrement": true 1139 + }, 1140 + "did": { 1141 + "name": "did", 1142 + "type": "text", 1143 + "primaryKey": false, 1144 + "notNull": true, 1145 + "autoincrement": false 1146 + }, 1147 + "rkey": { 1148 + "name": "rkey", 1149 + "type": "text", 1150 + "primaryKey": false, 1151 + "notNull": true, 1152 + "autoincrement": false 1153 + }, 1154 + "cid": { 1155 + "name": "cid", 1156 + "type": "text", 1157 + "primaryKey": false, 1158 + "notNull": true, 1159 + "autoincrement": false 1160 + }, 1161 + "default_light_theme_uri": { 1162 + "name": "default_light_theme_uri", 1163 + "type": "text", 1164 + "primaryKey": false, 1165 + "notNull": true, 1166 + "autoincrement": false 1167 + }, 1168 + "default_dark_theme_uri": { 1169 + "name": "default_dark_theme_uri", 1170 + "type": "text", 1171 + "primaryKey": false, 1172 + "notNull": true, 1173 + "autoincrement": false 1174 + }, 1175 + "allow_user_choice": { 1176 + "name": "allow_user_choice", 1177 + "type": "integer", 1178 + "primaryKey": false, 1179 + "notNull": true, 1180 + "autoincrement": false 1181 + }, 1182 + "indexed_at": { 1183 + "name": "indexed_at", 1184 + "type": "integer", 1185 + "primaryKey": false, 1186 + "notNull": true, 1187 + "autoincrement": false 1188 + } 1189 + }, 1190 + "indexes": { 1191 + "theme_policies_did_rkey_idx": { 1192 + "name": "theme_policies_did_rkey_idx", 1193 + "columns": [ 1194 + "did", 1195 + "rkey" 1196 + ], 1197 + "isUnique": true 1198 + } 1199 + }, 1200 + "foreignKeys": {}, 1201 + "compositePrimaryKeys": {}, 1202 + "uniqueConstraints": {}, 1203 + "checkConstraints": {} 1204 + }, 1205 + "theme_policy_available_themes": { 1206 + "name": "theme_policy_available_themes", 1207 + "columns": { 1208 + "policy_id": { 1209 + "name": "policy_id", 1210 + "type": "integer", 1211 + "primaryKey": false, 1212 + "notNull": true, 1213 + "autoincrement": false 1214 + }, 1215 + "theme_uri": { 1216 + "name": "theme_uri", 1217 + "type": "text", 1218 + "primaryKey": false, 1219 + "notNull": true, 1220 + "autoincrement": false 1221 + }, 1222 + "theme_cid": { 1223 + "name": "theme_cid", 1224 + "type": "text", 1225 + "primaryKey": false, 1226 + "notNull": true, 1227 + "autoincrement": false 1228 + } 1229 + }, 1230 + "indexes": {}, 1231 + "foreignKeys": { 1232 + "theme_policy_available_themes_policy_id_theme_policies_id_fk": { 1233 + "name": "theme_policy_available_themes_policy_id_theme_policies_id_fk", 1234 + "tableFrom": "theme_policy_available_themes", 1235 + "tableTo": "theme_policies", 1236 + "columnsFrom": [ 1237 + "policy_id" 1238 + ], 1239 + "columnsTo": [ 1240 + "id" 1241 + ], 1242 + "onDelete": "cascade", 1243 + "onUpdate": "no action" 1244 + } 1245 + }, 1246 + "compositePrimaryKeys": { 1247 + "theme_policy_available_themes_policy_id_theme_uri_pk": { 1248 + "columns": [ 1249 + "policy_id", 1250 + "theme_uri" 1251 + ], 1252 + "name": "theme_policy_available_themes_policy_id_theme_uri_pk" 1253 + } 1254 + }, 1255 + "uniqueConstraints": {}, 1256 + "checkConstraints": {} 1257 + }, 1258 + "themes": { 1259 + "name": "themes", 1260 + "columns": { 1261 + "id": { 1262 + "name": "id", 1263 + "type": "integer", 1264 + "primaryKey": true, 1265 + "notNull": true, 1266 + "autoincrement": true 1267 + }, 1268 + "did": { 1269 + "name": "did", 1270 + "type": "text", 1271 + "primaryKey": false, 1272 + "notNull": true, 1273 + "autoincrement": false 1274 + }, 1275 + "rkey": { 1276 + "name": "rkey", 1277 + "type": "text", 1278 + "primaryKey": false, 1279 + "notNull": true, 1280 + "autoincrement": false 1281 + }, 1282 + "cid": { 1283 + "name": "cid", 1284 + "type": "text", 1285 + "primaryKey": false, 1286 + "notNull": true, 1287 + "autoincrement": false 1288 + }, 1289 + "name": { 1290 + "name": "name", 1291 + "type": "text", 1292 + "primaryKey": false, 1293 + "notNull": true, 1294 + "autoincrement": false 1295 + }, 1296 + "color_scheme": { 1297 + "name": "color_scheme", 1298 + "type": "text", 1299 + "primaryKey": false, 1300 + "notNull": true, 1301 + "autoincrement": false 1302 + }, 1303 + "tokens": { 1304 + "name": "tokens", 1305 + "type": "text", 1306 + "primaryKey": false, 1307 + "notNull": true, 1308 + "autoincrement": false 1309 + }, 1310 + "css_overrides": { 1311 + "name": "css_overrides", 1312 + "type": "text", 1313 + "primaryKey": false, 1314 + "notNull": false, 1315 + "autoincrement": false 1316 + }, 1317 + "font_urls": { 1318 + "name": "font_urls", 1319 + "type": "text", 1320 + "primaryKey": false, 1321 + "notNull": false, 1322 + "autoincrement": false 1323 + }, 1324 + "created_at": { 1325 + "name": "created_at", 1326 + "type": "integer", 1327 + "primaryKey": false, 1328 + "notNull": true, 1329 + "autoincrement": false 1330 + }, 1331 + "indexed_at": { 1332 + "name": "indexed_at", 1333 + "type": "integer", 1334 + "primaryKey": false, 1335 + "notNull": true, 1336 + "autoincrement": false 1337 + } 1338 + }, 1339 + "indexes": { 1340 + "themes_did_rkey_idx": { 1341 + "name": "themes_did_rkey_idx", 1342 + "columns": [ 1343 + "did", 1344 + "rkey" 1345 + ], 1346 + "isUnique": true 1347 + } 1348 + }, 1349 + "foreignKeys": {}, 1350 + "compositePrimaryKeys": {}, 1351 + "uniqueConstraints": {}, 1352 + "checkConstraints": {} 1353 + }, 1354 + "users": { 1355 + "name": "users", 1356 + "columns": { 1357 + "did": { 1358 + "name": "did", 1359 + "type": "text", 1360 + "primaryKey": true, 1361 + "notNull": true, 1362 + "autoincrement": false 1363 + }, 1364 + "handle": { 1365 + "name": "handle", 1366 + "type": "text", 1367 + "primaryKey": false, 1368 + "notNull": false, 1369 + "autoincrement": false 1370 + }, 1371 + "indexed_at": { 1372 + "name": "indexed_at", 1373 + "type": "integer", 1374 + "primaryKey": false, 1375 + "notNull": true, 1376 + "autoincrement": false 1377 + } 1378 + }, 1379 + "indexes": {}, 1380 + "foreignKeys": {}, 1381 + "compositePrimaryKeys": {}, 1382 + "uniqueConstraints": {}, 1383 + "checkConstraints": {} 1384 + } 1385 + }, 1386 + "views": {}, 1387 + "enums": {}, 1388 + "_meta": { 1389 + "schemas": {}, 1390 + "tables": {}, 1391 + "columns": {} 1392 + }, 1393 + "internal": { 1394 + "indexes": {} 1395 + } 1396 + }
+7
apps/appview/drizzle-sqlite/meta/_journal.json
··· 8 8 "when": 1772035997110, 9 9 "tag": "0000_thankful_mister_fear", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1772482055726, 16 + "tag": "0001_add_theme_tables", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+35
apps/appview/drizzle/0013_add_theme_tables.sql
··· 1 + CREATE TABLE "theme_policies" ( 2 + "id" bigserial PRIMARY KEY NOT NULL, 3 + "did" text NOT NULL, 4 + "rkey" text NOT NULL, 5 + "cid" text NOT NULL, 6 + "default_light_theme_uri" text NOT NULL, 7 + "default_dark_theme_uri" text NOT NULL, 8 + "allow_user_choice" boolean NOT NULL, 9 + "indexed_at" timestamp with time zone NOT NULL 10 + ); 11 + --> statement-breakpoint 12 + CREATE TABLE "theme_policy_available_themes" ( 13 + "policy_id" bigint NOT NULL, 14 + "theme_uri" text NOT NULL, 15 + "theme_cid" text NOT NULL, 16 + CONSTRAINT "theme_policy_available_themes_policy_id_theme_uri_pk" PRIMARY KEY("policy_id","theme_uri") 17 + ); 18 + --> statement-breakpoint 19 + CREATE TABLE "themes" ( 20 + "id" bigserial PRIMARY KEY NOT NULL, 21 + "did" text NOT NULL, 22 + "rkey" text NOT NULL, 23 + "cid" text NOT NULL, 24 + "name" text NOT NULL, 25 + "color_scheme" text NOT NULL, 26 + "tokens" jsonb NOT NULL, 27 + "css_overrides" text, 28 + "font_urls" text[], 29 + "created_at" timestamp with time zone NOT NULL, 30 + "indexed_at" timestamp with time zone NOT NULL 31 + ); 32 + --> statement-breakpoint 33 + ALTER TABLE "theme_policy_available_themes" ADD CONSTRAINT "theme_policy_available_themes_policy_id_theme_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."theme_policies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 34 + CREATE UNIQUE INDEX "theme_policies_did_rkey_idx" ON "theme_policies" USING btree ("did","rkey");--> statement-breakpoint 35 + CREATE UNIQUE INDEX "themes_did_rkey_idx" ON "themes" USING btree ("did","rkey");
+1526
apps/appview/drizzle/meta/0013_snapshot.json
··· 1 + { 2 + "id": "50219693-221b-49a6-8363-f9de0bf6afdd", 3 + "prevId": "0179c1d9-9fbb-4bc8-9606-475e10ba22dc", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.backfill_errors": { 8 + "name": "backfill_errors", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "bigint", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "did": { 24 + "name": "did", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "collection": { 30 + "name": "collection", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "error_message": { 36 + "name": "error_message", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "created_at": { 42 + "name": "created_at", 43 + "type": "timestamp with time zone", 44 + "primaryKey": false, 45 + "notNull": true 46 + } 47 + }, 48 + "indexes": { 49 + "backfill_errors_backfill_id_idx": { 50 + "name": "backfill_errors_backfill_id_idx", 51 + "columns": [ 52 + { 53 + "expression": "backfill_id", 54 + "isExpression": false, 55 + "asc": true, 56 + "nulls": "last" 57 + } 58 + ], 59 + "isUnique": false, 60 + "concurrently": false, 61 + "method": "btree", 62 + "with": {} 63 + } 64 + }, 65 + "foreignKeys": { 66 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 67 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 68 + "tableFrom": "backfill_errors", 69 + "tableTo": "backfill_progress", 70 + "columnsFrom": [ 71 + "backfill_id" 72 + ], 73 + "columnsTo": [ 74 + "id" 75 + ], 76 + "onDelete": "no action", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "policies": {}, 83 + "checkConstraints": {}, 84 + "isRLSEnabled": false 85 + }, 86 + "public.backfill_progress": { 87 + "name": "backfill_progress", 88 + "schema": "", 89 + "columns": { 90 + "id": { 91 + "name": "id", 92 + "type": "bigserial", 93 + "primaryKey": true, 94 + "notNull": true 95 + }, 96 + "status": { 97 + "name": "status", 98 + "type": "text", 99 + "primaryKey": false, 100 + "notNull": true 101 + }, 102 + "backfill_type": { 103 + "name": "backfill_type", 104 + "type": "text", 105 + "primaryKey": false, 106 + "notNull": true 107 + }, 108 + "last_processed_did": { 109 + "name": "last_processed_did", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false 113 + }, 114 + "dids_total": { 115 + "name": "dids_total", 116 + "type": "integer", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "default": 0 120 + }, 121 + "dids_processed": { 122 + "name": "dids_processed", 123 + "type": "integer", 124 + "primaryKey": false, 125 + "notNull": true, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "default": 0 134 + }, 135 + "started_at": { 136 + "name": "started_at", 137 + "type": "timestamp with time zone", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "completed_at": { 142 + "name": "completed_at", 143 + "type": "timestamp with time zone", 144 + "primaryKey": false, 145 + "notNull": false 146 + }, 147 + "error_message": { 148 + "name": "error_message", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": false 152 + } 153 + }, 154 + "indexes": {}, 155 + "foreignKeys": {}, 156 + "compositePrimaryKeys": {}, 157 + "uniqueConstraints": {}, 158 + "policies": {}, 159 + "checkConstraints": {}, 160 + "isRLSEnabled": false 161 + }, 162 + "public.boards": { 163 + "name": "boards", 164 + "schema": "", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "bigserial", 169 + "primaryKey": true, 170 + "notNull": true 171 + }, 172 + "did": { 173 + "name": "did", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": true 177 + }, 178 + "rkey": { 179 + "name": "rkey", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": true 183 + }, 184 + "cid": { 185 + "name": "cid", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true 189 + }, 190 + "name": { 191 + "name": "name", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": true 195 + }, 196 + "description": { 197 + "name": "description", 198 + "type": "text", 199 + "primaryKey": false, 200 + "notNull": false 201 + }, 202 + "slug": { 203 + "name": "slug", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false 207 + }, 208 + "sort_order": { 209 + "name": "sort_order", 210 + "type": "integer", 211 + "primaryKey": false, 212 + "notNull": false 213 + }, 214 + "category_id": { 215 + "name": "category_id", 216 + "type": "bigint", 217 + "primaryKey": false, 218 + "notNull": false 219 + }, 220 + "category_uri": { 221 + "name": "category_uri", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "timestamp with time zone", 229 + "primaryKey": false, 230 + "notNull": true 231 + }, 232 + "indexed_at": { 233 + "name": "indexed_at", 234 + "type": "timestamp with time zone", 235 + "primaryKey": false, 236 + "notNull": true 237 + } 238 + }, 239 + "indexes": { 240 + "boards_did_rkey_idx": { 241 + "name": "boards_did_rkey_idx", 242 + "columns": [ 243 + { 244 + "expression": "did", 245 + "isExpression": false, 246 + "asc": true, 247 + "nulls": "last" 248 + }, 249 + { 250 + "expression": "rkey", 251 + "isExpression": false, 252 + "asc": true, 253 + "nulls": "last" 254 + } 255 + ], 256 + "isUnique": true, 257 + "concurrently": false, 258 + "method": "btree", 259 + "with": {} 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + { 265 + "expression": "category_id", 266 + "isExpression": false, 267 + "asc": true, 268 + "nulls": "last" 269 + } 270 + ], 271 + "isUnique": false, 272 + "concurrently": false, 273 + "method": "btree", 274 + "with": {} 275 + } 276 + }, 277 + "foreignKeys": { 278 + "boards_category_id_categories_id_fk": { 279 + "name": "boards_category_id_categories_id_fk", 280 + "tableFrom": "boards", 281 + "tableTo": "categories", 282 + "columnsFrom": [ 283 + "category_id" 284 + ], 285 + "columnsTo": [ 286 + "id" 287 + ], 288 + "onDelete": "no action", 289 + "onUpdate": "no action" 290 + } 291 + }, 292 + "compositePrimaryKeys": {}, 293 + "uniqueConstraints": {}, 294 + "policies": {}, 295 + "checkConstraints": {}, 296 + "isRLSEnabled": false 297 + }, 298 + "public.categories": { 299 + "name": "categories", 300 + "schema": "", 301 + "columns": { 302 + "id": { 303 + "name": "id", 304 + "type": "bigserial", 305 + "primaryKey": true, 306 + "notNull": true 307 + }, 308 + "did": { 309 + "name": "did", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true 313 + }, 314 + "rkey": { 315 + "name": "rkey", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true 319 + }, 320 + "cid": { 321 + "name": "cid", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true 325 + }, 326 + "name": { 327 + "name": "name", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true 331 + }, 332 + "description": { 333 + "name": "description", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": false 337 + }, 338 + "slug": { 339 + "name": "slug", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": false 343 + }, 344 + "sort_order": { 345 + "name": "sort_order", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false 349 + }, 350 + "forum_id": { 351 + "name": "forum_id", 352 + "type": "bigint", 353 + "primaryKey": false, 354 + "notNull": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "timestamp with time zone", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "indexed_at": { 363 + "name": "indexed_at", 364 + "type": "timestamp with time zone", 365 + "primaryKey": false, 366 + "notNull": true 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + { 374 + "expression": "did", 375 + "isExpression": false, 376 + "asc": true, 377 + "nulls": "last" 378 + }, 379 + { 380 + "expression": "rkey", 381 + "isExpression": false, 382 + "asc": true, 383 + "nulls": "last" 384 + } 385 + ], 386 + "isUnique": true, 387 + "concurrently": false, 388 + "method": "btree", 389 + "with": {} 390 + } 391 + }, 392 + "foreignKeys": { 393 + "categories_forum_id_forums_id_fk": { 394 + "name": "categories_forum_id_forums_id_fk", 395 + "tableFrom": "categories", 396 + "tableTo": "forums", 397 + "columnsFrom": [ 398 + "forum_id" 399 + ], 400 + "columnsTo": [ 401 + "id" 402 + ], 403 + "onDelete": "no action", 404 + "onUpdate": "no action" 405 + } 406 + }, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "policies": {}, 410 + "checkConstraints": {}, 411 + "isRLSEnabled": false 412 + }, 413 + "public.firehose_cursor": { 414 + "name": "firehose_cursor", 415 + "schema": "", 416 + "columns": { 417 + "service": { 418 + "name": "service", 419 + "type": "text", 420 + "primaryKey": true, 421 + "notNull": true, 422 + "default": "'jetstream'" 423 + }, 424 + "cursor": { 425 + "name": "cursor", 426 + "type": "bigint", 427 + "primaryKey": false, 428 + "notNull": true 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "timestamp with time zone", 433 + "primaryKey": false, 434 + "notNull": true 435 + } 436 + }, 437 + "indexes": {}, 438 + "foreignKeys": {}, 439 + "compositePrimaryKeys": {}, 440 + "uniqueConstraints": {}, 441 + "policies": {}, 442 + "checkConstraints": {}, 443 + "isRLSEnabled": false 444 + }, 445 + "public.forums": { 446 + "name": "forums", 447 + "schema": "", 448 + "columns": { 449 + "id": { 450 + "name": "id", 451 + "type": "bigserial", 452 + "primaryKey": true, 453 + "notNull": true 454 + }, 455 + "did": { 456 + "name": "did", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "rkey": { 462 + "name": "rkey", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "cid": { 468 + "name": "cid", 469 + "type": "text", 470 + "primaryKey": false, 471 + "notNull": true 472 + }, 473 + "name": { 474 + "name": "name", 475 + "type": "text", 476 + "primaryKey": false, 477 + "notNull": true 478 + }, 479 + "description": { 480 + "name": "description", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": false 484 + }, 485 + "indexed_at": { 486 + "name": "indexed_at", 487 + "type": "timestamp with time zone", 488 + "primaryKey": false, 489 + "notNull": true 490 + } 491 + }, 492 + "indexes": { 493 + "forums_did_rkey_idx": { 494 + "name": "forums_did_rkey_idx", 495 + "columns": [ 496 + { 497 + "expression": "did", 498 + "isExpression": false, 499 + "asc": true, 500 + "nulls": "last" 501 + }, 502 + { 503 + "expression": "rkey", 504 + "isExpression": false, 505 + "asc": true, 506 + "nulls": "last" 507 + } 508 + ], 509 + "isUnique": true, 510 + "concurrently": false, 511 + "method": "btree", 512 + "with": {} 513 + } 514 + }, 515 + "foreignKeys": {}, 516 + "compositePrimaryKeys": {}, 517 + "uniqueConstraints": {}, 518 + "policies": {}, 519 + "checkConstraints": {}, 520 + "isRLSEnabled": false 521 + }, 522 + "public.memberships": { 523 + "name": "memberships", 524 + "schema": "", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "bigserial", 529 + "primaryKey": true, 530 + "notNull": true 531 + }, 532 + "did": { 533 + "name": "did", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "rkey": { 539 + "name": "rkey", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "cid": { 545 + "name": "cid", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": true 549 + }, 550 + "forum_id": { 551 + "name": "forum_id", 552 + "type": "bigint", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_uri": { 557 + "name": "forum_uri", 558 + "type": "text", 559 + "primaryKey": false, 560 + "notNull": true 561 + }, 562 + "role": { 563 + "name": "role", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "role_uri": { 569 + "name": "role_uri", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": false 573 + }, 574 + "joined_at": { 575 + "name": "joined_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 + "memberships_did_rkey_idx": { 595 + "name": "memberships_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 + "memberships_did_idx": { 616 + "name": "memberships_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + } 630 + }, 631 + "foreignKeys": { 632 + "memberships_did_users_did_fk": { 633 + "name": "memberships_did_users_did_fk", 634 + "tableFrom": "memberships", 635 + "tableTo": "users", 636 + "columnsFrom": [ 637 + "did" 638 + ], 639 + "columnsTo": [ 640 + "did" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + }, 645 + "memberships_forum_id_forums_id_fk": { 646 + "name": "memberships_forum_id_forums_id_fk", 647 + "tableFrom": "memberships", 648 + "tableTo": "forums", 649 + "columnsFrom": [ 650 + "forum_id" 651 + ], 652 + "columnsTo": [ 653 + "id" 654 + ], 655 + "onDelete": "no action", 656 + "onUpdate": "no action" 657 + } 658 + }, 659 + "compositePrimaryKeys": {}, 660 + "uniqueConstraints": {}, 661 + "policies": {}, 662 + "checkConstraints": {}, 663 + "isRLSEnabled": false 664 + }, 665 + "public.mod_actions": { 666 + "name": "mod_actions", 667 + "schema": "", 668 + "columns": { 669 + "id": { 670 + "name": "id", 671 + "type": "bigserial", 672 + "primaryKey": true, 673 + "notNull": true 674 + }, 675 + "did": { 676 + "name": "did", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true 680 + }, 681 + "rkey": { 682 + "name": "rkey", 683 + "type": "text", 684 + "primaryKey": false, 685 + "notNull": true 686 + }, 687 + "cid": { 688 + "name": "cid", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": true 692 + }, 693 + "action": { 694 + "name": "action", 695 + "type": "text", 696 + "primaryKey": false, 697 + "notNull": true 698 + }, 699 + "subject_did": { 700 + "name": "subject_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false 704 + }, 705 + "subject_post_uri": { 706 + "name": "subject_post_uri", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false 710 + }, 711 + "forum_id": { 712 + "name": "forum_id", 713 + "type": "bigint", 714 + "primaryKey": false, 715 + "notNull": false 716 + }, 717 + "reason": { 718 + "name": "reason", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false 722 + }, 723 + "created_by": { 724 + "name": "created_by", 725 + "type": "text", 726 + "primaryKey": false, 727 + "notNull": true 728 + }, 729 + "expires_at": { 730 + "name": "expires_at", 731 + "type": "timestamp with time zone", 732 + "primaryKey": false, 733 + "notNull": false 734 + }, 735 + "created_at": { 736 + "name": "created_at", 737 + "type": "timestamp with time zone", 738 + "primaryKey": false, 739 + "notNull": true 740 + }, 741 + "indexed_at": { 742 + "name": "indexed_at", 743 + "type": "timestamp with time zone", 744 + "primaryKey": false, 745 + "notNull": true 746 + } 747 + }, 748 + "indexes": { 749 + "mod_actions_did_rkey_idx": { 750 + "name": "mod_actions_did_rkey_idx", 751 + "columns": [ 752 + { 753 + "expression": "did", 754 + "isExpression": false, 755 + "asc": true, 756 + "nulls": "last" 757 + }, 758 + { 759 + "expression": "rkey", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": true, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "mod_actions_subject_did_idx": { 771 + "name": "mod_actions_subject_did_idx", 772 + "columns": [ 773 + { 774 + "expression": "subject_did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "mod_actions_subject_post_uri_idx": { 786 + "name": "mod_actions_subject_post_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "subject_post_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 + }, 801 + "foreignKeys": { 802 + "mod_actions_forum_id_forums_id_fk": { 803 + "name": "mod_actions_forum_id_forums_id_fk", 804 + "tableFrom": "mod_actions", 805 + "tableTo": "forums", 806 + "columnsFrom": [ 807 + "forum_id" 808 + ], 809 + "columnsTo": [ 810 + "id" 811 + ], 812 + "onDelete": "no action", 813 + "onUpdate": "no action" 814 + } 815 + }, 816 + "compositePrimaryKeys": {}, 817 + "uniqueConstraints": {}, 818 + "policies": {}, 819 + "checkConstraints": {}, 820 + "isRLSEnabled": false 821 + }, 822 + "public.posts": { 823 + "name": "posts", 824 + "schema": "", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "bigserial", 829 + "primaryKey": true, 830 + "notNull": true 831 + }, 832 + "did": { 833 + "name": "did", 834 + "type": "text", 835 + "primaryKey": false, 836 + "notNull": true 837 + }, 838 + "rkey": { 839 + "name": "rkey", 840 + "type": "text", 841 + "primaryKey": false, 842 + "notNull": true 843 + }, 844 + "cid": { 845 + "name": "cid", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": true 849 + }, 850 + "title": { 851 + "name": "title", 852 + "type": "text", 853 + "primaryKey": false, 854 + "notNull": false 855 + }, 856 + "text": { 857 + "name": "text", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": true 861 + }, 862 + "forum_uri": { 863 + "name": "forum_uri", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false 867 + }, 868 + "board_uri": { 869 + "name": "board_uri", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": false 873 + }, 874 + "board_id": { 875 + "name": "board_id", 876 + "type": "bigint", 877 + "primaryKey": false, 878 + "notNull": false 879 + }, 880 + "root_post_id": { 881 + "name": "root_post_id", 882 + "type": "bigint", 883 + "primaryKey": false, 884 + "notNull": false 885 + }, 886 + "parent_post_id": { 887 + "name": "parent_post_id", 888 + "type": "bigint", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "root_uri": { 893 + "name": "root_uri", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false 897 + }, 898 + "parent_uri": { 899 + "name": "parent_uri", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false 903 + }, 904 + "created_at": { 905 + "name": "created_at", 906 + "type": "timestamp with time zone", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "indexed_at": { 911 + "name": "indexed_at", 912 + "type": "timestamp with time zone", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "banned_by_mod": { 917 + "name": "banned_by_mod", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "deleted_by_user": { 924 + "name": "deleted_by_user", 925 + "type": "boolean", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "default": false 929 + } 930 + }, 931 + "indexes": { 932 + "posts_did_rkey_idx": { 933 + "name": "posts_did_rkey_idx", 934 + "columns": [ 935 + { 936 + "expression": "did", 937 + "isExpression": false, 938 + "asc": true, 939 + "nulls": "last" 940 + }, 941 + { 942 + "expression": "rkey", 943 + "isExpression": false, 944 + "asc": true, 945 + "nulls": "last" 946 + } 947 + ], 948 + "isUnique": true, 949 + "concurrently": false, 950 + "method": "btree", 951 + "with": {} 952 + }, 953 + "posts_forum_uri_idx": { 954 + "name": "posts_forum_uri_idx", 955 + "columns": [ 956 + { 957 + "expression": "forum_uri", 958 + "isExpression": false, 959 + "asc": true, 960 + "nulls": "last" 961 + } 962 + ], 963 + "isUnique": false, 964 + "concurrently": false, 965 + "method": "btree", 966 + "with": {} 967 + }, 968 + "posts_board_id_idx": { 969 + "name": "posts_board_id_idx", 970 + "columns": [ 971 + { 972 + "expression": "board_id", 973 + "isExpression": false, 974 + "asc": true, 975 + "nulls": "last" 976 + } 977 + ], 978 + "isUnique": false, 979 + "concurrently": false, 980 + "method": "btree", 981 + "with": {} 982 + }, 983 + "posts_board_uri_idx": { 984 + "name": "posts_board_uri_idx", 985 + "columns": [ 986 + { 987 + "expression": "board_uri", 988 + "isExpression": false, 989 + "asc": true, 990 + "nulls": "last" 991 + } 992 + ], 993 + "isUnique": false, 994 + "concurrently": false, 995 + "method": "btree", 996 + "with": {} 997 + }, 998 + "posts_root_post_id_idx": { 999 + "name": "posts_root_post_id_idx", 1000 + "columns": [ 1001 + { 1002 + "expression": "root_post_id", 1003 + "isExpression": false, 1004 + "asc": true, 1005 + "nulls": "last" 1006 + } 1007 + ], 1008 + "isUnique": false, 1009 + "concurrently": false, 1010 + "method": "btree", 1011 + "with": {} 1012 + } 1013 + }, 1014 + "foreignKeys": { 1015 + "posts_did_users_did_fk": { 1016 + "name": "posts_did_users_did_fk", 1017 + "tableFrom": "posts", 1018 + "tableTo": "users", 1019 + "columnsFrom": [ 1020 + "did" 1021 + ], 1022 + "columnsTo": [ 1023 + "did" 1024 + ], 1025 + "onDelete": "no action", 1026 + "onUpdate": "no action" 1027 + }, 1028 + "posts_board_id_boards_id_fk": { 1029 + "name": "posts_board_id_boards_id_fk", 1030 + "tableFrom": "posts", 1031 + "tableTo": "boards", 1032 + "columnsFrom": [ 1033 + "board_id" 1034 + ], 1035 + "columnsTo": [ 1036 + "id" 1037 + ], 1038 + "onDelete": "no action", 1039 + "onUpdate": "no action" 1040 + }, 1041 + "posts_root_post_id_posts_id_fk": { 1042 + "name": "posts_root_post_id_posts_id_fk", 1043 + "tableFrom": "posts", 1044 + "tableTo": "posts", 1045 + "columnsFrom": [ 1046 + "root_post_id" 1047 + ], 1048 + "columnsTo": [ 1049 + "id" 1050 + ], 1051 + "onDelete": "no action", 1052 + "onUpdate": "no action" 1053 + }, 1054 + "posts_parent_post_id_posts_id_fk": { 1055 + "name": "posts_parent_post_id_posts_id_fk", 1056 + "tableFrom": "posts", 1057 + "tableTo": "posts", 1058 + "columnsFrom": [ 1059 + "parent_post_id" 1060 + ], 1061 + "columnsTo": [ 1062 + "id" 1063 + ], 1064 + "onDelete": "no action", 1065 + "onUpdate": "no action" 1066 + } 1067 + }, 1068 + "compositePrimaryKeys": {}, 1069 + "uniqueConstraints": {}, 1070 + "policies": {}, 1071 + "checkConstraints": {}, 1072 + "isRLSEnabled": false 1073 + }, 1074 + "public.role_permissions": { 1075 + "name": "role_permissions", 1076 + "schema": "", 1077 + "columns": { 1078 + "role_id": { 1079 + "name": "role_id", 1080 + "type": "bigint", 1081 + "primaryKey": false, 1082 + "notNull": true 1083 + }, 1084 + "permission": { 1085 + "name": "permission", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + } 1090 + }, 1091 + "indexes": {}, 1092 + "foreignKeys": { 1093 + "role_permissions_role_id_roles_id_fk": { 1094 + "name": "role_permissions_role_id_roles_id_fk", 1095 + "tableFrom": "role_permissions", 1096 + "tableTo": "roles", 1097 + "columnsFrom": [ 1098 + "role_id" 1099 + ], 1100 + "columnsTo": [ 1101 + "id" 1102 + ], 1103 + "onDelete": "cascade", 1104 + "onUpdate": "no action" 1105 + } 1106 + }, 1107 + "compositePrimaryKeys": { 1108 + "role_permissions_role_id_permission_pk": { 1109 + "name": "role_permissions_role_id_permission_pk", 1110 + "columns": [ 1111 + "role_id", 1112 + "permission" 1113 + ] 1114 + } 1115 + }, 1116 + "uniqueConstraints": {}, 1117 + "policies": {}, 1118 + "checkConstraints": {}, 1119 + "isRLSEnabled": false 1120 + }, 1121 + "public.roles": { 1122 + "name": "roles", 1123 + "schema": "", 1124 + "columns": { 1125 + "id": { 1126 + "name": "id", 1127 + "type": "bigserial", 1128 + "primaryKey": true, 1129 + "notNull": true 1130 + }, 1131 + "did": { 1132 + "name": "did", 1133 + "type": "text", 1134 + "primaryKey": false, 1135 + "notNull": true 1136 + }, 1137 + "rkey": { 1138 + "name": "rkey", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": true 1142 + }, 1143 + "cid": { 1144 + "name": "cid", 1145 + "type": "text", 1146 + "primaryKey": false, 1147 + "notNull": true 1148 + }, 1149 + "name": { 1150 + "name": "name", 1151 + "type": "text", 1152 + "primaryKey": false, 1153 + "notNull": true 1154 + }, 1155 + "description": { 1156 + "name": "description", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false 1160 + }, 1161 + "priority": { 1162 + "name": "priority", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": true 1166 + }, 1167 + "created_at": { 1168 + "name": "created_at", 1169 + "type": "timestamp with time zone", 1170 + "primaryKey": false, 1171 + "notNull": true 1172 + }, 1173 + "indexed_at": { 1174 + "name": "indexed_at", 1175 + "type": "timestamp with time zone", 1176 + "primaryKey": false, 1177 + "notNull": true 1178 + } 1179 + }, 1180 + "indexes": { 1181 + "roles_did_rkey_idx": { 1182 + "name": "roles_did_rkey_idx", 1183 + "columns": [ 1184 + { 1185 + "expression": "did", 1186 + "isExpression": false, 1187 + "asc": true, 1188 + "nulls": "last" 1189 + }, 1190 + { 1191 + "expression": "rkey", 1192 + "isExpression": false, 1193 + "asc": true, 1194 + "nulls": "last" 1195 + } 1196 + ], 1197 + "isUnique": true, 1198 + "concurrently": false, 1199 + "method": "btree", 1200 + "with": {} 1201 + }, 1202 + "roles_did_idx": { 1203 + "name": "roles_did_idx", 1204 + "columns": [ 1205 + { 1206 + "expression": "did", 1207 + "isExpression": false, 1208 + "asc": true, 1209 + "nulls": "last" 1210 + } 1211 + ], 1212 + "isUnique": false, 1213 + "concurrently": false, 1214 + "method": "btree", 1215 + "with": {} 1216 + }, 1217 + "roles_did_name_idx": { 1218 + "name": "roles_did_name_idx", 1219 + "columns": [ 1220 + { 1221 + "expression": "did", 1222 + "isExpression": false, 1223 + "asc": true, 1224 + "nulls": "last" 1225 + }, 1226 + { 1227 + "expression": "name", 1228 + "isExpression": false, 1229 + "asc": true, 1230 + "nulls": "last" 1231 + } 1232 + ], 1233 + "isUnique": false, 1234 + "concurrently": false, 1235 + "method": "btree", 1236 + "with": {} 1237 + } 1238 + }, 1239 + "foreignKeys": {}, 1240 + "compositePrimaryKeys": {}, 1241 + "uniqueConstraints": {}, 1242 + "policies": {}, 1243 + "checkConstraints": {}, 1244 + "isRLSEnabled": false 1245 + }, 1246 + "public.theme_policies": { 1247 + "name": "theme_policies", 1248 + "schema": "", 1249 + "columns": { 1250 + "id": { 1251 + "name": "id", 1252 + "type": "bigserial", 1253 + "primaryKey": true, 1254 + "notNull": true 1255 + }, 1256 + "did": { 1257 + "name": "did", 1258 + "type": "text", 1259 + "primaryKey": false, 1260 + "notNull": true 1261 + }, 1262 + "rkey": { 1263 + "name": "rkey", 1264 + "type": "text", 1265 + "primaryKey": false, 1266 + "notNull": true 1267 + }, 1268 + "cid": { 1269 + "name": "cid", 1270 + "type": "text", 1271 + "primaryKey": false, 1272 + "notNull": true 1273 + }, 1274 + "default_light_theme_uri": { 1275 + "name": "default_light_theme_uri", 1276 + "type": "text", 1277 + "primaryKey": false, 1278 + "notNull": true 1279 + }, 1280 + "default_dark_theme_uri": { 1281 + "name": "default_dark_theme_uri", 1282 + "type": "text", 1283 + "primaryKey": false, 1284 + "notNull": true 1285 + }, 1286 + "allow_user_choice": { 1287 + "name": "allow_user_choice", 1288 + "type": "boolean", 1289 + "primaryKey": false, 1290 + "notNull": true 1291 + }, 1292 + "indexed_at": { 1293 + "name": "indexed_at", 1294 + "type": "timestamp with time zone", 1295 + "primaryKey": false, 1296 + "notNull": true 1297 + } 1298 + }, 1299 + "indexes": { 1300 + "theme_policies_did_rkey_idx": { 1301 + "name": "theme_policies_did_rkey_idx", 1302 + "columns": [ 1303 + { 1304 + "expression": "did", 1305 + "isExpression": false, 1306 + "asc": true, 1307 + "nulls": "last" 1308 + }, 1309 + { 1310 + "expression": "rkey", 1311 + "isExpression": false, 1312 + "asc": true, 1313 + "nulls": "last" 1314 + } 1315 + ], 1316 + "isUnique": true, 1317 + "concurrently": false, 1318 + "method": "btree", 1319 + "with": {} 1320 + } 1321 + }, 1322 + "foreignKeys": {}, 1323 + "compositePrimaryKeys": {}, 1324 + "uniqueConstraints": {}, 1325 + "policies": {}, 1326 + "checkConstraints": {}, 1327 + "isRLSEnabled": false 1328 + }, 1329 + "public.theme_policy_available_themes": { 1330 + "name": "theme_policy_available_themes", 1331 + "schema": "", 1332 + "columns": { 1333 + "policy_id": { 1334 + "name": "policy_id", 1335 + "type": "bigint", 1336 + "primaryKey": false, 1337 + "notNull": true 1338 + }, 1339 + "theme_uri": { 1340 + "name": "theme_uri", 1341 + "type": "text", 1342 + "primaryKey": false, 1343 + "notNull": true 1344 + }, 1345 + "theme_cid": { 1346 + "name": "theme_cid", 1347 + "type": "text", 1348 + "primaryKey": false, 1349 + "notNull": true 1350 + } 1351 + }, 1352 + "indexes": {}, 1353 + "foreignKeys": { 1354 + "theme_policy_available_themes_policy_id_theme_policies_id_fk": { 1355 + "name": "theme_policy_available_themes_policy_id_theme_policies_id_fk", 1356 + "tableFrom": "theme_policy_available_themes", 1357 + "tableTo": "theme_policies", 1358 + "columnsFrom": [ 1359 + "policy_id" 1360 + ], 1361 + "columnsTo": [ 1362 + "id" 1363 + ], 1364 + "onDelete": "cascade", 1365 + "onUpdate": "no action" 1366 + } 1367 + }, 1368 + "compositePrimaryKeys": { 1369 + "theme_policy_available_themes_policy_id_theme_uri_pk": { 1370 + "name": "theme_policy_available_themes_policy_id_theme_uri_pk", 1371 + "columns": [ 1372 + "policy_id", 1373 + "theme_uri" 1374 + ] 1375 + } 1376 + }, 1377 + "uniqueConstraints": {}, 1378 + "policies": {}, 1379 + "checkConstraints": {}, 1380 + "isRLSEnabled": false 1381 + }, 1382 + "public.themes": { 1383 + "name": "themes", 1384 + "schema": "", 1385 + "columns": { 1386 + "id": { 1387 + "name": "id", 1388 + "type": "bigserial", 1389 + "primaryKey": true, 1390 + "notNull": true 1391 + }, 1392 + "did": { 1393 + "name": "did", 1394 + "type": "text", 1395 + "primaryKey": false, 1396 + "notNull": true 1397 + }, 1398 + "rkey": { 1399 + "name": "rkey", 1400 + "type": "text", 1401 + "primaryKey": false, 1402 + "notNull": true 1403 + }, 1404 + "cid": { 1405 + "name": "cid", 1406 + "type": "text", 1407 + "primaryKey": false, 1408 + "notNull": true 1409 + }, 1410 + "name": { 1411 + "name": "name", 1412 + "type": "text", 1413 + "primaryKey": false, 1414 + "notNull": true 1415 + }, 1416 + "color_scheme": { 1417 + "name": "color_scheme", 1418 + "type": "text", 1419 + "primaryKey": false, 1420 + "notNull": true 1421 + }, 1422 + "tokens": { 1423 + "name": "tokens", 1424 + "type": "jsonb", 1425 + "primaryKey": false, 1426 + "notNull": true 1427 + }, 1428 + "css_overrides": { 1429 + "name": "css_overrides", 1430 + "type": "text", 1431 + "primaryKey": false, 1432 + "notNull": false 1433 + }, 1434 + "font_urls": { 1435 + "name": "font_urls", 1436 + "type": "text[]", 1437 + "primaryKey": false, 1438 + "notNull": false 1439 + }, 1440 + "created_at": { 1441 + "name": "created_at", 1442 + "type": "timestamp with time zone", 1443 + "primaryKey": false, 1444 + "notNull": true 1445 + }, 1446 + "indexed_at": { 1447 + "name": "indexed_at", 1448 + "type": "timestamp with time zone", 1449 + "primaryKey": false, 1450 + "notNull": true 1451 + } 1452 + }, 1453 + "indexes": { 1454 + "themes_did_rkey_idx": { 1455 + "name": "themes_did_rkey_idx", 1456 + "columns": [ 1457 + { 1458 + "expression": "did", 1459 + "isExpression": false, 1460 + "asc": true, 1461 + "nulls": "last" 1462 + }, 1463 + { 1464 + "expression": "rkey", 1465 + "isExpression": false, 1466 + "asc": true, 1467 + "nulls": "last" 1468 + } 1469 + ], 1470 + "isUnique": true, 1471 + "concurrently": false, 1472 + "method": "btree", 1473 + "with": {} 1474 + } 1475 + }, 1476 + "foreignKeys": {}, 1477 + "compositePrimaryKeys": {}, 1478 + "uniqueConstraints": {}, 1479 + "policies": {}, 1480 + "checkConstraints": {}, 1481 + "isRLSEnabled": false 1482 + }, 1483 + "public.users": { 1484 + "name": "users", 1485 + "schema": "", 1486 + "columns": { 1487 + "did": { 1488 + "name": "did", 1489 + "type": "text", 1490 + "primaryKey": true, 1491 + "notNull": true 1492 + }, 1493 + "handle": { 1494 + "name": "handle", 1495 + "type": "text", 1496 + "primaryKey": false, 1497 + "notNull": false 1498 + }, 1499 + "indexed_at": { 1500 + "name": "indexed_at", 1501 + "type": "timestamp with time zone", 1502 + "primaryKey": false, 1503 + "notNull": true 1504 + } 1505 + }, 1506 + "indexes": {}, 1507 + "foreignKeys": {}, 1508 + "compositePrimaryKeys": {}, 1509 + "uniqueConstraints": {}, 1510 + "policies": {}, 1511 + "checkConstraints": {}, 1512 + "isRLSEnabled": false 1513 + } 1514 + }, 1515 + "enums": {}, 1516 + "schemas": {}, 1517 + "sequences": {}, 1518 + "roles": {}, 1519 + "policies": {}, 1520 + "views": {}, 1521 + "_meta": { 1522 + "columns": {}, 1523 + "schemas": {}, 1524 + "tables": {} 1525 + } 1526 + }
+7
apps/appview/drizzle/meta/_journal.json
··· 92 92 "when": 1772035630111, 93 93 "tag": "0012_acoustic_swordsman", 94 94 "breakpoints": true 95 + }, 96 + { 97 + "idx": 13, 98 + "version": "7", 99 + "when": 1772482052223, 100 + "tag": "0013_add_theme_tables", 101 + "breakpoints": true 95 102 } 96 103 ] 97 104 }
+9 -1
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 2 import { createDb, runSqliteMigrations } from "@atbb/db"; 3 - import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors } from "@atbb/db"; 3 + import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 4 import { createLogger } from "@atbb/logger"; 5 5 import path from "path"; 6 6 import { fileURLToPath } from "url"; ··· 92 92 await db.delete(modActions).catch(() => {}); 93 93 await db.delete(backfillErrors).catch(() => {}); 94 94 await db.delete(backfillProgress).catch(() => {}); 95 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 96 + await db.delete(themePolicies).catch(() => {}); // cascades to theme_policy_available_themes 97 + await db.delete(themes).catch(() => {}); 95 98 await db.delete(forums).catch(() => {}); 96 99 return; 97 100 } ··· 109 112 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 110 113 await db.delete(backfillErrors).catch(() => {}); 111 114 await db.delete(backfillProgress).catch(() => {}); 115 + // Deleting themePolicies cascades to theme_policy_available_themes 116 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 117 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 112 118 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 113 119 }; 114 120 ··· 181 187 await db.delete(boards).where(eq(boards.did, config.forumDid)); 182 188 await db.delete(categories).where(eq(categories.did, config.forumDid)); 183 189 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 190 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 191 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 184 192 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 185 193 await db.delete(backfillErrors).catch(() => {}); 186 194 await db.delete(backfillProgress).catch(() => {});
+12
apps/appview/src/lib/firehose.ts
··· 141 141 onCreate: this.createWrappedHandler("handleReactionCreate"), 142 142 onUpdate: this.createWrappedHandler("handleReactionUpdate"), 143 143 onDelete: this.createWrappedHandler("handleReactionDelete"), 144 + }) 145 + .register({ 146 + collection: "space.atbb.forum.theme", 147 + onCreate: this.createWrappedHandler("handleThemeCreate"), 148 + onUpdate: this.createWrappedHandler("handleThemeUpdate"), 149 + onDelete: this.createWrappedHandler("handleThemeDelete"), 150 + }) 151 + .register({ 152 + collection: "space.atbb.forum.themePolicy", 153 + onCreate: this.createWrappedHandler("handleThemePolicyCreate"), 154 + onUpdate: this.createWrappedHandler("handleThemePolicyUpdate"), 155 + onDelete: this.createWrappedHandler("handleThemePolicyDelete"), 144 156 }); 145 157 } 146 158
+99
apps/appview/src/lib/indexer.ts
··· 15 15 modActions, 16 16 roles, 17 17 rolePermissions, 18 + themes, 19 + themePolicies, 20 + themePolicyAvailableThemes, 18 21 } from "@atbb/db"; 19 22 import { eq, and } from "drizzle-orm"; 20 23 import { parseAtUri } from "./at-uri.js"; ··· 27 30 SpaceAtbbMembership as Membership, 28 31 SpaceAtbbModAction as ModAction, 29 32 SpaceAtbbForumRole as Role, 33 + SpaceAtbbForumTheme as Theme, 34 + SpaceAtbbForumThemePolicy as ThemePolicy, 30 35 } from "@atbb/lexicon"; 31 36 32 37 // ── Collection Config Types ───────────────────────────── ··· 354 359 }, 355 360 }; 356 361 362 + private themeConfig: CollectionConfig<Theme.Record> = { 363 + name: "Theme", 364 + table: themes, 365 + deleteStrategy: "hard", 366 + toInsertValues: async (event, record) => ({ 367 + did: event.did, 368 + rkey: event.commit.rkey, 369 + cid: event.commit.cid, 370 + name: record.name, 371 + colorScheme: record.colorScheme as string, 372 + tokens: record.tokens, 373 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 374 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 375 + createdAt: new Date(record.createdAt as string), 376 + indexedAt: new Date(), 377 + }), 378 + toUpdateValues: async (event, record) => ({ 379 + cid: event.commit.cid, 380 + name: record.name, 381 + colorScheme: record.colorScheme as string, 382 + tokens: record.tokens, 383 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 384 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 385 + indexedAt: new Date(), 386 + }), 387 + }; 388 + 389 + private themePolicyConfig: CollectionConfig<ThemePolicy.Record> = { 390 + name: "ThemePolicy", 391 + table: themePolicies, 392 + deleteStrategy: "hard", 393 + toInsertValues: async (event, record) => ({ 394 + did: event.did, 395 + rkey: event.commit.rkey, 396 + cid: event.commit.cid, 397 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 398 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 399 + allowUserChoice: record.allowUserChoice, 400 + indexedAt: new Date(), 401 + }), 402 + toUpdateValues: async (event, record) => ({ 403 + cid: event.commit.cid, 404 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 405 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 406 + allowUserChoice: record.allowUserChoice, 407 + indexedAt: new Date(), 408 + }), 409 + afterUpsert: async (_event, record, policyId, tx) => { 410 + // Atomically replace all available-theme rows for this policy 411 + await tx 412 + .delete(themePolicyAvailableThemes) 413 + .where(eq(themePolicyAvailableThemes.policyId, policyId)); 414 + 415 + const available = record.availableThemes ?? []; 416 + if (available.length > 0) { 417 + await tx.insert(themePolicyAvailableThemes).values( 418 + available.map((themeRef) => ({ 419 + policyId, 420 + themeUri: themeRef.theme.uri, 421 + themeCid: themeRef.theme.cid, 422 + })) 423 + ); 424 + } 425 + }, 426 + }; 427 + 357 428 private membershipConfig: CollectionConfig<Membership.Record> = { 358 429 name: "Membership", 359 430 table: memberships, ··· 739 810 740 811 async handleRoleDelete(event: CommitDeleteEvent<"space.atbb.forum.role">) { 741 812 await this.genericDelete(this.roleConfig, event); 813 + } 814 + 815 + // ── Theme Handlers ────────────────────────────────────── 816 + 817 + async handleThemeCreate(event: CommitCreateEvent<"space.atbb.forum.theme">) { 818 + await this.genericCreate(this.themeConfig, event); 819 + } 820 + 821 + async handleThemeUpdate(event: CommitUpdateEvent<"space.atbb.forum.theme">) { 822 + await this.genericUpdate(this.themeConfig, event); 823 + } 824 + 825 + async handleThemeDelete(event: CommitDeleteEvent<"space.atbb.forum.theme">) { 826 + await this.genericDelete(this.themeConfig, event); 827 + } 828 + 829 + // ── ThemePolicy Handlers ───────────────────────────────── 830 + 831 + async handleThemePolicyCreate(event: CommitCreateEvent<"space.atbb.forum.themePolicy">) { 832 + await this.genericCreate(this.themePolicyConfig, event); 833 + } 834 + 835 + async handleThemePolicyUpdate(event: CommitUpdateEvent<"space.atbb.forum.themePolicy">) { 836 + await this.genericUpdate(this.themePolicyConfig, event); 837 + } 838 + 839 + async handleThemePolicyDelete(event: CommitDeleteEvent<"space.atbb.forum.themePolicy">) { 840 + await this.genericDelete(this.themePolicyConfig, event); 742 841 } 743 842 744 843 // ── Membership Handlers ─────────────────────────────────
+292
apps/appview/src/routes/__tests__/themes.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 5 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 6 + 7 + // ── GET /api/themes ────────────────────────────────────── 8 + 9 + describe("GET /api/themes", () => { 10 + let ctx: TestContext; 11 + let app: Hono; 12 + 13 + beforeEach(async () => { 14 + ctx = await createTestContext(); 15 + app = new Hono() 16 + .route("/themes", createThemesRoutes(ctx)) 17 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 18 + }); 19 + 20 + afterEach(async () => { 21 + await ctx.cleanup(); 22 + }); 23 + 24 + it("returns empty array when no policy exists", async () => { 25 + const res = await app.request("/themes"); 26 + expect(res.status).toBe(200); 27 + const body = await res.json(); 28 + expect(body).toHaveProperty("themes"); 29 + expect(body.themes).toEqual([]); 30 + }); 31 + 32 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 33 + await ctx.db.insert(themes).values([ 34 + { 35 + did: ctx.config.forumDid, 36 + rkey: "3lbltheme1aa", 37 + cid: "bafytheme1", 38 + name: "Neobrutal Light", 39 + colorScheme: "light", 40 + tokens: { "color-bg": "#f5f0e8" }, 41 + createdAt: new Date(), 42 + indexedAt: new Date(), 43 + }, 44 + { 45 + did: ctx.config.forumDid, 46 + rkey: "3lbltheme2bb", 47 + cid: "bafytheme2", 48 + name: "Neobrutal Dark", 49 + colorScheme: "dark", 50 + tokens: { "color-bg": "#1a1a1a" }, 51 + createdAt: new Date(), 52 + indexedAt: new Date(), 53 + }, 54 + ]); 55 + 56 + const [policy] = await ctx.db 57 + .insert(themePolicies) 58 + .values({ 59 + did: ctx.config.forumDid, 60 + rkey: "self", 61 + cid: "bafypolicy1", 62 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 63 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 64 + allowUserChoice: true, 65 + indexedAt: new Date(), 66 + }) 67 + .returning(); 68 + 69 + // Only expose theme1 (light) — theme2 (dark) stays hidden 70 + await ctx.db.insert(themePolicyAvailableThemes).values({ 71 + policyId: policy.id, 72 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 73 + themeCid: "bafytheme1", 74 + }); 75 + 76 + const res = await app.request("/themes"); 77 + expect(res.status).toBe(200); 78 + const body = await res.json(); 79 + 80 + expect(body.themes).toHaveLength(1); 81 + expect(body.themes[0].name).toBe("Neobrutal Light"); 82 + // theme2 is in DB but NOT in availableThemes — must not appear 83 + const names = body.themes.map((t: any) => t.name); 84 + expect(names).not.toContain("Neobrutal Dark"); 85 + }); 86 + 87 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 88 + await ctx.db.insert(themes).values({ 89 + did: ctx.config.forumDid, 90 + rkey: "3lbltheme3cc", 91 + cid: "bafytheme3", 92 + name: "Clean Light", 93 + colorScheme: "light", 94 + tokens: { "color-bg": "#ffffff" }, 95 + cssOverrides: ".card { border-radius: 4px; }", 96 + createdAt: new Date(), 97 + indexedAt: new Date(), 98 + }); 99 + 100 + const [policy] = await ctx.db 101 + .insert(themePolicies) 102 + .values({ 103 + did: ctx.config.forumDid, 104 + rkey: "self", 105 + cid: "bafypolicy2", 106 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 107 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 108 + allowUserChoice: true, 109 + indexedAt: new Date(), 110 + }) 111 + .returning(); 112 + 113 + await ctx.db.insert(themePolicyAvailableThemes).values({ 114 + policyId: policy.id, 115 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 116 + themeCid: "bafytheme3", 117 + }); 118 + 119 + const res = await app.request("/themes"); 120 + const body = await res.json(); 121 + const theme = body.themes[0]; 122 + 123 + expect(theme).toHaveProperty("id"); 124 + expect(theme).toHaveProperty("uri"); 125 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 126 + expect(theme).toHaveProperty("name"); 127 + expect(theme).toHaveProperty("colorScheme"); 128 + expect(theme).toHaveProperty("indexedAt"); 129 + // List endpoint must NOT return full token set 130 + expect(theme).not.toHaveProperty("tokens"); 131 + expect(theme).not.toHaveProperty("cssOverrides"); 132 + expect(theme).not.toHaveProperty("fontUrls"); 133 + }); 134 + 135 + it("returns 503 on database error", async () => { 136 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 137 + throw new Error("Database connection lost"); 138 + }); 139 + 140 + const res = await app.request("/themes"); 141 + expect(res.status).toBe(503); 142 + }); 143 + }); 144 + 145 + // ── GET /api/themes/:rkey ──────────────────────────────── 146 + 147 + describe("GET /api/themes/:rkey", () => { 148 + let ctx: TestContext; 149 + let app: Hono; 150 + 151 + beforeEach(async () => { 152 + ctx = await createTestContext(); 153 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 154 + }); 155 + 156 + afterEach(async () => { 157 + await ctx.cleanup(); 158 + }); 159 + 160 + it("returns 404 for unknown rkey", async () => { 161 + const res = await app.request("/themes/nonexistent"); 162 + expect(res.status).toBe(404); 163 + const body = await res.json(); 164 + expect(body.error).toBeDefined(); 165 + }); 166 + 167 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 168 + await ctx.db.insert(themes).values({ 169 + did: ctx.config.forumDid, 170 + rkey: "3lblfulltest", 171 + cid: "bafyfull", 172 + name: "Neobrutal Light", 173 + colorScheme: "light", 174 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 175 + cssOverrides: ".btn { font-weight: 700; }", 176 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 177 + createdAt: new Date(), 178 + indexedAt: new Date(), 179 + }); 180 + 181 + const res = await app.request("/themes/3lblfulltest"); 182 + expect(res.status).toBe(200); 183 + const body = await res.json(); 184 + 185 + expect(body.name).toBe("Neobrutal Light"); 186 + expect(body.colorScheme).toBe("light"); 187 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 188 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 189 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 190 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 191 + expect(body.indexedAt).toBeDefined(); 192 + }); 193 + 194 + it("returns null for optional fields when not set", async () => { 195 + await ctx.db.insert(themes).values({ 196 + did: ctx.config.forumDid, 197 + rkey: "3lblminimal", 198 + cid: "bafymin", 199 + name: "Minimal", 200 + colorScheme: "light", 201 + tokens: { "color-bg": "#fff" }, 202 + createdAt: new Date(), 203 + indexedAt: new Date(), 204 + }); 205 + 206 + const res = await app.request("/themes/3lblminimal"); 207 + expect(res.status).toBe(200); 208 + const body = await res.json(); 209 + expect(body.cssOverrides).toBeNull(); 210 + expect(body.fontUrls).toBeNull(); 211 + }); 212 + 213 + it("returns 503 on database error", async () => { 214 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 215 + throw new Error("Database connection lost"); 216 + }); 217 + 218 + const res = await app.request("/themes/any-rkey"); 219 + expect(res.status).toBe(503); 220 + }); 221 + }); 222 + 223 + // ── GET /api/theme-policy ──────────────────────────────── 224 + 225 + describe("GET /api/theme-policy", () => { 226 + let ctx: TestContext; 227 + let app: Hono; 228 + 229 + beforeEach(async () => { 230 + ctx = await createTestContext(); 231 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 232 + }); 233 + 234 + afterEach(async () => { 235 + await ctx.cleanup(); 236 + }); 237 + 238 + it("returns 404 when no policy exists", async () => { 239 + const res = await app.request("/theme-policy"); 240 + expect(res.status).toBe(404); 241 + const body = await res.json(); 242 + expect(body.error).toBeDefined(); 243 + }); 244 + 245 + it("returns policy with correct fields", async () => { 246 + const [policy] = await ctx.db 247 + .insert(themePolicies) 248 + .values({ 249 + did: ctx.config.forumDid, 250 + rkey: "self", 251 + cid: "bafypol", 252 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 253 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 254 + allowUserChoice: false, 255 + indexedAt: new Date(), 256 + }) 257 + .returning(); 258 + 259 + await ctx.db.insert(themePolicyAvailableThemes).values([ 260 + { 261 + policyId: policy.id, 262 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 263 + themeCid: "bafylight", 264 + }, 265 + { 266 + policyId: policy.id, 267 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 268 + themeCid: "bafydark", 269 + }, 270 + ]); 271 + 272 + const res = await app.request("/theme-policy"); 273 + expect(res.status).toBe(200); 274 + const body = await res.json(); 275 + 276 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 277 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 278 + expect(body.allowUserChoice).toBe(false); 279 + expect(body.availableThemes).toHaveLength(2); 280 + expect(body.availableThemes[0]).toHaveProperty("uri"); 281 + expect(body.availableThemes[0]).toHaveProperty("cid"); 282 + }); 283 + 284 + it("returns 503 on database error", async () => { 285 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 286 + throw new Error("Database connection lost"); 287 + }); 288 + 289 + const res = await app.request("/theme-policy"); 290 + expect(res.status).toBe(503); 291 + }); 292 + });
+4 -1
apps/appview/src/routes/index.ts
··· 9 9 import { createAuthRoutes } from "./auth.js"; 10 10 import { createAdminRoutes } from "./admin.js"; 11 11 import { createModRoutes } from "./mod.js"; 12 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 12 13 13 14 /** 14 15 * Factory function that creates all API routes with access to app context. ··· 24 25 .route("/topics", createTopicsRoutes(ctx)) 25 26 .route("/posts", createPostsRoutes(ctx)) 26 27 .route("/admin", createAdminRoutes(ctx)) 27 - .route("/mod", createModRoutes(ctx)); 28 + .route("/mod", createModRoutes(ctx)) 29 + .route("/themes", createThemesRoutes(ctx)) 30 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 28 31 } 29 32 30 33 // Export stub routes for tests that don't need database access
+152
apps/appview/src/routes/themes.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 + import { eq, inArray, and } from "drizzle-orm"; 5 + import { serializeBigInt, serializeDate } from "./helpers.js"; 6 + import { handleRouteError } from "../lib/route-errors.js"; 7 + import { parseAtUri } from "../lib/at-uri.js"; 8 + 9 + type ThemeRow = typeof themes.$inferSelect; 10 + 11 + function serializeThemeSummary(theme: ThemeRow) { 12 + return { 13 + id: serializeBigInt(theme.id), 14 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 15 + name: theme.name, 16 + colorScheme: theme.colorScheme, 17 + indexedAt: serializeDate(theme.indexedAt), 18 + }; 19 + } 20 + 21 + function serializeThemeFull(theme: ThemeRow) { 22 + return { 23 + id: serializeBigInt(theme.id), 24 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 25 + name: theme.name, 26 + colorScheme: theme.colorScheme, 27 + tokens: theme.tokens, 28 + cssOverrides: theme.cssOverrides ?? null, 29 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 30 + createdAt: serializeDate(theme.createdAt), 31 + indexedAt: serializeDate(theme.indexedAt), 32 + }; 33 + } 34 + 35 + export function createThemesRoutes(ctx: AppContext) { 36 + return new Hono() 37 + .get("/", async (c) => { 38 + try { 39 + // Step 1: Get available theme URIs from this forum's policy 40 + const availableRows = await ctx.db 41 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 42 + .from(themePolicyAvailableThemes) 43 + .innerJoin( 44 + themePolicies, 45 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 46 + ) 47 + .where(eq(themePolicies.did, ctx.config.forumDid)); 48 + 49 + if (availableRows.length === 0) { 50 + return c.json({ themes: [] }); 51 + } 52 + 53 + // Step 2: Parse rkeys from AT-URIs 54 + const rkeys = availableRows 55 + .map((r) => parseAtUri(r.themeUri)?.rkey) 56 + .filter((rkey): rkey is string => !!rkey); 57 + 58 + if (rkeys.length === 0) { 59 + return c.json({ themes: [] }); 60 + } 61 + 62 + // Step 3: Fetch matching themes 63 + const themeList = await ctx.db 64 + .select() 65 + .from(themes) 66 + .where( 67 + and( 68 + eq(themes.did, ctx.config.forumDid), 69 + inArray(themes.rkey, rkeys) 70 + ) 71 + ) 72 + .limit(100); 73 + 74 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 75 + } catch (error) { 76 + return handleRouteError(c, error, "Failed to retrieve themes", { 77 + operation: "GET /api/themes", 78 + logger: ctx.logger, 79 + }); 80 + } 81 + }) 82 + .get("/:rkey", async (c) => { 83 + const rkey = c.req.param("rkey").trim(); 84 + if (!rkey) { 85 + return c.json({ error: "Invalid theme rkey" }, 400); 86 + } 87 + 88 + try { 89 + const [theme] = await ctx.db 90 + .select() 91 + .from(themes) 92 + .where( 93 + and( 94 + eq(themes.did, ctx.config.forumDid), 95 + eq(themes.rkey, rkey) 96 + ) 97 + ) 98 + .limit(1); 99 + 100 + if (!theme) { 101 + return c.json({ error: "Theme not found" }, 404); 102 + } 103 + 104 + return c.json(serializeThemeFull(theme)); 105 + } catch (error) { 106 + return handleRouteError(c, error, "Failed to retrieve theme", { 107 + operation: "GET /api/themes/:rkey", 108 + logger: ctx.logger, 109 + themeRkey: rkey, 110 + }); 111 + } 112 + }); 113 + } 114 + 115 + export function createThemePolicyRoutes(ctx: AppContext) { 116 + return new Hono().get("/", async (c) => { 117 + try { 118 + const [policy] = await ctx.db 119 + .select() 120 + .from(themePolicies) 121 + .where(eq(themePolicies.did, ctx.config.forumDid)) 122 + .limit(1); 123 + 124 + if (!policy) { 125 + return c.json({ error: "Theme policy not found" }, 404); 126 + } 127 + 128 + const available = await ctx.db 129 + .select({ 130 + themeUri: themePolicyAvailableThemes.themeUri, 131 + themeCid: themePolicyAvailableThemes.themeCid, 132 + }) 133 + .from(themePolicyAvailableThemes) 134 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 135 + 136 + return c.json({ 137 + defaultLightThemeUri: policy.defaultLightThemeUri, 138 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 139 + allowUserChoice: policy.allowUserChoice, 140 + availableThemes: available.map((t) => ({ 141 + uri: t.themeUri, 142 + cid: t.themeCid, 143 + })), 144 + }); 145 + } catch (error) { 146 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 147 + operation: "GET /api/theme-policy", 148 + logger: ctx.logger, 149 + }); 150 + } 151 + }); 152 + }
+35
bruno/AppView API/Themes/Get Theme Policy.bru
··· 1 + meta { 2 + name: Get Theme Policy 3 + type: http 4 + seq: 3 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/theme-policy 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.allowUserChoice: isDefined 14 + res.body.availableThemes: isDefined 15 + } 16 + 17 + docs { 18 + Returns the forum's theme policy: which themes are available to users, 19 + the default light and dark themes, and whether users can pick their own. 20 + 21 + Returns: 22 + { 23 + "defaultLightThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 24 + "defaultDarkThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 25 + "allowUserChoice": true, 26 + "availableThemes": [ 27 + { "uri": "at://did:plc:.../space.atbb.forum.theme/...", "cid": "bafy..." } 28 + ] 29 + } 30 + 31 + Error codes: 32 + - 404: No theme policy published yet 33 + - 500: Server error 34 + - 503: Database temporarily unavailable 35 + }
+44
bruno/AppView API/Themes/Get Theme.bru
··· 1 + meta { 2 + name: Get Theme 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/themes/{{theme_rkey}} 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.name: isDefined 14 + res.body.tokens: isDefined 15 + } 16 + 17 + docs { 18 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 19 + for the theme identified by its rkey (TID). 20 + 21 + Set the theme_rkey environment variable to a valid theme rkey before running. 22 + 23 + Path params: 24 + - rkey: Theme record key (TID, e.g. 3lblexample) 25 + 26 + Returns: 27 + { 28 + "id": "1", 29 + "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 30 + "name": "Neobrutal Light", 31 + "colorScheme": "light", 32 + "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 33 + "cssOverrides": null, 34 + "fontUrls": null, 35 + "createdAt": "2026-03-01T00:00:00.000Z", 36 + "indexedAt": "2026-03-01T00:00:00.000Z" 37 + } 38 + 39 + Error codes: 40 + - 400: Invalid rkey (empty) 41 + - 404: Theme not found 42 + - 500: Server error 43 + - 503: Database temporarily unavailable 44 + }
+36
bruno/AppView API/Themes/List Available Themes.bru
··· 1 + meta { 2 + name: List Available Themes 3 + type: http 4 + seq: 1 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/themes 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.themes: isDefined 14 + } 15 + 16 + docs { 17 + Returns themes filtered to those in the forum's themePolicy.availableThemes. 18 + Returns an empty array if no theme policy has been published. 19 + 20 + Returns: 21 + { 22 + "themes": [ 23 + { 24 + "id": "1", 25 + "uri": "at://did:plc:.../space.atbb.forum.theme/...", 26 + "name": "Neobrutal Light", 27 + "colorScheme": "light", 28 + "indexedAt": "2026-03-01T00:00:00.000Z" 29 + } 30 + ] 31 + } 32 + 33 + Error codes: 34 + - 500: Server error 35 + - 503: Database temporarily unavailable 36 + }
+1214
docs/plans/2026-03-02-atb-55-theme-api.md
··· 1 + # ATB-55: Theme Read API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `themes`, `theme_policies`, and `theme_policy_available_themes` tables, firehose indexers, and three read-only REST endpoints (`GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy`) to the AppView. 6 + 7 + **Architecture:** Dual-schema (Postgres + SQLite): `jsonb`/`text({mode:"json"})` for `tokens`, `text[].array()`/`text({mode:"json"})` for `fontUrls`. A normalized join table (`theme_policy_available_themes`) enables SQL-level filtering in `GET /api/themes`. Routes follow the `createXxxRoutes(ctx)` factory pattern; indexer follows the `CollectionConfig<TRecord>` pattern. 8 + 9 + **Tech Stack:** Drizzle ORM, Hono, Vitest, `@atbb/lexicon` (themePolicy types must be regenerated), `drizzle-kit generate` for migrations. 10 + 11 + **Design doc:** `docs/plans/2026-03-02-theme-api-design.md` 12 + 13 + --- 14 + 15 + ## ⚠️ Pre-flight: Environment 16 + 17 + All commands require the devenv PATH. Prefix every command with: 18 + ```bash 19 + export DEVENV="$(git rev-parse --show-toplevel)/.devenv/profile/bin" 20 + export PATH="$DEVENV:/bin:/usr/bin:$PATH" 21 + ``` 22 + 23 + The **lexicon `build:types` step** uses `shopt -s globstar` and **must** run inside `devenv shell`: 24 + ```bash 25 + devenv shell -- pnpm --filter @atbb/lexicon build 26 + ``` 27 + 28 + --- 29 + 30 + ## Task 1: Rebuild Lexicon to Generate `themePolicy` TypeScript Types 31 + 32 + **Why:** `themePolicy.json` was produced in ATB-51 but `themePolicy.ts` was never generated. `SpaceAtbbForumThemePolicy` does not yet exist in `@atbb/lexicon`. 33 + 34 + **Files:** 35 + - No edits needed — just run the build 36 + 37 + **Step 1: Run the lexicon build inside devenv shell** 38 + 39 + ```bash 40 + devenv shell -- pnpm --filter @atbb/lexicon build 41 + ``` 42 + 43 + Expected: build succeeds. The `build:types` step runs `lex gen-api` over all JSON lexicons including `themePolicy.json`. 44 + 45 + **Step 2: Verify themePolicy types were generated** 46 + 47 + ```bash 48 + ls packages/lexicon/dist/types/types/space/atbb/forum/ 49 + ``` 50 + 51 + Expected: `themePolicy.ts` (and `themePolicy.js`, `themePolicy.d.ts`) now present alongside `theme.ts`. 52 + 53 + **Step 3: Verify the export is in the index** 54 + 55 + ```bash 56 + grep "ThemePolicy" packages/lexicon/dist/types/index.ts 57 + ``` 58 + 59 + Expected: lines like `import * as SpaceAtbbForumThemePolicy from ...` and `export * as SpaceAtbbForumThemePolicy`. 60 + 61 + **Step 4: Commit** 62 + 63 + ```bash 64 + git add packages/lexicon/dist/ 65 + git commit -m "chore(lexicon): rebuild dist to generate themePolicy TypeScript types" 66 + ``` 67 + 68 + --- 69 + 70 + ## Task 2: Add Three New Tables to the Postgres Schema 71 + 72 + **Files:** 73 + - Modify: `packages/db/src/schema.ts` 74 + 75 + **Step 1: Add `jsonb` to the Postgres import** 76 + 77 + In `packages/db/src/schema.ts`, change the import from `drizzle-orm/pg-core` to include `jsonb`: 78 + 79 + ```typescript 80 + import { 81 + pgTable, 82 + bigserial, 83 + text, 84 + timestamp, 85 + integer, 86 + boolean, 87 + bigint, 88 + uniqueIndex, 89 + index, 90 + primaryKey, 91 + jsonb, // ADD THIS 92 + } from "drizzle-orm/pg-core"; 93 + ``` 94 + 95 + **Step 2: Append the three new table definitions at the end of `schema.ts`** 96 + 97 + ```typescript 98 + // ── themes ────────────────────────────────────────────── 99 + // Theme definitions, owned by Forum DID. Multiple themes per forum. 100 + // Key: tid (multiple records per repo). 101 + export const themes = pgTable( 102 + "themes", 103 + { 104 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 105 + did: text("did").notNull(), 106 + rkey: text("rkey").notNull(), 107 + cid: text("cid").notNull(), 108 + name: text("name").notNull(), 109 + colorScheme: text("color_scheme").notNull(), // "light" | "dark" 110 + tokens: jsonb("tokens").notNull(), // design token key-value map 111 + cssOverrides: text("css_overrides"), 112 + fontUrls: text("font_urls").array(), 113 + createdAt: timestamp("created_at", { withTimezone: true }), 114 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 115 + }, 116 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 117 + ); 118 + 119 + // ── theme_policies ─────────────────────────────────────── 120 + // Singleton theme policy, owned by Forum DID. 121 + // Key: literal:self (rkey is always "self"). 122 + export const themePolicies = pgTable( 123 + "theme_policies", 124 + { 125 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 126 + did: text("did").notNull(), 127 + rkey: text("rkey").notNull(), 128 + cid: text("cid").notNull(), 129 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 130 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 131 + allowUserChoice: boolean("allow_user_choice").notNull(), 132 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 133 + }, 134 + (table) => [ 135 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 136 + ] 137 + ); 138 + 139 + // ── theme_policy_available_themes ──────────────────────── 140 + // Normalized join table: which themes does the policy expose to users? 141 + // ON DELETE CASCADE: deleting a policy row removes all its available-theme rows. 142 + export const themePolicyAvailableThemes = pgTable( 143 + "theme_policy_available_themes", 144 + { 145 + policyId: bigint("policy_id", { mode: "bigint" }) 146 + .notNull() 147 + .references(() => themePolicies.id, { onDelete: "cascade" }), 148 + themeUri: text("theme_uri").notNull(), 149 + themeCid: text("theme_cid").notNull(), 150 + }, 151 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 152 + ); 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 3: Add the Same Three Tables to the SQLite Schema 158 + 159 + **Files:** 160 + - Modify: `packages/db/src/schema.sqlite.ts` 161 + 162 + **Step 1: Append the three table definitions at the end of the file** 163 + 164 + Note: SQLite has no native `jsonb` or `text[]`. Use `text({ mode: "json" })` — Drizzle auto-serializes objects on insert and deserializes them on select. Use plain `text` for arrays too. 165 + 166 + ```typescript 167 + // ── themes ────────────────────────────────────────────── 168 + export const themes = sqliteTable( 169 + "themes", 170 + { 171 + id: integer("id").primaryKey({ autoIncrement: true }), 172 + did: text("did").notNull(), 173 + rkey: text("rkey").notNull(), 174 + cid: text("cid").notNull(), 175 + name: text("name").notNull(), 176 + colorScheme: text("color_scheme").notNull(), 177 + tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 178 + cssOverrides: text("css_overrides"), 179 + fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 180 + createdAt: integer("created_at", { mode: "timestamp" }), 181 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 182 + }, 183 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 184 + ); 185 + 186 + // ── theme_policies ─────────────────────────────────────── 187 + export const themePolicies = sqliteTable( 188 + "theme_policies", 189 + { 190 + id: integer("id").primaryKey({ autoIncrement: true }), 191 + did: text("did").notNull(), 192 + rkey: text("rkey").notNull(), 193 + cid: text("cid").notNull(), 194 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 195 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 196 + allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 197 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 198 + }, 199 + (table) => [ 200 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 201 + ] 202 + ); 203 + 204 + // ── theme_policy_available_themes ──────────────────────── 205 + export const themePolicyAvailableThemes = sqliteTable( 206 + "theme_policy_available_themes", 207 + { 208 + policyId: integer("policy_id") 209 + .notNull() 210 + .references(() => themePolicies.id, { onDelete: "cascade" }), 211 + themeUri: text("theme_uri").notNull(), 212 + themeCid: text("theme_cid").notNull(), 213 + }, 214 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 215 + ); 216 + ``` 217 + 218 + --- 219 + 220 + ## Task 4: Build `@atbb/db` and Generate Both Migrations 221 + 222 + **Files:** 223 + - Create (generated): `apps/appview/drizzle/0013_*.sql` 224 + - Create (generated): `apps/appview/drizzle-sqlite/0001_*.sql` 225 + 226 + **Step 1: Build the DB package so the new tables are compiled** 227 + 228 + ```bash 229 + pnpm --filter @atbb/db build 230 + ``` 231 + 232 + Expected: `packages/db/dist/` rebuilt with new table exports. 233 + 234 + **Step 2: Generate the Postgres migration** 235 + 236 + ```bash 237 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.postgres.config.ts 238 + ``` 239 + 240 + Expected: new file `apps/appview/drizzle/0013_*.sql` created with `CREATE TABLE themes`, `CREATE TABLE theme_policies`, `CREATE TABLE theme_policy_available_themes`. 241 + 242 + **Step 3: Generate the SQLite migration** 243 + 244 + ```bash 245 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.sqlite.config.ts 246 + ``` 247 + 248 + Expected: new file `apps/appview/drizzle-sqlite/0001_*.sql` created with the SQLite equivalents. 249 + 250 + **Step 4: Inspect the generated SQL to verify correctness** 251 + 252 + ```bash 253 + cat apps/appview/drizzle/0013_*.sql 254 + cat apps/appview/drizzle-sqlite/0001_*.sql 255 + ``` 256 + 257 + Check: three `CREATE TABLE` statements, correct column types, `REFERENCES theme_policies(id) ON DELETE CASCADE` on the join table. 258 + 259 + **Step 5: Commit** 260 + 261 + ```bash 262 + git add packages/db/src/ packages/db/dist/ apps/appview/drizzle/ apps/appview/drizzle-sqlite/ 263 + git commit -m "feat(db): add themes, theme_policies, theme_policy_available_themes tables" 264 + ``` 265 + 266 + --- 267 + 268 + ## Task 5: Update Test Context Cleanup for New Tables 269 + 270 + **Files:** 271 + - Modify: `apps/appview/src/lib/__tests__/test-context.ts` 272 + 273 + **Step 1: Add new table imports** 274 + 275 + At the top where tables are imported from `@atbb/db`, add: 276 + 277 + ```typescript 278 + import { 279 + forums, posts, users, categories, memberships, boards, roles, modActions, 280 + backfillProgress, backfillErrors, 281 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 282 + } from "@atbb/db"; 283 + ``` 284 + 285 + **Step 2: Add cleanup in `cleanDatabase()` — SQLite branch (delete ALL rows)** 286 + 287 + Inside the `if (isSqlite)` block, add before `await db.delete(forums)`: 288 + 289 + ```typescript 290 + // theme_policy_available_themes cascades from theme_policies, but explicit is clearer 291 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 292 + await db.delete(themePolicies).catch(() => {}); 293 + await db.delete(themes).catch(() => {}); 294 + ``` 295 + 296 + **Step 3: Add cleanup in `cleanDatabase()` — Postgres branch (delete by DID)** 297 + 298 + After the `roles` delete, add: 299 + 300 + ```typescript 301 + // Deleting themePolicies cascades to theme_policy_available_themes 302 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 303 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 304 + ``` 305 + 306 + **Step 4: Add the same cleanup in `cleanup()`** 307 + 308 + In the `cleanup()` function body, add after the `roles` delete: 309 + 310 + ```typescript 311 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 312 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 313 + ``` 314 + 315 + (The cascade handles `theme_policy_available_themes` automatically in both branches.) 316 + 317 + **Step 5: Verify the test context compiles** 318 + 319 + ```bash 320 + pnpm --filter @atbb/appview exec tsc --noEmit 321 + ``` 322 + 323 + Expected: no errors. 324 + 325 + --- 326 + 327 + ## Task 6: Write Failing Tests for All Three Theme Endpoints 328 + 329 + **Files:** 330 + - Create: `apps/appview/src/routes/__tests__/themes.test.ts` 331 + 332 + Write the full test file — these tests will fail until Task 7 creates the route file. 333 + 334 + ```typescript 335 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 336 + import { Hono } from "hono"; 337 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 338 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 339 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 340 + 341 + // ── GET /api/themes ────────────────────────────────────── 342 + 343 + describe("GET /api/themes", () => { 344 + let ctx: TestContext; 345 + let app: Hono; 346 + 347 + beforeEach(async () => { 348 + ctx = await createTestContext(); 349 + app = new Hono() 350 + .route("/themes", createThemesRoutes(ctx)) 351 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 352 + }); 353 + 354 + afterEach(async () => { 355 + await ctx.cleanup(); 356 + }); 357 + 358 + it("returns empty array when no policy exists", async () => { 359 + const res = await app.request("/themes"); 360 + expect(res.status).toBe(200); 361 + const body = await res.json(); 362 + expect(body).toHaveProperty("themes"); 363 + expect(body.themes).toEqual([]); 364 + }); 365 + 366 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 367 + await ctx.db.insert(themes).values([ 368 + { 369 + did: ctx.config.forumDid, 370 + rkey: "3lbltheme1aa", 371 + cid: "bafytheme1", 372 + name: "Neobrutal Light", 373 + colorScheme: "light", 374 + tokens: { "color-bg": "#f5f0e8" }, 375 + indexedAt: new Date(), 376 + }, 377 + { 378 + did: ctx.config.forumDid, 379 + rkey: "3lbltheme2bb", 380 + cid: "bafytheme2", 381 + name: "Neobrutal Dark", 382 + colorScheme: "dark", 383 + tokens: { "color-bg": "#1a1a1a" }, 384 + indexedAt: new Date(), 385 + }, 386 + ]); 387 + 388 + const [policy] = await ctx.db 389 + .insert(themePolicies) 390 + .values({ 391 + did: ctx.config.forumDid, 392 + rkey: "self", 393 + cid: "bafypolicy1", 394 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 395 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 396 + allowUserChoice: true, 397 + indexedAt: new Date(), 398 + }) 399 + .returning(); 400 + 401 + // Only expose theme1 (light) — theme2 (dark) stays hidden 402 + await ctx.db.insert(themePolicyAvailableThemes).values({ 403 + policyId: policy.id, 404 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 405 + themeCid: "bafytheme1", 406 + }); 407 + 408 + const res = await app.request("/themes"); 409 + expect(res.status).toBe(200); 410 + const body = await res.json(); 411 + 412 + expect(body.themes).toHaveLength(1); 413 + expect(body.themes[0].name).toBe("Neobrutal Light"); 414 + // theme2 is in DB but NOT in availableThemes — must not appear 415 + const names = body.themes.map((t: any) => t.name); 416 + expect(names).not.toContain("Neobrutal Dark"); 417 + }); 418 + 419 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 420 + await ctx.db.insert(themes).values({ 421 + did: ctx.config.forumDid, 422 + rkey: "3lbltheme3cc", 423 + cid: "bafytheme3", 424 + name: "Clean Light", 425 + colorScheme: "light", 426 + tokens: { "color-bg": "#ffffff" }, 427 + cssOverrides: ".card { border-radius: 4px; }", 428 + indexedAt: new Date(), 429 + }); 430 + 431 + const [policy] = await ctx.db 432 + .insert(themePolicies) 433 + .values({ 434 + did: ctx.config.forumDid, 435 + rkey: "self", 436 + cid: "bafypolicy2", 437 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 438 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 439 + allowUserChoice: true, 440 + indexedAt: new Date(), 441 + }) 442 + .returning(); 443 + 444 + await ctx.db.insert(themePolicyAvailableThemes).values({ 445 + policyId: policy.id, 446 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 447 + themeCid: "bafytheme3", 448 + }); 449 + 450 + const res = await app.request("/themes"); 451 + const body = await res.json(); 452 + const theme = body.themes[0]; 453 + 454 + expect(theme).toHaveProperty("id"); 455 + expect(theme).toHaveProperty("uri"); 456 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 457 + expect(theme).toHaveProperty("name"); 458 + expect(theme).toHaveProperty("colorScheme"); 459 + expect(theme).toHaveProperty("indexedAt"); 460 + // List endpoint must NOT return full token set 461 + expect(theme).not.toHaveProperty("tokens"); 462 + expect(theme).not.toHaveProperty("cssOverrides"); 463 + expect(theme).not.toHaveProperty("fontUrls"); 464 + }); 465 + 466 + it("returns 503 on database error", async () => { 467 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 468 + throw new Error("Database connection lost"); 469 + }); 470 + 471 + const res = await app.request("/themes"); 472 + expect(res.status).toBe(503); 473 + }); 474 + }); 475 + 476 + // ── GET /api/themes/:rkey ──────────────────────────────── 477 + 478 + describe("GET /api/themes/:rkey", () => { 479 + let ctx: TestContext; 480 + let app: Hono; 481 + 482 + beforeEach(async () => { 483 + ctx = await createTestContext(); 484 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 485 + }); 486 + 487 + afterEach(async () => { 488 + await ctx.cleanup(); 489 + }); 490 + 491 + it("returns 404 for unknown rkey", async () => { 492 + const res = await app.request("/themes/nonexistent"); 493 + expect(res.status).toBe(404); 494 + const body = await res.json(); 495 + expect(body.error).toBeDefined(); 496 + }); 497 + 498 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 499 + await ctx.db.insert(themes).values({ 500 + did: ctx.config.forumDid, 501 + rkey: "3lblfulltest", 502 + cid: "bafyfull", 503 + name: "Neobrutal Light", 504 + colorScheme: "light", 505 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 506 + cssOverrides: ".btn { font-weight: 700; }", 507 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 508 + indexedAt: new Date(), 509 + }); 510 + 511 + const res = await app.request("/themes/3lblfulltest"); 512 + expect(res.status).toBe(200); 513 + const body = await res.json(); 514 + 515 + expect(body.name).toBe("Neobrutal Light"); 516 + expect(body.colorScheme).toBe("light"); 517 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 518 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 519 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 520 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 521 + expect(body.indexedAt).toBeDefined(); 522 + }); 523 + 524 + it("returns null for optional fields when not set", async () => { 525 + await ctx.db.insert(themes).values({ 526 + did: ctx.config.forumDid, 527 + rkey: "3lblminimal", 528 + cid: "bafymin", 529 + name: "Minimal", 530 + colorScheme: "light", 531 + tokens: { "color-bg": "#fff" }, 532 + indexedAt: new Date(), 533 + }); 534 + 535 + const res = await app.request("/themes/3lblminimal"); 536 + expect(res.status).toBe(200); 537 + const body = await res.json(); 538 + expect(body.cssOverrides).toBeNull(); 539 + expect(body.fontUrls).toBeNull(); 540 + }); 541 + 542 + it("returns 503 on database error", async () => { 543 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 544 + throw new Error("Database connection lost"); 545 + }); 546 + 547 + const res = await app.request("/themes/any-rkey"); 548 + expect(res.status).toBe(503); 549 + }); 550 + }); 551 + 552 + // ── GET /api/theme-policy ──────────────────────────────── 553 + 554 + describe("GET /api/theme-policy", () => { 555 + let ctx: TestContext; 556 + let app: Hono; 557 + 558 + beforeEach(async () => { 559 + ctx = await createTestContext(); 560 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 561 + }); 562 + 563 + afterEach(async () => { 564 + await ctx.cleanup(); 565 + }); 566 + 567 + it("returns 404 when no policy exists", async () => { 568 + const res = await app.request("/theme-policy"); 569 + expect(res.status).toBe(404); 570 + const body = await res.json(); 571 + expect(body.error).toBeDefined(); 572 + }); 573 + 574 + it("returns policy with correct fields", async () => { 575 + const [policy] = await ctx.db 576 + .insert(themePolicies) 577 + .values({ 578 + did: ctx.config.forumDid, 579 + rkey: "self", 580 + cid: "bafypol", 581 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 582 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 583 + allowUserChoice: false, 584 + indexedAt: new Date(), 585 + }) 586 + .returning(); 587 + 588 + await ctx.db.insert(themePolicyAvailableThemes).values([ 589 + { 590 + policyId: policy.id, 591 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 592 + themeCid: "bafylight", 593 + }, 594 + { 595 + policyId: policy.id, 596 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 597 + themeCid: "bafydark", 598 + }, 599 + ]); 600 + 601 + const res = await app.request("/theme-policy"); 602 + expect(res.status).toBe(200); 603 + const body = await res.json(); 604 + 605 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 606 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 607 + expect(body.allowUserChoice).toBe(false); 608 + expect(body.availableThemes).toHaveLength(2); 609 + expect(body.availableThemes[0]).toHaveProperty("uri"); 610 + expect(body.availableThemes[0]).toHaveProperty("cid"); 611 + }); 612 + 613 + it("returns 503 on database error", async () => { 614 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 615 + throw new Error("Database connection lost"); 616 + }); 617 + 618 + const res = await app.request("/theme-policy"); 619 + expect(res.status).toBe(503); 620 + }); 621 + }); 622 + ``` 623 + 624 + **Step 2: Run to verify the tests fail (expected — route file doesn't exist yet)** 625 + 626 + ```bash 627 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 628 + ``` 629 + 630 + Expected: FAIL with import error (module `../themes.js` not found) or TypeScript compile error. 631 + 632 + --- 633 + 634 + ## Task 7: Implement `routes/themes.ts` 635 + 636 + **Files:** 637 + - Create: `apps/appview/src/routes/themes.ts` 638 + 639 + ```typescript 640 + import { Hono } from "hono"; 641 + import type { AppContext } from "../lib/app-context.js"; 642 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 643 + import { eq, inArray, and } from "drizzle-orm"; 644 + import { serializeBigInt, serializeDate } from "./helpers.js"; 645 + import { handleRouteError } from "../lib/route-errors.js"; 646 + import { parseAtUri } from "../lib/at-uri.js"; 647 + 648 + type ThemeRow = typeof themes.$inferSelect; 649 + 650 + function serializeThemeSummary(theme: ThemeRow) { 651 + return { 652 + id: serializeBigInt(theme.id), 653 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 654 + name: theme.name, 655 + colorScheme: theme.colorScheme, 656 + indexedAt: serializeDate(theme.indexedAt), 657 + }; 658 + } 659 + 660 + function serializeThemeFull(theme: ThemeRow) { 661 + return { 662 + id: serializeBigInt(theme.id), 663 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 664 + name: theme.name, 665 + colorScheme: theme.colorScheme, 666 + tokens: theme.tokens, 667 + cssOverrides: theme.cssOverrides ?? null, 668 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 669 + createdAt: serializeDate(theme.createdAt), 670 + indexedAt: serializeDate(theme.indexedAt), 671 + }; 672 + } 673 + 674 + export function createThemesRoutes(ctx: AppContext) { 675 + return new Hono() 676 + .get("/", async (c) => { 677 + try { 678 + // Step 1: Get available theme URIs from this forum's policy 679 + const availableRows = await ctx.db 680 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 681 + .from(themePolicyAvailableThemes) 682 + .innerJoin( 683 + themePolicies, 684 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 685 + ) 686 + .where(eq(themePolicies.did, ctx.config.forumDid)); 687 + 688 + if (availableRows.length === 0) { 689 + return c.json({ themes: [] }); 690 + } 691 + 692 + // Step 2: Parse rkeys from AT-URIs 693 + const rkeys = availableRows 694 + .map((r) => parseAtUri(r.themeUri)?.rkey) 695 + .filter((rkey): rkey is string => !!rkey); 696 + 697 + if (rkeys.length === 0) { 698 + return c.json({ themes: [] }); 699 + } 700 + 701 + // Step 3: Fetch matching themes 702 + const themeList = await ctx.db 703 + .select() 704 + .from(themes) 705 + .where( 706 + and( 707 + eq(themes.did, ctx.config.forumDid), 708 + inArray(themes.rkey, rkeys) 709 + ) 710 + ) 711 + .limit(100); 712 + 713 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 714 + } catch (error) { 715 + return handleRouteError(c, error, "Failed to retrieve themes", { 716 + operation: "GET /api/themes", 717 + logger: ctx.logger, 718 + }); 719 + } 720 + }) 721 + .get("/:rkey", async (c) => { 722 + const rkey = c.req.param("rkey").trim(); 723 + if (!rkey) { 724 + return c.json({ error: "Invalid theme rkey" }, 400); 725 + } 726 + 727 + try { 728 + const [theme] = await ctx.db 729 + .select() 730 + .from(themes) 731 + .where( 732 + and( 733 + eq(themes.did, ctx.config.forumDid), 734 + eq(themes.rkey, rkey) 735 + ) 736 + ) 737 + .limit(1); 738 + 739 + if (!theme) { 740 + return c.json({ error: "Theme not found" }, 404); 741 + } 742 + 743 + return c.json(serializeThemeFull(theme)); 744 + } catch (error) { 745 + return handleRouteError(c, error, "Failed to retrieve theme", { 746 + operation: "GET /api/themes/:rkey", 747 + logger: ctx.logger, 748 + themeRkey: rkey, 749 + }); 750 + } 751 + }); 752 + } 753 + 754 + export function createThemePolicyRoutes(ctx: AppContext) { 755 + return new Hono().get("/", async (c) => { 756 + try { 757 + const [policy] = await ctx.db 758 + .select() 759 + .from(themePolicies) 760 + .where(eq(themePolicies.did, ctx.config.forumDid)) 761 + .limit(1); 762 + 763 + if (!policy) { 764 + return c.json({ error: "Theme policy not found" }, 404); 765 + } 766 + 767 + const available = await ctx.db 768 + .select({ 769 + themeUri: themePolicyAvailableThemes.themeUri, 770 + themeCid: themePolicyAvailableThemes.themeCid, 771 + }) 772 + .from(themePolicyAvailableThemes) 773 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 774 + 775 + return c.json({ 776 + defaultLightThemeUri: policy.defaultLightThemeUri, 777 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 778 + allowUserChoice: policy.allowUserChoice, 779 + availableThemes: available.map((t) => ({ 780 + uri: t.themeUri, 781 + cid: t.themeCid, 782 + })), 783 + }); 784 + } catch (error) { 785 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 786 + operation: "GET /api/theme-policy", 787 + logger: ctx.logger, 788 + }); 789 + } 790 + }); 791 + } 792 + ``` 793 + 794 + --- 795 + 796 + ## Task 8: Register Routes and Run Tests 797 + 798 + **Files:** 799 + - Modify: `apps/appview/src/routes/index.ts` 800 + 801 + **Step 1: Import and register the new routes** 802 + 803 + Add to the imports at the top: 804 + ```typescript 805 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 806 + ``` 807 + 808 + Add to the `createApiRoutes` function: 809 + ```typescript 810 + .route("/themes", createThemesRoutes(ctx)) 811 + .route("/theme-policy", createThemePolicyRoutes(ctx)) 812 + ``` 813 + 814 + Also add stub routes for the test-only `apiRoutes` export at the bottom: 815 + ```typescript 816 + const stubThemesRoutes = new Hono().get("/", (c) => c.json({ themes: [] })); 817 + const stubThemePolicyRoutes = new Hono().get("/", (c) => c.json({ error: "not found" }, 404)); 818 + ``` 819 + 820 + And register them on `apiRoutes`: 821 + ```typescript 822 + .route("/themes", stubThemesRoutes) 823 + .route("/theme-policy", stubThemePolicyRoutes) 824 + ``` 825 + 826 + **Step 2: Run the full theme test suite** 827 + 828 + ```bash 829 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 830 + ``` 831 + 832 + Expected: ALL tests PASS. 833 + 834 + **Step 3: Run the full test suite to catch regressions** 835 + 836 + ```bash 837 + pnpm --filter @atbb/appview exec vitest run 838 + ``` 839 + 840 + Expected: all tests pass. 841 + 842 + **Step 4: Commit** 843 + 844 + ```bash 845 + git add apps/appview/src/routes/themes.ts apps/appview/src/routes/index.ts \ 846 + apps/appview/src/routes/__tests__/themes.test.ts \ 847 + apps/appview/src/lib/__tests__/test-context.ts 848 + git commit -m "feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)" 849 + ``` 850 + 851 + --- 852 + 853 + ## Task 9: Add Indexer Configs for Theme and ThemePolicy 854 + 855 + **Files:** 856 + - Modify: `apps/appview/src/lib/indexer.ts` 857 + 858 + **Step 1: Add imports for new tables and lexicon types** 859 + 860 + At the top, add to the DB table imports: 861 + ```typescript 862 + import { 863 + posts, forums, categories, boards, users, memberships, modActions, roles, rolePermissions, 864 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 865 + } from "@atbb/db"; 866 + ``` 867 + 868 + Add to the lexicon imports: 869 + ```typescript 870 + import { 871 + SpaceAtbbPost as Post, 872 + SpaceAtbbForumForum as Forum, 873 + SpaceAtbbForumCategory as Category, 874 + SpaceAtbbForumBoard as Board, 875 + SpaceAtbbMembership as Membership, 876 + SpaceAtbbModAction as ModAction, 877 + SpaceAtbbForumRole as Role, 878 + SpaceAtbbForumTheme as Theme, // ADD 879 + SpaceAtbbForumThemePolicy as ThemePolicy, // ADD 880 + } from "@atbb/lexicon"; 881 + ``` 882 + 883 + **Step 2: Add `themeConfig` after the existing `boardConfig`** 884 + 885 + ```typescript 886 + private themeConfig: CollectionConfig<Theme.Record> = { 887 + name: "Theme", 888 + table: themes, 889 + deleteStrategy: "hard", 890 + toInsertValues: async (event, record) => ({ 891 + did: event.did, 892 + rkey: event.commit.rkey, 893 + cid: event.commit.cid, 894 + name: record.name, 895 + colorScheme: record.colorScheme as string, 896 + tokens: record.tokens, 897 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 898 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 899 + createdAt: record.createdAt ? new Date(record.createdAt as string) : null, 900 + indexedAt: new Date(), 901 + }), 902 + toUpdateValues: async (event, record) => ({ 903 + cid: event.commit.cid, 904 + name: record.name, 905 + colorScheme: record.colorScheme as string, 906 + tokens: record.tokens, 907 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 908 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 909 + indexedAt: new Date(), 910 + }), 911 + }; 912 + ``` 913 + 914 + **Step 3: Add `themePolicyConfig` after `themeConfig`** 915 + 916 + Note: `SpaceAtbbForumThemePolicy.Record` will be generated by Task 1. The `availableThemes`, `defaultLightTheme`, and `defaultDarkTheme` fields use `as any` because the lexicon generator emits `[k: string]: unknown` for complex refs. Adjust the casts if the generated interface exposes them explicitly. 917 + 918 + ```typescript 919 + private themePolicyConfig: CollectionConfig<ThemePolicy.Record> = { 920 + name: "ThemePolicy", 921 + table: themePolicies, 922 + deleteStrategy: "hard", 923 + toInsertValues: async (event, record) => ({ 924 + did: event.did, 925 + rkey: event.commit.rkey, 926 + cid: event.commit.cid, 927 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 928 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 929 + allowUserChoice: record.allowUserChoice as boolean, 930 + indexedAt: new Date(), 931 + }), 932 + toUpdateValues: async (event, record) => ({ 933 + cid: event.commit.cid, 934 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 935 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 936 + allowUserChoice: record.allowUserChoice as boolean, 937 + indexedAt: new Date(), 938 + }), 939 + afterUpsert: async (_event, record, policyId, tx) => { 940 + // Atomically replace all available-theme rows for this policy 941 + await tx 942 + .delete(themePolicyAvailableThemes) 943 + .where(eq(themePolicyAvailableThemes.policyId, policyId)); 944 + 945 + const available = (record.availableThemes as any[]) ?? []; 946 + if (available.length > 0) { 947 + await tx.insert(themePolicyAvailableThemes).values( 948 + available.map((themeRef: any) => ({ 949 + policyId, 950 + themeUri: themeRef.theme.uri as string, 951 + themeCid: themeRef.theme.cid as string, 952 + })) 953 + ); 954 + } 955 + }, 956 + }; 957 + ``` 958 + 959 + **Step 4: Wire the configs to handler methods** 960 + 961 + Find the section where `handlePostCreate`, `handleForumCreate`, etc. are defined (these are generated from `CollectionConfig` via a generic handler). Look for the pattern of how the indexer exposes public handler methods and wire `themeConfig` and `themePolicyConfig` the same way the existing configs are wired. 962 + 963 + Check the bottom of `indexer.ts` for the generic handler wiring. It likely looks like: 964 + ```typescript 965 + handleThemeCreate = this.createHandler(this.themeConfig, "create"); 966 + handleThemeUpdate = this.createHandler(this.themeConfig, "update"); 967 + handleThemeDelete = this.createHandler(this.themeConfig, "delete"); 968 + handleThemePolicyCreate = this.createHandler(this.themePolicyConfig, "create"); 969 + handleThemePolicyUpdate = this.createHandler(this.themePolicyConfig, "update"); 970 + handleThemePolicyDelete = this.createHandler(this.themePolicyConfig, "delete"); 971 + ``` 972 + 973 + > **Note:** Read the bottom 100 lines of `indexer.ts` first to understand the exact wiring pattern, then follow it exactly for theme and themePolicy. 974 + 975 + --- 976 + 977 + ## Task 10: Register Theme and ThemePolicy in the Firehose 978 + 979 + **Files:** 980 + - Modify: `apps/appview/src/lib/firehose.ts` 981 + 982 + **Step 1: Add two `.register()` blocks in `createHandlerRegistry()`** 983 + 984 + After the existing `.register({ collection: "space.atbb.reaction", ... })` block: 985 + 986 + ```typescript 987 + .register({ 988 + collection: "space.atbb.forum.theme", 989 + onCreate: this.createWrappedHandler("handleThemeCreate"), 990 + onUpdate: this.createWrappedHandler("handleThemeUpdate"), 991 + onDelete: this.createWrappedHandler("handleThemeDelete"), 992 + }) 993 + .register({ 994 + collection: "space.atbb.forum.themePolicy", 995 + onCreate: this.createWrappedHandler("handleThemePolicyCreate"), 996 + onUpdate: this.createWrappedHandler("handleThemePolicyUpdate"), 997 + onDelete: this.createWrappedHandler("handleThemePolicyDelete"), 998 + }) 999 + ``` 1000 + 1001 + **Step 2: Verify TypeScript compiles** 1002 + 1003 + ```bash 1004 + pnpm --filter @atbb/appview exec tsc --noEmit 1005 + ``` 1006 + 1007 + Expected: no errors. 1008 + 1009 + **Step 3: Run all tests** 1010 + 1011 + ```bash 1012 + pnpm --filter @atbb/appview exec vitest run 1013 + ``` 1014 + 1015 + Expected: all tests pass (indexer changes don't affect route tests). 1016 + 1017 + **Step 4: Commit** 1018 + 1019 + ```bash 1020 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/firehose.ts 1021 + git commit -m "feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)" 1022 + ``` 1023 + 1024 + --- 1025 + 1026 + ## Task 11: Write Bruno Collection Files 1027 + 1028 + **Files:** 1029 + - Create: `bruno/AppView API/Themes/List Available Themes.bru` 1030 + - Create: `bruno/AppView API/Themes/Get Theme.bru` 1031 + - Create: `bruno/AppView API/Themes/Get Theme Policy.bru` 1032 + 1033 + **`List Available Themes.bru`:** 1034 + ``` 1035 + meta { 1036 + name: List Available Themes 1037 + type: http 1038 + seq: 1 1039 + } 1040 + 1041 + get { 1042 + url: {{appview_url}}/api/themes 1043 + } 1044 + 1045 + assert { 1046 + res.status: eq 200 1047 + res.body.themes: isDefined 1048 + } 1049 + 1050 + docs { 1051 + Returns themes filtered to those in the forum's themePolicy.availableThemes. 1052 + Returns an empty array if no theme policy has been published. 1053 + 1054 + Returns: 1055 + { 1056 + "themes": [ 1057 + { 1058 + "id": "1", 1059 + "uri": "at://did:plc:.../space.atbb.forum.theme/...", 1060 + "name": "Neobrutal Light", 1061 + "colorScheme": "light", 1062 + "indexedAt": "2026-03-01T00:00:00.000Z" 1063 + } 1064 + ] 1065 + } 1066 + 1067 + Error codes: 1068 + - 500: Server error 1069 + - 503: Database temporarily unavailable 1070 + } 1071 + ``` 1072 + 1073 + **`Get Theme.bru`:** 1074 + ``` 1075 + meta { 1076 + name: Get Theme 1077 + type: http 1078 + seq: 2 1079 + } 1080 + 1081 + get { 1082 + url: {{appview_url}}/api/themes/{{theme_rkey}} 1083 + } 1084 + 1085 + assert { 1086 + res.status: eq 200 1087 + res.body.name: isDefined 1088 + res.body.tokens: isDefined 1089 + } 1090 + 1091 + docs { 1092 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 1093 + for the theme identified by its rkey (TID). 1094 + 1095 + Set the theme_rkey environment variable to a valid theme rkey before running. 1096 + 1097 + Path params: 1098 + - rkey: Theme record key (TID, e.g. 3lblexample) 1099 + 1100 + Returns: 1101 + { 1102 + "id": "1", 1103 + "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 1104 + "name": "Neobrutal Light", 1105 + "colorScheme": "light", 1106 + "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 1107 + "cssOverrides": null, 1108 + "fontUrls": null, 1109 + "createdAt": "2026-03-01T00:00:00.000Z", 1110 + "indexedAt": "2026-03-01T00:00:00.000Z" 1111 + } 1112 + 1113 + Error codes: 1114 + - 400: Invalid rkey (empty) 1115 + - 404: Theme not found 1116 + - 500: Server error 1117 + - 503: Database temporarily unavailable 1118 + } 1119 + ``` 1120 + 1121 + **`Get Theme Policy.bru`:** 1122 + ``` 1123 + meta { 1124 + name: Get Theme Policy 1125 + type: http 1126 + seq: 3 1127 + } 1128 + 1129 + get { 1130 + url: {{appview_url}}/api/theme-policy 1131 + } 1132 + 1133 + assert { 1134 + res.status: eq 200 1135 + res.body.allowUserChoice: isDefined 1136 + res.body.availableThemes: isDefined 1137 + } 1138 + 1139 + docs { 1140 + Returns the forum's theme policy: which themes are available to users, 1141 + the default light and dark themes, and whether users can pick their own. 1142 + 1143 + Returns: 1144 + { 1145 + "defaultLightThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1146 + "defaultDarkThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1147 + "allowUserChoice": true, 1148 + "availableThemes": [ 1149 + { "uri": "at://did:plc:.../space.atbb.forum.theme/...", "cid": "bafy..." } 1150 + ] 1151 + } 1152 + 1153 + Error codes: 1154 + - 404: No theme policy published yet 1155 + - 500: Server error 1156 + - 503: Database temporarily unavailable 1157 + } 1158 + ``` 1159 + 1160 + **Step 2: Commit** 1161 + 1162 + ```bash 1163 + git add bruno/AppView API/Themes/ 1164 + git commit -m "docs(bruno): add Themes API collection (ATB-55)" 1165 + ``` 1166 + 1167 + --- 1168 + 1169 + ## Task 12: Final Verification + Linear Update 1170 + 1171 + **Step 1: Run the full test suite** 1172 + 1173 + ```bash 1174 + pnpm --filter @atbb/appview exec vitest run 1175 + ``` 1176 + 1177 + Expected: all tests pass, no skipped tests. 1178 + 1179 + **Step 2: Typecheck the whole monorepo** 1180 + 1181 + ```bash 1182 + pnpm --filter @atbb/appview exec tsc --noEmit 1183 + pnpm --filter @atbb/db exec tsc --noEmit 1184 + ``` 1185 + 1186 + Expected: no errors. 1187 + 1188 + **Step 3: Update Linear** 1189 + 1190 + - Change ATB-55 status to **In Review** 1191 + - Add a comment: "Implementation complete. PRs: [link]. Endpoints: GET /api/themes, GET /api/themes/:rkey, GET /api/theme-policy. Indexer: space.atbb.forum.theme + space.atbb.forum.themePolicy." 1192 + 1193 + **Step 4: Push and open PR** 1194 + 1195 + ```bash 1196 + git push origin HEAD 1197 + gh pr create \ 1198 + --title "feat(appview): theme read API endpoints (ATB-55)" \ 1199 + --body "$(cat <<'EOF' 1200 + ## Summary 1201 + - Adds `themes`, `theme_policies`, `theme_policy_available_themes` tables to both Postgres and SQLite schemas 1202 + - Indexes `space.atbb.forum.theme` and `space.atbb.forum.themePolicy` from the Jetstream firehose 1203 + - Implements `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` read endpoints 1204 + - Bruno collection added for all three endpoints 1205 + 1206 + ## Test plan 1207 + - [ ] All theme route tests pass (`vitest run src/routes/__tests__/themes.test.ts`) 1208 + - [ ] Full suite passes (`pnpm --filter @atbb/appview exec vitest run`) 1209 + - [ ] TypeScript compiles clean (`tsc --noEmit`) 1210 + - [ ] Postgres migration reviewed (three new CREATE TABLE statements) 1211 + - [ ] SQLite migration reviewed 1212 + EOF 1213 + )" 1214 + ```
+197
docs/plans/2026-03-02-theme-api-design.md
··· 1 + # ATB-55: Theme Read API Endpoints — Design 2 + 3 + **Date:** 2026-03-02 4 + **Status:** Approved 5 + **Linear:** ATB-55 6 + **Depends on:** ATB-51 (theme + themePolicy lexicons — already shipped) 7 + 8 + --- 9 + 10 + ## Summary 11 + 12 + Add database tables, firehose indexers, and read-only REST endpoints for theme and 13 + theme policy data. The web UI (and future mobile clients) will consume these to 14 + resolve which CSS tokens to render per request. 15 + 16 + --- 17 + 18 + ## Database Schema 19 + 20 + Three new tables added to both `packages/db/src/schema.ts` (Postgres) and 21 + `packages/db/src/schema.sqlite.ts` (SQLite). All follow the existing 22 + `(did, rkey, cid, indexed_at)` pattern. 23 + 24 + ### `themes` 25 + 26 + | Column | Postgres | SQLite | Notes | 27 + |---|---|---|---| 28 + | `id` | `bigserial PK` | `integer PK autoIncrement` | | 29 + | `did` | `text NOT NULL` | `text NOT NULL` | Forum DID | 30 + | `rkey` | `text NOT NULL` | `text NOT NULL` | TID key | 31 + | `cid` | `text NOT NULL` | `text NOT NULL` | | 32 + | `name` | `text NOT NULL` | `text NOT NULL` | | 33 + | `colorScheme` | `text NOT NULL` | `text NOT NULL` | `"light"` or `"dark"` | 34 + | `tokens` | `jsonb NOT NULL` | `text NOT NULL` | SQLite: JSON string | 35 + | `cssOverrides` | `text` | `text` | Optional raw CSS | 36 + | `fontUrls` | `text[] ` | `text` | SQLite: JSON string array | 37 + | `createdAt` | `timestamp` | `integer (timestamp)` | | 38 + | `indexedAt` | `timestamp` | `integer (timestamp)` | | 39 + 40 + Indexes: `UNIQUE(did, rkey)` 41 + 42 + ### `theme_policies` 43 + 44 + Singleton per forum (rkey is always `"self"`). 45 + 46 + | Column | Postgres | SQLite | Notes | 47 + |---|---|---|---| 48 + | `id` | `bigserial PK` | `integer PK autoIncrement` | | 49 + | `did` | `text NOT NULL` | `text NOT NULL` | Forum DID | 50 + | `rkey` | `text NOT NULL` | `text NOT NULL` | Always `"self"` | 51 + | `cid` | `text NOT NULL` | `text NOT NULL` | | 52 + | `defaultLightThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI | 53 + | `defaultDarkThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI | 54 + | `allowUserChoice` | `boolean NOT NULL` | `integer (boolean)` | | 55 + | `indexedAt` | `timestamp` | `integer (timestamp)` | | 56 + 57 + Indexes: `UNIQUE(did, rkey)` 58 + 59 + ### `theme_policy_available_themes` 60 + 61 + Normalized join table for the `availableThemes` array from themePolicy records. 62 + Enables SQL-level filtering in `GET /api/themes` without application-layer iteration. 63 + 64 + | Column | Postgres | SQLite | Notes | 65 + |---|---|---|---| 66 + | `policyId` | `bigint FK → theme_policies.id ON DELETE CASCADE` | `integer FK` | | 67 + | `themeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI of the theme | 68 + | `themeCid` | `text NOT NULL` | `text NOT NULL` | CID for integrity | 69 + 70 + Primary key: `(policyId, themeUri)` 71 + 72 + --- 73 + 74 + ## Firehose Indexer 75 + 76 + Two new `CollectionConfig` entries in `apps/appview/src/lib/indexer.ts`, following 77 + the pattern established by `categoryConfig`, `roleConfig`, etc. 78 + 79 + ### `space.atbb.forum.theme` 80 + 81 + - `toInsertValues`: maps `name`, `colorScheme`, `tokens` (JSON.stringify for SQLite, 82 + raw object for Postgres), `cssOverrides`, `fontUrls` (array for Postgres, 83 + JSON.stringify for SQLite), `createdAt`, `indexedAt` 84 + - `toUpdateValues`: same fields minus `did` / `rkey` / `createdAt` 85 + - No `afterUpsert` needed 86 + 87 + ### `space.atbb.forum.themePolicy` 88 + 89 + - `toInsertValues`: maps `defaultLightTheme.theme.uri`, `defaultDarkTheme.theme.uri`, 90 + `allowUserChoice`, `indexedAt` 91 + - `afterUpsert`: atomically replaces `theme_policy_available_themes` rows for this 92 + policy — DELETE existing rows by `policyId`, then INSERT one row per entry in 93 + `record.availableThemes`. Same pattern as `roleConfig.afterUpsert` for permissions. 94 + 95 + Both collections registered in `firehose.ts` `createHandlerRegistry()`. 96 + 97 + --- 98 + 99 + ## API Endpoints 100 + 101 + New file: `apps/appview/src/routes/themes.ts` 102 + Factory function: `createThemesRoutes(ctx: AppContext)` 103 + Registered in `routes/index.ts` as `.route("/themes", createThemesRoutes(ctx))` 104 + 105 + ### `GET /api/themes` 106 + 107 + Returns themes filtered to those in `themePolicy.availableThemes` via SQL join. 108 + Returns `{ themes: [] }` when no policy exists (no 404). 109 + 110 + Query: 111 + ```sql 112 + SELECT t.* FROM themes t 113 + INNER JOIN theme_policy_available_themes tpa 114 + ON tpa.theme_uri = ('at://' || t.did || '/space.atbb.forum.theme/' || t.rkey) 115 + INNER JOIN theme_policies tp ON tp.id = tpa.policy_id 116 + ``` 117 + 118 + Response: `{ themes: [{ id, uri, name, colorScheme, indexedAt }] }` 119 + (Token summary only — full tokens are in the single-theme endpoint.) 120 + 121 + Error codes: 500 with structured logging for DB errors. 122 + 123 + ### `GET /api/themes/:rkey` 124 + 125 + Returns full theme data for a single theme identified by its rkey. 126 + The Forum DID comes from `ctx.config.forumDid`. 127 + 128 + Validation: 400 for empty/missing rkey (rkeys are TIDs, not BigInts — use string 129 + validation, not `parseBigIntParam`). 130 + 131 + Response: `{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }` 132 + 133 + Error codes: 400 (invalid rkey), 404 (theme not found), 500 (DB error). 134 + 135 + ### `GET /api/theme-policy` 136 + 137 + Returns the forum's singleton themePolicy record with its `availableThemes` list. 138 + Returns 404 when no policy exists. 139 + 140 + Queries `theme_policies` then assembles `availableThemes` from 141 + `theme_policy_available_themes` join. 142 + 143 + Response: 144 + ```json 145 + { 146 + "defaultLightThemeUri": "at://...", 147 + "defaultDarkThemeUri": "at://...", 148 + "allowUserChoice": true, 149 + "availableThemes": [{ "uri": "at://...", "cid": "..." }] 150 + } 151 + ``` 152 + 153 + Error codes: 404 (no policy), 500 (DB error). 154 + 155 + --- 156 + 157 + ## Tests 158 + 159 + File: `apps/appview/src/routes/__tests__/themes.test.ts` 160 + 161 + ### Happy path 162 + 163 + - `GET /api/themes` returns only themes listed in `availableThemes` (not all themes in DB) 164 + - `GET /api/themes/:rkey` returns full token set for a known theme 165 + - `GET /api/theme-policy` returns correct `allowUserChoice` and `availableThemes` 166 + 167 + ### Error / edge cases 168 + 169 + - `GET /api/themes` returns `{ themes: [] }` when no policy exists 170 + - `GET /api/themes/:rkey` returns 404 for unknown rkey 171 + - `GET /api/themes/:rkey` returns 400 for empty rkey 172 + - `GET /api/theme-policy` returns 404 when no policy exists 173 + - `GET /api/themes` does **not** include a theme that exists in DB but is absent from `availableThemes` 174 + 175 + --- 176 + 177 + ## Bruno Collection 178 + 179 + New folder: `bruno/AppView API/Themes/` 180 + 181 + | File | Method | URL | 182 + |---|---|---| 183 + | `List Available Themes.bru` | GET | `{{appview_url}}/api/themes` | 184 + | `Get Theme.bru` | GET | `{{appview_url}}/api/themes/{{theme_rkey}}` | 185 + | `Get Theme Policy.bru` | GET | `{{appview_url}}/api/theme-policy` | 186 + 187 + Each file documents all HTTP status codes the endpoint can return and uses 188 + environment variables for all URLs and test data. 189 + 190 + --- 191 + 192 + ## Out of Scope (ATB-55) 193 + 194 + - Write endpoints (`POST /api/themes`, `PUT /api/theme-policy`, etc.) — separate ticket 195 + - User theme preference (`PATCH /api/membership/theme`) — separate ticket 196 + - CSS sanitization for `cssOverrides` — required before admin write endpoints ship 197 + - Web UI theme resolution and injection into `BaseLayout`
+50
packages/db/src/schema.sqlite.ts
··· 249 249 }, 250 250 (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 251 251 ); 252 + 253 + // ── themes ────────────────────────────────────────────── 254 + export const themes = sqliteTable( 255 + "themes", 256 + { 257 + id: integer("id").primaryKey({ autoIncrement: true }), 258 + did: text("did").notNull(), 259 + rkey: text("rkey").notNull(), 260 + cid: text("cid").notNull(), 261 + name: text("name").notNull(), 262 + colorScheme: text("color_scheme").notNull(), 263 + tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 264 + cssOverrides: text("css_overrides"), 265 + fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 266 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 267 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 268 + }, 269 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 270 + ); 271 + 272 + // ── theme_policies ─────────────────────────────────────── 273 + export const themePolicies = sqliteTable( 274 + "theme_policies", 275 + { 276 + id: integer("id").primaryKey({ autoIncrement: true }), 277 + did: text("did").notNull(), 278 + rkey: text("rkey").notNull(), 279 + cid: text("cid").notNull(), 280 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 281 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 282 + allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 283 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 284 + }, 285 + (table) => [ 286 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 287 + ] 288 + ); 289 + 290 + // ── theme_policy_available_themes ──────────────────────── 291 + export const themePolicyAvailableThemes = sqliteTable( 292 + "theme_policy_available_themes", 293 + { 294 + policyId: integer("policy_id") 295 + .notNull() 296 + .references(() => themePolicies.id, { onDelete: "cascade" }), 297 + themeUri: text("theme_uri").notNull(), 298 + themeCid: text("theme_cid").notNull(), 299 + }, 300 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 301 + );
+57
packages/db/src/schema.ts
··· 6 6 integer, 7 7 boolean, 8 8 bigint, 9 + jsonb, 9 10 uniqueIndex, 10 11 index, 11 12 primaryKey, ··· 258 259 }, 259 260 (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 260 261 ); 262 + 263 + // ── themes ────────────────────────────────────────────── 264 + // Theme definitions, owned by Forum DID. Multiple themes per forum. 265 + // Key: tid (multiple records per repo). 266 + export const themes = pgTable( 267 + "themes", 268 + { 269 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 270 + did: text("did").notNull(), 271 + rkey: text("rkey").notNull(), 272 + cid: text("cid").notNull(), 273 + name: text("name").notNull(), 274 + colorScheme: text("color_scheme").notNull(), // "light" | "dark" 275 + tokens: jsonb("tokens").notNull(), // design token key-value map 276 + cssOverrides: text("css_overrides"), 277 + fontUrls: text("font_urls").array(), 278 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 279 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 280 + }, 281 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 282 + ); 283 + 284 + // ── theme_policies ─────────────────────────────────────── 285 + // Singleton theme policy, owned by Forum DID. 286 + // Key: literal:self (rkey is always "self"). 287 + export const themePolicies = pgTable( 288 + "theme_policies", 289 + { 290 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 291 + did: text("did").notNull(), 292 + rkey: text("rkey").notNull(), 293 + cid: text("cid").notNull(), 294 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 295 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 296 + allowUserChoice: boolean("allow_user_choice").notNull(), 297 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 298 + }, 299 + (table) => [ 300 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 301 + ] 302 + ); 303 + 304 + // ── theme_policy_available_themes ──────────────────────── 305 + // Normalized join table: which themes does the policy expose to users? 306 + // ON DELETE CASCADE: deleting a policy row removes all its available-theme rows. 307 + export const themePolicyAvailableThemes = pgTable( 308 + "theme_policy_available_themes", 309 + { 310 + policyId: bigint("policy_id", { mode: "bigint" }) 311 + .notNull() 312 + .references(() => themePolicies.id, { onDelete: "cascade" }), 313 + themeUri: text("theme_uri").notNull(), 314 + themeCid: text("theme_cid").notNull(), 315 + }, 316 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 317 + );