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

fix(appview): separate ban enforcement from user-initiated deletes (ATB-25) (#56)

* fix(indexer): strip title from reply records at index time (ATB-35)

When a space.atbb.post record carries both a reply ref and a title field,
the indexer now coerces title to null for both INSERT and UPDATE paths.
This enforces the lexicon invariant ("title is omitted for replies") at
the data-ingestion boundary rather than relying on the API or UI to ignore
the field.

ATB-36 is a duplicate of this issue and has been marked accordingly.

* fix(indexer): address PR review feedback (ATB-35)

- Add try-catch to getPostIdByUri, matching the structured error logging
pattern used by all other helper methods
- Warn when reply ref is present but missing $type (rootPostId/parentPostId
will be null; operators now have an observable signal)
- Reaction stub handlers: info → warn (events are being permanently
discarded, not successfully processed)
- Tests: extract createTrackingDb helper to eliminate inline mock duplication
- Tests: add topic-starter title-preserved assertions (right branch of ternary)
- Tests: assert full inserted shape on reply create (rootPostId/parentPostId
null, rootUri/parentUri populated) to document the $type-less behavior

* fix(appview): separate ban enforcement from user-initiated deletes (ATB-25)

- Add bannedByMod column to posts: applyBan/liftBan now use this column
exclusively, so lifting a ban can never resurrect user-deleted content
- Add deletedByUser column and tombstone user-initiated deletes: when a
post delete arrives from the firehose, the row is kept (for FK stability)
but text is replaced with '[user deleted this post]' and deletedByUser
is set to true — personal content is gone, thread structure is preserved
- Remove shared deleted column; all API filters now use bannedByMod=false
- Migrations: 0009 adds banned_by_mod, 0010 drops deleted / adds deleted_by_user

* test: fix schema column list test and strengthen tombstone assertion

- schema.test.ts: update "has expected columns for the unified post model"
to check for bannedByMod/deletedByUser instead of deleted (was missed in
the original commit, causing CI to fail)
- indexer.test.ts: replace weak toHaveBeenCalled() guard with exact
payload assertion per code review — verifies text and deletedByUser are
set, and that bannedByMod/deleted are never touched

* docs: correct genericDelete comment — posts always tombstone, no fallback

authored by

Malpercio and committed by
GitHub
027dfa38 6586811b

+2647 -69
+1
apps/appview/drizzle/0009_rich_bushwacker.sql
··· 1 + ALTER TABLE "posts" ADD COLUMN "banned_by_mod" boolean DEFAULT false NOT NULL;
+2
apps/appview/drizzle/0010_add_deleted_by_user.sql
··· 1 + ALTER TABLE "posts" ADD COLUMN "deleted_by_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint 2 + ALTER TABLE "posts" DROP COLUMN "deleted";
+1249
apps/appview/drizzle/meta/0009_snapshot.json
··· 1 + { 2 + "id": "14960cce-f002-4674-a602-87c464a8dc28", 3 + "prevId": "53a1f2f4-ad21-481d-ad60-8769c68353d2", 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 + "deleted": { 917 + "name": "deleted", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "banned_by_mod": { 924 + "name": "banned_by_mod", 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.roles": { 1075 + "name": "roles", 1076 + "schema": "", 1077 + "columns": { 1078 + "id": { 1079 + "name": "id", 1080 + "type": "bigserial", 1081 + "primaryKey": true, 1082 + "notNull": true 1083 + }, 1084 + "did": { 1085 + "name": "did", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + }, 1090 + "rkey": { 1091 + "name": "rkey", 1092 + "type": "text", 1093 + "primaryKey": false, 1094 + "notNull": true 1095 + }, 1096 + "cid": { 1097 + "name": "cid", 1098 + "type": "text", 1099 + "primaryKey": false, 1100 + "notNull": true 1101 + }, 1102 + "name": { 1103 + "name": "name", 1104 + "type": "text", 1105 + "primaryKey": false, 1106 + "notNull": true 1107 + }, 1108 + "description": { 1109 + "name": "description", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": false 1113 + }, 1114 + "permissions": { 1115 + "name": "permissions", 1116 + "type": "text[]", 1117 + "primaryKey": false, 1118 + "notNull": true, 1119 + "default": "'{}'::text[]" 1120 + }, 1121 + "priority": { 1122 + "name": "priority", 1123 + "type": "integer", 1124 + "primaryKey": false, 1125 + "notNull": true 1126 + }, 1127 + "created_at": { 1128 + "name": "created_at", 1129 + "type": "timestamp with time zone", 1130 + "primaryKey": false, 1131 + "notNull": true 1132 + }, 1133 + "indexed_at": { 1134 + "name": "indexed_at", 1135 + "type": "timestamp with time zone", 1136 + "primaryKey": false, 1137 + "notNull": true 1138 + } 1139 + }, 1140 + "indexes": { 1141 + "roles_did_rkey_idx": { 1142 + "name": "roles_did_rkey_idx", 1143 + "columns": [ 1144 + { 1145 + "expression": "did", 1146 + "isExpression": false, 1147 + "asc": true, 1148 + "nulls": "last" 1149 + }, 1150 + { 1151 + "expression": "rkey", 1152 + "isExpression": false, 1153 + "asc": true, 1154 + "nulls": "last" 1155 + } 1156 + ], 1157 + "isUnique": true, 1158 + "concurrently": false, 1159 + "method": "btree", 1160 + "with": {} 1161 + }, 1162 + "roles_did_idx": { 1163 + "name": "roles_did_idx", 1164 + "columns": [ 1165 + { 1166 + "expression": "did", 1167 + "isExpression": false, 1168 + "asc": true, 1169 + "nulls": "last" 1170 + } 1171 + ], 1172 + "isUnique": false, 1173 + "concurrently": false, 1174 + "method": "btree", 1175 + "with": {} 1176 + }, 1177 + "roles_did_name_idx": { 1178 + "name": "roles_did_name_idx", 1179 + "columns": [ 1180 + { 1181 + "expression": "did", 1182 + "isExpression": false, 1183 + "asc": true, 1184 + "nulls": "last" 1185 + }, 1186 + { 1187 + "expression": "name", 1188 + "isExpression": false, 1189 + "asc": true, 1190 + "nulls": "last" 1191 + } 1192 + ], 1193 + "isUnique": false, 1194 + "concurrently": false, 1195 + "method": "btree", 1196 + "with": {} 1197 + } 1198 + }, 1199 + "foreignKeys": {}, 1200 + "compositePrimaryKeys": {}, 1201 + "uniqueConstraints": {}, 1202 + "policies": {}, 1203 + "checkConstraints": {}, 1204 + "isRLSEnabled": false 1205 + }, 1206 + "public.users": { 1207 + "name": "users", 1208 + "schema": "", 1209 + "columns": { 1210 + "did": { 1211 + "name": "did", 1212 + "type": "text", 1213 + "primaryKey": true, 1214 + "notNull": true 1215 + }, 1216 + "handle": { 1217 + "name": "handle", 1218 + "type": "text", 1219 + "primaryKey": false, 1220 + "notNull": false 1221 + }, 1222 + "indexed_at": { 1223 + "name": "indexed_at", 1224 + "type": "timestamp with time zone", 1225 + "primaryKey": false, 1226 + "notNull": true 1227 + } 1228 + }, 1229 + "indexes": {}, 1230 + "foreignKeys": {}, 1231 + "compositePrimaryKeys": {}, 1232 + "uniqueConstraints": {}, 1233 + "policies": {}, 1234 + "checkConstraints": {}, 1235 + "isRLSEnabled": false 1236 + } 1237 + }, 1238 + "enums": {}, 1239 + "schemas": {}, 1240 + "sequences": {}, 1241 + "roles": {}, 1242 + "policies": {}, 1243 + "views": {}, 1244 + "_meta": { 1245 + "columns": {}, 1246 + "schemas": {}, 1247 + "tables": {} 1248 + } 1249 + }
+1249
apps/appview/drizzle/meta/0010_snapshot.json
··· 1 + { 2 + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", 3 + "prevId": "14960cce-f002-4674-a602-87c464a8dc28", 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.roles": { 1075 + "name": "roles", 1076 + "schema": "", 1077 + "columns": { 1078 + "id": { 1079 + "name": "id", 1080 + "type": "bigserial", 1081 + "primaryKey": true, 1082 + "notNull": true 1083 + }, 1084 + "did": { 1085 + "name": "did", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + }, 1090 + "rkey": { 1091 + "name": "rkey", 1092 + "type": "text", 1093 + "primaryKey": false, 1094 + "notNull": true 1095 + }, 1096 + "cid": { 1097 + "name": "cid", 1098 + "type": "text", 1099 + "primaryKey": false, 1100 + "notNull": true 1101 + }, 1102 + "name": { 1103 + "name": "name", 1104 + "type": "text", 1105 + "primaryKey": false, 1106 + "notNull": true 1107 + }, 1108 + "description": { 1109 + "name": "description", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": false 1113 + }, 1114 + "permissions": { 1115 + "name": "permissions", 1116 + "type": "text[]", 1117 + "primaryKey": false, 1118 + "notNull": true, 1119 + "default": "'{}'::text[]" 1120 + }, 1121 + "priority": { 1122 + "name": "priority", 1123 + "type": "integer", 1124 + "primaryKey": false, 1125 + "notNull": true 1126 + }, 1127 + "created_at": { 1128 + "name": "created_at", 1129 + "type": "timestamp with time zone", 1130 + "primaryKey": false, 1131 + "notNull": true 1132 + }, 1133 + "indexed_at": { 1134 + "name": "indexed_at", 1135 + "type": "timestamp with time zone", 1136 + "primaryKey": false, 1137 + "notNull": true 1138 + } 1139 + }, 1140 + "indexes": { 1141 + "roles_did_rkey_idx": { 1142 + "name": "roles_did_rkey_idx", 1143 + "columns": [ 1144 + { 1145 + "expression": "did", 1146 + "isExpression": false, 1147 + "asc": true, 1148 + "nulls": "last" 1149 + }, 1150 + { 1151 + "expression": "rkey", 1152 + "isExpression": false, 1153 + "asc": true, 1154 + "nulls": "last" 1155 + } 1156 + ], 1157 + "isUnique": true, 1158 + "concurrently": false, 1159 + "method": "btree", 1160 + "with": {} 1161 + }, 1162 + "roles_did_idx": { 1163 + "name": "roles_did_idx", 1164 + "columns": [ 1165 + { 1166 + "expression": "did", 1167 + "isExpression": false, 1168 + "asc": true, 1169 + "nulls": "last" 1170 + } 1171 + ], 1172 + "isUnique": false, 1173 + "concurrently": false, 1174 + "method": "btree", 1175 + "with": {} 1176 + }, 1177 + "roles_did_name_idx": { 1178 + "name": "roles_did_name_idx", 1179 + "columns": [ 1180 + { 1181 + "expression": "did", 1182 + "isExpression": false, 1183 + "asc": true, 1184 + "nulls": "last" 1185 + }, 1186 + { 1187 + "expression": "name", 1188 + "isExpression": false, 1189 + "asc": true, 1190 + "nulls": "last" 1191 + } 1192 + ], 1193 + "isUnique": false, 1194 + "concurrently": false, 1195 + "method": "btree", 1196 + "with": {} 1197 + } 1198 + }, 1199 + "foreignKeys": {}, 1200 + "compositePrimaryKeys": {}, 1201 + "uniqueConstraints": {}, 1202 + "policies": {}, 1203 + "checkConstraints": {}, 1204 + "isRLSEnabled": false 1205 + }, 1206 + "public.users": { 1207 + "name": "users", 1208 + "schema": "", 1209 + "columns": { 1210 + "did": { 1211 + "name": "did", 1212 + "type": "text", 1213 + "primaryKey": true, 1214 + "notNull": true 1215 + }, 1216 + "handle": { 1217 + "name": "handle", 1218 + "type": "text", 1219 + "primaryKey": false, 1220 + "notNull": false 1221 + }, 1222 + "indexed_at": { 1223 + "name": "indexed_at", 1224 + "type": "timestamp with time zone", 1225 + "primaryKey": false, 1226 + "notNull": true 1227 + } 1228 + }, 1229 + "indexes": {}, 1230 + "foreignKeys": {}, 1231 + "compositePrimaryKeys": {}, 1232 + "uniqueConstraints": {}, 1233 + "policies": {}, 1234 + "checkConstraints": {}, 1235 + "isRLSEnabled": false 1236 + } 1237 + }, 1238 + "enums": {}, 1239 + "schemas": {}, 1240 + "sequences": {}, 1241 + "roles": {}, 1242 + "policies": {}, 1243 + "views": {}, 1244 + "_meta": { 1245 + "columns": {}, 1246 + "schemas": {}, 1247 + "tables": {} 1248 + } 1249 + }
+14
apps/appview/drizzle/meta/_journal.json
··· 64 64 "when": 1771898269612, 65 65 "tag": "0008_flat_sauron", 66 66 "breakpoints": true 67 + }, 68 + { 69 + "idx": 9, 70 + "version": "7", 71 + "when": 1771951669278, 72 + "tag": "0009_rich_bushwacker", 73 + "breakpoints": true 74 + }, 75 + { 76 + "idx": 10, 77 + "version": "7", 78 + "when": 1771951670000, 79 + "tag": "0010_add_deleted_by_user", 80 + "breakpoints": true 67 81 } 68 82 ] 69 83 }
+6 -4
apps/appview/src/lib/__tests__/indexer-ban-enforcer.test.ts
··· 76 76 }); 77 77 78 78 describe("applyBan", () => { 79 - it("soft-deletes all posts for the subject DID", async () => { 79 + it("sets bannedByMod=true on all posts for the subject DID", async () => { 80 80 const mockWhere = vi.fn().mockResolvedValue(undefined); 81 81 const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); 82 82 (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet }); 83 83 84 84 await enforcer.applyBan("did:plc:banned123"); 85 85 86 - expect(mockSet).toHaveBeenCalledWith({ deleted: true }); 86 + expect(mockSet).toHaveBeenCalledWith({ bannedByMod: true }); 87 87 expect(mockWhere).toHaveBeenCalled(); 88 88 }); 89 89 ··· 100 100 }); 101 101 102 102 describe("liftBan", () => { 103 - it("restores all posts for the subject DID", async () => { 103 + it("sets bannedByMod=false on all posts for the subject DID without touching deleted", async () => { 104 104 const mockWhere = vi.fn().mockResolvedValue(undefined); 105 105 const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); 106 106 (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet }); 107 107 108 108 await enforcer.liftBan("did:plc:unbanned123"); 109 109 110 - expect(mockSet).toHaveBeenCalledWith({ deleted: false }); 110 + // Must only set bannedByMod — never touch deleted (user-initiated deletes must not be resurrected) 111 + expect(mockSet).toHaveBeenCalledWith({ bannedByMod: false }); 112 + expect(mockSet).not.toHaveBeenCalledWith(expect.objectContaining({ deleted: expect.anything() })); 111 113 expect(mockWhere).toHaveBeenCalled(); 112 114 }); 113 115
+18 -1
apps/appview/src/lib/__tests__/indexer.test.ts
··· 1054 1054 }); 1055 1055 1056 1056 describe("Delete Strategy Verification", () => { 1057 - it("should soft delete posts using db.update with deleted=true", async () => { 1057 + it("should tombstone posts using db.update (preserves row for FK stability)", async () => { 1058 + // Capture mockSet to verify the exact tombstone payload 1059 + const mockWhere = vi.fn().mockResolvedValue(undefined); 1060 + const mockSet = vi.fn().mockReturnValue({ where: mockWhere }); 1061 + (mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce({ set: mockSet }); 1062 + 1058 1063 const event: CommitDeleteEvent<"space.atbb.post"> = { 1059 1064 did: "did:plc:test", 1060 1065 time_us: 1234567890, ··· 1069 1074 1070 1075 await indexer.handlePostDelete(event); 1071 1076 1077 + // Tombstone: row is updated (content replaced), not hard-deleted 1072 1078 expect(mockDb.update).toHaveBeenCalled(); 1073 1079 expect(mockDb.delete).not.toHaveBeenCalled(); 1080 + // Must set the exact tombstone payload — not bannedByMod, not deleted 1081 + expect(mockSet).toHaveBeenCalledWith({ 1082 + text: "[user deleted this post]", 1083 + deletedByUser: true, 1084 + }); 1085 + expect(mockSet).not.toHaveBeenCalledWith( 1086 + expect.objectContaining({ bannedByMod: expect.anything() }) 1087 + ); 1088 + expect(mockSet).not.toHaveBeenCalledWith( 1089 + expect.objectContaining({ deleted: expect.anything() }) 1090 + ); 1074 1091 }); 1075 1092 1076 1093 it("should hard delete forums using db.delete", async () => {
+8 -12
apps/appview/src/lib/ban-enforcer.ts
··· 49 49 } 50 50 51 51 /** 52 - * Soft-deletes all posts for the given DID. 52 + * Hides all posts for the given DID from public view. 53 53 * Called when a ban mod action is indexed. 54 + * Uses bannedByMod column (not deleted) so user-initiated deletes are preserved. 54 55 */ 55 56 async applyBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> { 56 57 try { 57 58 await dbOrTx 58 59 .update(posts) 59 - .set({ deleted: true }) 60 + .set({ bannedByMod: true }) 60 61 .where(eq(posts.did, subjectDid)); 61 - this.logger.info("Applied ban: soft-deleted all posts", { subjectDid }); 62 + this.logger.info("Applied ban: hid all posts via bannedByMod", { subjectDid }); 62 63 } catch (error) { 63 64 this.logger.error("Failed to apply ban - posts may not be hidden", { 64 65 subjectDid, ··· 69 70 } 70 71 71 72 /** 72 - * Restores all posts for the given DID. 73 + * Unhides all mod-hidden posts for the given DID. 73 74 * Called when a ban mod action record is deleted (unban). 74 - * 75 - * NOTE (ATB-25): This unconditionally sets deleted = false for all posts 76 - * by the subject DID. The `deleted` column is shared between ban enforcement 77 - * and user-initiated deletes, so posts the user intentionally removed while 78 - * banned will be silently restored on unban. Fix requires a separate 79 - * `banned_by_mod` column. Tracked in ATB-25. 75 + * Only touches bannedByMod; user-initiated deletes (deleted=true) are preserved. 80 76 */ 81 77 async liftBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise<void> { 82 78 try { 83 79 await dbOrTx 84 80 .update(posts) 85 - .set({ deleted: false }) 81 + .set({ bannedByMod: false }) 86 82 .where(eq(posts.did, subjectDid)); 87 - this.logger.info("Lifted ban: restored all posts", { subjectDid }); 83 + this.logger.info("Lifted ban: unhid all posts via bannedByMod", { subjectDid }); 88 84 } catch (error) { 89 85 this.logger.error("Failed to lift ban - posts may not be restored", { 90 86 subjectDid,
+34 -26
apps/appview/src/lib/indexer.ts
··· 40 40 name: string; 41 41 /** Drizzle table reference */ 42 42 table: any; 43 - /** "soft" = set deleted=true, "hard" = DELETE FROM */ 44 - deleteStrategy: "soft" | "hard"; 43 + /** "hard" = DELETE FROM (all non-post collections) */ 44 + deleteStrategy: "hard"; 45 45 /** Call ensureUser(event.did) before insert? (user-owned records) */ 46 46 ensureUserOnCreate?: boolean; 47 47 /** ··· 81 81 private postConfig: CollectionConfig<Post.Record> = { 82 82 name: "Post", 83 83 table: posts, 84 - deleteStrategy: "soft", 84 + deleteStrategy: "hard", 85 85 ensureUserOnCreate: true, 86 86 toInsertValues: async (event, record, tx) => { 87 87 // Look up parent/root for replies ··· 559 559 } 560 560 561 561 /** 562 - * Generic delete handler. Performs a soft delete (set deleted=true) 563 - * or hard delete (DELETE FROM) depending on the config. 562 + * Generic delete handler. Hard-deletes a record (DELETE FROM). 563 + * Posts use handlePostDelete instead (always tombstone). 564 564 */ 565 565 private async genericDelete(config: CollectionConfig<any>, event: any) { 566 566 try { 567 - if (config.deleteStrategy === "soft") { 568 - await this.db 569 - .update(config.table) 570 - .set({ deleted: true }) 571 - .where( 572 - and( 573 - eq(config.table.did, event.did), 574 - eq(config.table.rkey, event.commit.rkey) 575 - ) 576 - ); 577 - } else { 578 - await this.db 579 - .delete(config.table) 580 - .where( 581 - and( 582 - eq(config.table.did, event.did), 583 - eq(config.table.rkey, event.commit.rkey) 584 - ) 585 - ); 586 - } 567 + await this.db 568 + .delete(config.table) 569 + .where( 570 + and( 571 + eq(config.table.did, event.did), 572 + eq(config.table.rkey, event.commit.rkey) 573 + ) 574 + ); 587 575 588 576 this.logger.info(`${config.name} deleted`, { 589 577 did: event.did, ··· 617 605 await this.genericUpdate(this.postConfig, event); 618 606 } 619 607 608 + /** 609 + * Handles a user-initiated post delete from the PDS. 610 + * Always tombstones: replaces personal content with a placeholder and marks 611 + * deletedByUser=true. The row is kept so threads referencing this post as 612 + * their root or parent remain intact. Personal content is gone; structure is preserved. 613 + */ 620 614 async handlePostDelete(event: CommitDeleteEvent<"space.atbb.post">) { 621 - await this.genericDelete(this.postConfig, event); 615 + const { did, commit: { rkey } } = event; 616 + try { 617 + await this.db 618 + .update(posts) 619 + .set({ text: "[user deleted this post]", deletedByUser: true }) 620 + .where(and(eq(posts.did, did), eq(posts.rkey, rkey))); 621 + this.logger.info("Post tombstoned: content replaced, structure preserved", { did, rkey }); 622 + } catch (error) { 623 + this.logger.error("Failed to tombstone post", { 624 + did, 625 + rkey, 626 + error: error instanceof Error ? error.message : String(error), 627 + }); 628 + throw error; 629 + } 622 630 } 623 631 624 632 // ── Forum Handlers ──────────────────────────────────────
+26 -3
apps/appview/src/routes/__tests__/boards.test.ts
··· 306 306 expect(data.error).toBe("Invalid board ID format"); 307 307 }); 308 308 309 - it("filters out deleted posts", async () => { 309 + it("includes tombstoned posts (deletedByUser=true) — structure preserved for thread display", async () => { 310 310 await ctx.db.insert(posts).values({ 311 311 did: "did:plc:topicsuser", 312 312 rkey: "post3", 313 313 cid: "bafypost3", 314 - text: "Deleted topic", 314 + text: "[user deleted this post]", 315 315 boardId: boardId, 316 316 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 317 - deleted: true, 317 + deletedByUser: true, 318 318 createdAt: new Date("2026-02-13T12:00:00Z"), 319 + indexedAt: new Date(), 320 + }); 321 + 322 + const res = await app.request(`/api/boards/${boardId}/topics`); 323 + expect(res.status).toBe(200); 324 + 325 + const data = await res.json(); 326 + // Tombstoned topics remain visible with replaced content 327 + expect(data.topics).toHaveLength(3); 328 + const tombstoned = data.topics.find((t: { text: string }) => t.text === "[user deleted this post]"); 329 + expect(tombstoned).toBeDefined(); 330 + }); 331 + 332 + it("filters out mod-banned posts (bannedByMod=true)", async () => { 333 + await ctx.db.insert(posts).values({ 334 + did: "did:plc:topicsuser", 335 + rkey: "post4", 336 + cid: "bafypost4", 337 + text: "Topic by banned author", 338 + boardId: boardId, 339 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 340 + bannedByMod: true, 341 + createdAt: new Date("2026-02-13T13:00:00Z"), 319 342 indexedAt: new Date(), 320 343 }); 321 344
+23 -7
apps/appview/src/routes/__tests__/helpers.test.ts
··· 228 228 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 229 229 createdAt: new Date(), 230 230 indexedAt: new Date(), 231 - deleted: false, 232 231 }).returning(); 233 232 topicId = topic.id; 234 233 ··· 245 244 parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 246 245 createdAt: new Date(), 247 246 indexedAt: new Date(), 248 - deleted: false, 249 247 }).returning(); 250 248 replyId = reply.id; 251 249 }); ··· 262 260 expect(result.get(replyId)?.rkey).toBe("3lbk8reply"); 263 261 }); 264 262 265 - it("excludes deleted posts", async () => { 266 - // Mark topic as deleted 263 + it("includes tombstoned posts (deletedByUser=true) — structure preserved for thread display", async () => { 264 + // Tombstone the topic: content replaced but row retained for FK stability 267 265 await ctx.db 268 266 .update(posts) 269 - .set({ deleted: true }) 267 + .set({ text: "[user deleted this post]", deletedByUser: true }) 268 + .where(eq(posts.id, topicId)); 269 + 270 + const result = await getPostsByIds(ctx.db, [topicId, replyId]); 271 + 272 + // Both rows are returned — the tombstone keeps thread structure intact 273 + expect(result.size).toBe(2); 274 + expect(result.has(topicId)).toBe(true); 275 + expect(result.get(topicId)?.text).toBe("[user deleted this post]"); 276 + expect(result.has(replyId)).toBe(true); 277 + }); 278 + 279 + it("excludes mod-banned posts (bannedByMod=true)", async () => { 280 + // Mark topic as hidden by mod ban — must NOT be returned 281 + await ctx.db 282 + .update(posts) 283 + .set({ bannedByMod: true }) 270 284 .where(eq(posts.id, topicId)); 271 285 272 286 const result = await getPostsByIds(ctx.db, [topicId, replyId]); ··· 342 356 parentUri: null, 343 357 createdAt: baseDate, 344 358 indexedAt: baseDate, 345 - deleted: false, 359 + deletedByUser: false, 360 + bannedByMod: false, 346 361 ...overrides, 347 362 }); 348 363 ··· 362 377 parentUri: "at://did:plc:topic-author/space.atbb.post/3lbk7topic", 363 378 createdAt: baseDate, 364 379 indexedAt: baseDate, 365 - deleted: false, 380 + deletedByUser: false, 381 + bannedByMod: false, 366 382 ...overrides, 367 383 }); 368 384
-5
apps/appview/src/routes/__tests__/posts.test.ts
··· 56 56 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 57 57 createdAt: new Date(), 58 58 indexedAt: new Date(), 59 - deleted: false, 60 59 }) 61 60 .returning({ id: posts.id }); 62 61 ··· 78 77 parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", 79 78 createdAt: new Date(), 80 79 indexedAt: new Date(), 81 - deleted: false, 82 80 }) 83 81 .returning({ id: posts.id }); 84 82 ··· 209 207 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 210 208 createdAt: new Date(), 211 209 indexedAt: new Date(), 212 - deleted: false, 213 210 }) 214 211 .returning({ id: posts.id }); 215 212 ··· 415 412 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 416 413 createdAt: new Date(), 417 414 indexedAt: new Date(), 418 - deleted: false, 419 415 }) 420 416 .returning({ id: posts.id }); 421 417 ··· 636 632 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 637 633 createdAt: new Date(), 638 634 indexedAt: new Date(), 639 - deleted: false, 640 635 }) 641 636 .returning({ id: posts.id }); 642 637
-2
apps/appview/src/routes/__tests__/topics.test.ts
··· 75 75 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 76 76 createdAt: new Date(), 77 77 indexedAt: new Date(), 78 - deleted: false, 79 78 }).returning(); 80 79 81 80 const res = await app.request(`/api/topics/${topic.id.toString()}`); ··· 102 101 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 103 102 createdAt: new Date(), 104 103 indexedAt: new Date(), 105 - deleted: false, 106 104 }).returning(); 107 105 108 106 const res = await app.request(`/api/topics/${topic.id.toString()}`);
+1 -1
apps/appview/src/routes/boards.ts
··· 85 85 const topicFilter = and( 86 86 eq(posts.boardId, boardId), 87 87 isNull(posts.rootPostId), // Topics only (not replies) 88 - eq(posts.deleted, false) 88 + eq(posts.bannedByMod, false) 89 89 ); 90 90 91 91 const [countResult, topicResults] = await Promise.all([
+1 -1
apps/appview/src/routes/helpers.ts
··· 183 183 const results = await db 184 184 .select() 185 185 .from(posts) 186 - .where(and(inArray(posts.id, ids), eq(posts.deleted, false))); 186 + .where(and(inArray(posts.id, ids), eq(posts.bannedByMod, false))); 187 187 188 188 return new Map(results.map((post) => [post.id, post])); 189 189 }
+2 -2
apps/appview/src/routes/topics.ts
··· 44 44 }) 45 45 .from(posts) 46 46 .leftJoin(users, eq(posts.did, users.did)) 47 - .where(and(eq(posts.id, topicId), eq(posts.deleted, false))) 47 + .where(and(eq(posts.id, topicId), eq(posts.bannedByMod, false))) 48 48 .limit(1); 49 49 50 50 if (!topicResult) { ··· 60 60 }) 61 61 .from(posts) 62 62 .leftJoin(users, eq(posts.did, users.did)) 63 - .where(and(eq(posts.rootPostId, topicId), eq(posts.deleted, false))) 63 + .where(and(eq(posts.rootPostId, topicId), eq(posts.bannedByMod, false))) 64 64 .orderBy(asc(posts.createdAt)) 65 65 .limit(1000); // Defensive limit, consistent with categories 66 66
+11 -4
packages/db/src/__tests__/schema.test.ts
··· 116 116 expect(cols).toHaveProperty("parentUri"); 117 117 expect(cols).toHaveProperty("createdAt"); 118 118 expect(cols).toHaveProperty("indexedAt"); 119 - expect(cols).toHaveProperty("deleted"); 119 + expect(cols).toHaveProperty("bannedByMod"); 120 + expect(cols).toHaveProperty("deletedByUser"); 120 121 }); 121 122 122 123 it("has text as not-null", () => { ··· 124 125 expect(cols.text.notNull).toBe(true); 125 126 }); 126 127 127 - it("has deleted defaulting to false", () => { 128 + it("has bannedByMod defaulting to false", () => { 129 + const cols = getTableColumns(posts); 130 + expect(cols.bannedByMod.notNull).toBe(true); 131 + expect(cols.bannedByMod.hasDefault).toBe(true); 132 + }); 133 + 134 + it("has deletedByUser defaulting to false", () => { 128 135 const cols = getTableColumns(posts); 129 - expect(cols.deleted.notNull).toBe(true); 130 - expect(cols.deleted.hasDefault).toBe(true); 136 + expect(cols.deletedByUser.notNull).toBe(true); 137 + expect(cols.deletedByUser.hasDefault).toBe(true); 131 138 }); 132 139 133 140 it("has rootPostId and parentPostId as nullable (topics have no parent)", () => {
+2 -1
packages/db/src/schema.ts
··· 141 141 parentUri: text("parent_uri"), 142 142 createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 143 143 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 144 - deleted: boolean("deleted").notNull().default(false), 144 + bannedByMod: boolean("banned_by_mod").notNull().default(false), 145 + deletedByUser: boolean("deleted_by_user").notNull().default(false), 145 146 }, 146 147 (table) => [ 147 148 uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey),