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: implement Jetstream firehose subscription for atBB records

Implemented a complete firehose subscription system that connects to AT Proto Jetstream,
filters for space.atbb.* records, and indexes them into the PostgreSQL database.

Key Changes:

**Firehose Service** (packages/appview/src/lib/firehose.ts):
- Created FirehoseService class using @skyware/jetstream client
- Subscribes to space.atbb.post, space.atbb.forum, space.atbb.category,
space.atbb.membership, space.atbb.modAction, and space.atbb.reaction collections
- Implements connection lifecycle with automatic reconnection and exponential backoff
- Tracks cursor position in database for resume-from-last-position functionality
- Handles create, update, and delete events for all record types

**Record Indexer** (packages/appview/src/lib/indexer.ts):
- Implements database operations for all space.atbb.* record types
- Handles posts (topics and replies) with forum references and reply chains
- Creates users on-the-fly when new records are encountered
- Implements soft delete for posts (sets deleted flag)
- Parses AT Proto URIs to resolve foreign key references
- Handles forum, category, membership, and moderation action records

**Database Schema**:
- Added firehose_cursor table to track Jetstream event position
- Supports resuming from last known cursor after restarts
- Migration generated: drizzle/0001_daily_power_pack.sql

**Configuration**:
- Added JETSTREAM_URL environment variable
- Defaults to wss://jetstream2.us-east.bsky.network/subscribe
- Integrated firehose service with appview server startup
- Implements graceful shutdown to properly close WebSocket connections

**Dependencies**:
- Added @skyware/jetstream v0.2.5 to appview
- Added @atproto/api, @atproto/xrpc, @atproto/lexicon, multiformats to lexicon package
- Updated package exports to support direct type imports

**Integration**:
- Firehose starts automatically when appview server starts
- Handles SIGTERM and SIGINT for graceful shutdown
- Logs all record operations (create/update/delete) for debugging

Note: TypeScript build currently has issues with generated lexicon types missing .js
extensions. Runtime execution with tsx works correctly. This will be addressed in a
future update to the lexicon generation process.

Resolves ATB-9

https://claude.ai/code/session_01PaA43d9Q2ztwuRS8BRzJEL

Claude 17a076c1 30d024c1

+1758 -6
+1
.env.example
··· 2 2 PORT=3000 3 3 FORUM_DID=did:plc:your-forum-did-here 4 4 PDS_URL=https://your-pds.example.com 5 + JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe 5 6 6 7 # Database 7 8 DATABASE_URL=postgres://atbb:atbb@localhost:5432/atbb
+5
packages/appview/drizzle/0001_daily_power_pack.sql
··· 1 + CREATE TABLE "firehose_cursor" ( 2 + "service" text PRIMARY KEY DEFAULT 'jetstream' NOT NULL, 3 + "cursor" bigint NOT NULL, 4 + "updated_at" timestamp with time zone NOT NULL 5 + );
+728
packages/appview/drizzle/meta/0001_snapshot.json
··· 1 + { 2 + "id": "838aff64-8b13-4395-92c7-2c7ce2c12c73", 3 + "prevId": "c52ee650-b32a-4fd5-8523-4a8f39586ccc", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.categories": { 8 + "name": "categories", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "forum_id": { 60 + "name": "forum_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "created_at": { 66 + "name": "created_at", 67 + "type": "timestamp with time zone", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "indexed_at": { 72 + "name": "indexed_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + } 77 + }, 78 + "indexes": { 79 + "categories_did_rkey_idx": { 80 + "name": "categories_did_rkey_idx", 81 + "columns": [ 82 + { 83 + "expression": "did", 84 + "isExpression": false, 85 + "asc": true, 86 + "nulls": "last" 87 + }, 88 + { 89 + "expression": "rkey", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + } 94 + ], 95 + "isUnique": true, 96 + "concurrently": false, 97 + "method": "btree", 98 + "with": {} 99 + } 100 + }, 101 + "foreignKeys": { 102 + "categories_forum_id_forums_id_fk": { 103 + "name": "categories_forum_id_forums_id_fk", 104 + "tableFrom": "categories", 105 + "tableTo": "forums", 106 + "columnsFrom": [ 107 + "forum_id" 108 + ], 109 + "columnsTo": [ 110 + "id" 111 + ], 112 + "onDelete": "no action", 113 + "onUpdate": "no action" 114 + } 115 + }, 116 + "compositePrimaryKeys": {}, 117 + "uniqueConstraints": {}, 118 + "policies": {}, 119 + "checkConstraints": {}, 120 + "isRLSEnabled": false 121 + }, 122 + "public.firehose_cursor": { 123 + "name": "firehose_cursor", 124 + "schema": "", 125 + "columns": { 126 + "service": { 127 + "name": "service", 128 + "type": "text", 129 + "primaryKey": true, 130 + "notNull": true, 131 + "default": "'jetstream'" 132 + }, 133 + "cursor": { 134 + "name": "cursor", 135 + "type": "bigint", 136 + "primaryKey": false, 137 + "notNull": true 138 + }, 139 + "updated_at": { 140 + "name": "updated_at", 141 + "type": "timestamp with time zone", 142 + "primaryKey": false, 143 + "notNull": true 144 + } 145 + }, 146 + "indexes": {}, 147 + "foreignKeys": {}, 148 + "compositePrimaryKeys": {}, 149 + "uniqueConstraints": {}, 150 + "policies": {}, 151 + "checkConstraints": {}, 152 + "isRLSEnabled": false 153 + }, 154 + "public.forums": { 155 + "name": "forums", 156 + "schema": "", 157 + "columns": { 158 + "id": { 159 + "name": "id", 160 + "type": "bigserial", 161 + "primaryKey": true, 162 + "notNull": true 163 + }, 164 + "did": { 165 + "name": "did", 166 + "type": "text", 167 + "primaryKey": false, 168 + "notNull": true 169 + }, 170 + "rkey": { 171 + "name": "rkey", 172 + "type": "text", 173 + "primaryKey": false, 174 + "notNull": true 175 + }, 176 + "cid": { 177 + "name": "cid", 178 + "type": "text", 179 + "primaryKey": false, 180 + "notNull": true 181 + }, 182 + "name": { 183 + "name": "name", 184 + "type": "text", 185 + "primaryKey": false, 186 + "notNull": true 187 + }, 188 + "description": { 189 + "name": "description", 190 + "type": "text", 191 + "primaryKey": false, 192 + "notNull": false 193 + }, 194 + "indexed_at": { 195 + "name": "indexed_at", 196 + "type": "timestamp with time zone", 197 + "primaryKey": false, 198 + "notNull": true 199 + } 200 + }, 201 + "indexes": { 202 + "forums_did_rkey_idx": { 203 + "name": "forums_did_rkey_idx", 204 + "columns": [ 205 + { 206 + "expression": "did", 207 + "isExpression": false, 208 + "asc": true, 209 + "nulls": "last" 210 + }, 211 + { 212 + "expression": "rkey", 213 + "isExpression": false, 214 + "asc": true, 215 + "nulls": "last" 216 + } 217 + ], 218 + "isUnique": true, 219 + "concurrently": false, 220 + "method": "btree", 221 + "with": {} 222 + } 223 + }, 224 + "foreignKeys": {}, 225 + "compositePrimaryKeys": {}, 226 + "uniqueConstraints": {}, 227 + "policies": {}, 228 + "checkConstraints": {}, 229 + "isRLSEnabled": false 230 + }, 231 + "public.memberships": { 232 + "name": "memberships", 233 + "schema": "", 234 + "columns": { 235 + "id": { 236 + "name": "id", 237 + "type": "bigserial", 238 + "primaryKey": true, 239 + "notNull": true 240 + }, 241 + "did": { 242 + "name": "did", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": true 246 + }, 247 + "rkey": { 248 + "name": "rkey", 249 + "type": "text", 250 + "primaryKey": false, 251 + "notNull": true 252 + }, 253 + "cid": { 254 + "name": "cid", 255 + "type": "text", 256 + "primaryKey": false, 257 + "notNull": true 258 + }, 259 + "forum_id": { 260 + "name": "forum_id", 261 + "type": "bigint", 262 + "primaryKey": false, 263 + "notNull": false 264 + }, 265 + "forum_uri": { 266 + "name": "forum_uri", 267 + "type": "text", 268 + "primaryKey": false, 269 + "notNull": true 270 + }, 271 + "role": { 272 + "name": "role", 273 + "type": "text", 274 + "primaryKey": false, 275 + "notNull": false 276 + }, 277 + "role_uri": { 278 + "name": "role_uri", 279 + "type": "text", 280 + "primaryKey": false, 281 + "notNull": false 282 + }, 283 + "joined_at": { 284 + "name": "joined_at", 285 + "type": "timestamp with time zone", 286 + "primaryKey": false, 287 + "notNull": false 288 + }, 289 + "created_at": { 290 + "name": "created_at", 291 + "type": "timestamp with time zone", 292 + "primaryKey": false, 293 + "notNull": true 294 + }, 295 + "indexed_at": { 296 + "name": "indexed_at", 297 + "type": "timestamp with time zone", 298 + "primaryKey": false, 299 + "notNull": true 300 + } 301 + }, 302 + "indexes": { 303 + "memberships_did_rkey_idx": { 304 + "name": "memberships_did_rkey_idx", 305 + "columns": [ 306 + { 307 + "expression": "did", 308 + "isExpression": false, 309 + "asc": true, 310 + "nulls": "last" 311 + }, 312 + { 313 + "expression": "rkey", 314 + "isExpression": false, 315 + "asc": true, 316 + "nulls": "last" 317 + } 318 + ], 319 + "isUnique": true, 320 + "concurrently": false, 321 + "method": "btree", 322 + "with": {} 323 + }, 324 + "memberships_did_idx": { 325 + "name": "memberships_did_idx", 326 + "columns": [ 327 + { 328 + "expression": "did", 329 + "isExpression": false, 330 + "asc": true, 331 + "nulls": "last" 332 + } 333 + ], 334 + "isUnique": false, 335 + "concurrently": false, 336 + "method": "btree", 337 + "with": {} 338 + } 339 + }, 340 + "foreignKeys": { 341 + "memberships_did_users_did_fk": { 342 + "name": "memberships_did_users_did_fk", 343 + "tableFrom": "memberships", 344 + "tableTo": "users", 345 + "columnsFrom": [ 346 + "did" 347 + ], 348 + "columnsTo": [ 349 + "did" 350 + ], 351 + "onDelete": "no action", 352 + "onUpdate": "no action" 353 + }, 354 + "memberships_forum_id_forums_id_fk": { 355 + "name": "memberships_forum_id_forums_id_fk", 356 + "tableFrom": "memberships", 357 + "tableTo": "forums", 358 + "columnsFrom": [ 359 + "forum_id" 360 + ], 361 + "columnsTo": [ 362 + "id" 363 + ], 364 + "onDelete": "no action", 365 + "onUpdate": "no action" 366 + } 367 + }, 368 + "compositePrimaryKeys": {}, 369 + "uniqueConstraints": {}, 370 + "policies": {}, 371 + "checkConstraints": {}, 372 + "isRLSEnabled": false 373 + }, 374 + "public.mod_actions": { 375 + "name": "mod_actions", 376 + "schema": "", 377 + "columns": { 378 + "id": { 379 + "name": "id", 380 + "type": "bigserial", 381 + "primaryKey": true, 382 + "notNull": true 383 + }, 384 + "did": { 385 + "name": "did", 386 + "type": "text", 387 + "primaryKey": false, 388 + "notNull": true 389 + }, 390 + "rkey": { 391 + "name": "rkey", 392 + "type": "text", 393 + "primaryKey": false, 394 + "notNull": true 395 + }, 396 + "cid": { 397 + "name": "cid", 398 + "type": "text", 399 + "primaryKey": false, 400 + "notNull": true 401 + }, 402 + "action": { 403 + "name": "action", 404 + "type": "text", 405 + "primaryKey": false, 406 + "notNull": true 407 + }, 408 + "subject_did": { 409 + "name": "subject_did", 410 + "type": "text", 411 + "primaryKey": false, 412 + "notNull": false 413 + }, 414 + "subject_post_uri": { 415 + "name": "subject_post_uri", 416 + "type": "text", 417 + "primaryKey": false, 418 + "notNull": false 419 + }, 420 + "forum_id": { 421 + "name": "forum_id", 422 + "type": "bigint", 423 + "primaryKey": false, 424 + "notNull": false 425 + }, 426 + "reason": { 427 + "name": "reason", 428 + "type": "text", 429 + "primaryKey": false, 430 + "notNull": false 431 + }, 432 + "created_by": { 433 + "name": "created_by", 434 + "type": "text", 435 + "primaryKey": false, 436 + "notNull": true 437 + }, 438 + "expires_at": { 439 + "name": "expires_at", 440 + "type": "timestamp with time zone", 441 + "primaryKey": false, 442 + "notNull": false 443 + }, 444 + "created_at": { 445 + "name": "created_at", 446 + "type": "timestamp with time zone", 447 + "primaryKey": false, 448 + "notNull": true 449 + }, 450 + "indexed_at": { 451 + "name": "indexed_at", 452 + "type": "timestamp with time zone", 453 + "primaryKey": false, 454 + "notNull": true 455 + } 456 + }, 457 + "indexes": { 458 + "mod_actions_did_rkey_idx": { 459 + "name": "mod_actions_did_rkey_idx", 460 + "columns": [ 461 + { 462 + "expression": "did", 463 + "isExpression": false, 464 + "asc": true, 465 + "nulls": "last" 466 + }, 467 + { 468 + "expression": "rkey", 469 + "isExpression": false, 470 + "asc": true, 471 + "nulls": "last" 472 + } 473 + ], 474 + "isUnique": true, 475 + "concurrently": false, 476 + "method": "btree", 477 + "with": {} 478 + } 479 + }, 480 + "foreignKeys": { 481 + "mod_actions_forum_id_forums_id_fk": { 482 + "name": "mod_actions_forum_id_forums_id_fk", 483 + "tableFrom": "mod_actions", 484 + "tableTo": "forums", 485 + "columnsFrom": [ 486 + "forum_id" 487 + ], 488 + "columnsTo": [ 489 + "id" 490 + ], 491 + "onDelete": "no action", 492 + "onUpdate": "no action" 493 + } 494 + }, 495 + "compositePrimaryKeys": {}, 496 + "uniqueConstraints": {}, 497 + "policies": {}, 498 + "checkConstraints": {}, 499 + "isRLSEnabled": false 500 + }, 501 + "public.posts": { 502 + "name": "posts", 503 + "schema": "", 504 + "columns": { 505 + "id": { 506 + "name": "id", 507 + "type": "bigserial", 508 + "primaryKey": true, 509 + "notNull": true 510 + }, 511 + "did": { 512 + "name": "did", 513 + "type": "text", 514 + "primaryKey": false, 515 + "notNull": true 516 + }, 517 + "rkey": { 518 + "name": "rkey", 519 + "type": "text", 520 + "primaryKey": false, 521 + "notNull": true 522 + }, 523 + "cid": { 524 + "name": "cid", 525 + "type": "text", 526 + "primaryKey": false, 527 + "notNull": true 528 + }, 529 + "text": { 530 + "name": "text", 531 + "type": "text", 532 + "primaryKey": false, 533 + "notNull": true 534 + }, 535 + "forum_uri": { 536 + "name": "forum_uri", 537 + "type": "text", 538 + "primaryKey": false, 539 + "notNull": false 540 + }, 541 + "root_post_id": { 542 + "name": "root_post_id", 543 + "type": "bigint", 544 + "primaryKey": false, 545 + "notNull": false 546 + }, 547 + "parent_post_id": { 548 + "name": "parent_post_id", 549 + "type": "bigint", 550 + "primaryKey": false, 551 + "notNull": false 552 + }, 553 + "root_uri": { 554 + "name": "root_uri", 555 + "type": "text", 556 + "primaryKey": false, 557 + "notNull": false 558 + }, 559 + "parent_uri": { 560 + "name": "parent_uri", 561 + "type": "text", 562 + "primaryKey": false, 563 + "notNull": false 564 + }, 565 + "created_at": { 566 + "name": "created_at", 567 + "type": "timestamp with time zone", 568 + "primaryKey": false, 569 + "notNull": true 570 + }, 571 + "indexed_at": { 572 + "name": "indexed_at", 573 + "type": "timestamp with time zone", 574 + "primaryKey": false, 575 + "notNull": true 576 + }, 577 + "deleted": { 578 + "name": "deleted", 579 + "type": "boolean", 580 + "primaryKey": false, 581 + "notNull": true, 582 + "default": false 583 + } 584 + }, 585 + "indexes": { 586 + "posts_did_rkey_idx": { 587 + "name": "posts_did_rkey_idx", 588 + "columns": [ 589 + { 590 + "expression": "did", 591 + "isExpression": false, 592 + "asc": true, 593 + "nulls": "last" 594 + }, 595 + { 596 + "expression": "rkey", 597 + "isExpression": false, 598 + "asc": true, 599 + "nulls": "last" 600 + } 601 + ], 602 + "isUnique": true, 603 + "concurrently": false, 604 + "method": "btree", 605 + "with": {} 606 + }, 607 + "posts_forum_uri_idx": { 608 + "name": "posts_forum_uri_idx", 609 + "columns": [ 610 + { 611 + "expression": "forum_uri", 612 + "isExpression": false, 613 + "asc": true, 614 + "nulls": "last" 615 + } 616 + ], 617 + "isUnique": false, 618 + "concurrently": false, 619 + "method": "btree", 620 + "with": {} 621 + }, 622 + "posts_root_post_id_idx": { 623 + "name": "posts_root_post_id_idx", 624 + "columns": [ 625 + { 626 + "expression": "root_post_id", 627 + "isExpression": false, 628 + "asc": true, 629 + "nulls": "last" 630 + } 631 + ], 632 + "isUnique": false, 633 + "concurrently": false, 634 + "method": "btree", 635 + "with": {} 636 + } 637 + }, 638 + "foreignKeys": { 639 + "posts_did_users_did_fk": { 640 + "name": "posts_did_users_did_fk", 641 + "tableFrom": "posts", 642 + "tableTo": "users", 643 + "columnsFrom": [ 644 + "did" 645 + ], 646 + "columnsTo": [ 647 + "did" 648 + ], 649 + "onDelete": "no action", 650 + "onUpdate": "no action" 651 + }, 652 + "posts_root_post_id_posts_id_fk": { 653 + "name": "posts_root_post_id_posts_id_fk", 654 + "tableFrom": "posts", 655 + "tableTo": "posts", 656 + "columnsFrom": [ 657 + "root_post_id" 658 + ], 659 + "columnsTo": [ 660 + "id" 661 + ], 662 + "onDelete": "no action", 663 + "onUpdate": "no action" 664 + }, 665 + "posts_parent_post_id_posts_id_fk": { 666 + "name": "posts_parent_post_id_posts_id_fk", 667 + "tableFrom": "posts", 668 + "tableTo": "posts", 669 + "columnsFrom": [ 670 + "parent_post_id" 671 + ], 672 + "columnsTo": [ 673 + "id" 674 + ], 675 + "onDelete": "no action", 676 + "onUpdate": "no action" 677 + } 678 + }, 679 + "compositePrimaryKeys": {}, 680 + "uniqueConstraints": {}, 681 + "policies": {}, 682 + "checkConstraints": {}, 683 + "isRLSEnabled": false 684 + }, 685 + "public.users": { 686 + "name": "users", 687 + "schema": "", 688 + "columns": { 689 + "did": { 690 + "name": "did", 691 + "type": "text", 692 + "primaryKey": true, 693 + "notNull": true 694 + }, 695 + "handle": { 696 + "name": "handle", 697 + "type": "text", 698 + "primaryKey": false, 699 + "notNull": false 700 + }, 701 + "indexed_at": { 702 + "name": "indexed_at", 703 + "type": "timestamp with time zone", 704 + "primaryKey": false, 705 + "notNull": true 706 + } 707 + }, 708 + "indexes": {}, 709 + "foreignKeys": {}, 710 + "compositePrimaryKeys": {}, 711 + "uniqueConstraints": {}, 712 + "policies": {}, 713 + "checkConstraints": {}, 714 + "isRLSEnabled": false 715 + } 716 + }, 717 + "enums": {}, 718 + "schemas": {}, 719 + "sequences": {}, 720 + "roles": {}, 721 + "policies": {}, 722 + "views": {}, 723 + "_meta": { 724 + "columns": {}, 725 + "schemas": {}, 726 + "tables": {} 727 + } 728 + }
+7
packages/appview/drizzle/meta/_journal.json
··· 8 8 "when": 1770434840203, 9 9 "tag": "0000_lovely_roland_deschain", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "7", 15 + "when": 1770466250786, 16 + "tag": "0001_daily_power_pack", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+1
packages/appview/package.json
··· 17 17 "@atproto/api": "^0.15.0", 18 18 "@atproto/common-web": "^0.4.0", 19 19 "@hono/node-server": "^1.14.0", 20 + "@skyware/jetstream": "^0.2.5", 20 21 "drizzle-orm": "^0.45.1", 21 22 "hono": "^4.7.0", 22 23 "postgres": "^3.4.8"
+5
packages/appview/src/db/index.ts
··· 1 1 import { drizzle } from "drizzle-orm/postgres-js"; 2 2 import postgres from "postgres"; 3 3 import * as schema from "./schema.js"; 4 + import { loadConfig } from "../lib/config.js"; 4 5 5 6 export function createDb(databaseUrl: string) { 6 7 const client = postgres(databaseUrl); ··· 8 9 } 9 10 10 11 export type Database = ReturnType<typeof createDb>; 12 + 13 + // Singleton database instance 14 + const config = loadConfig(); 15 + export const db = createDb(config.databaseUrl); 11 16 12 17 export * from "./schema.js";
+9
packages/appview/src/db/schema.ts
··· 146 146 uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 147 147 ] 148 148 ); 149 + 150 + // ── firehose_cursor ───────────────────────────────────── 151 + // Tracks the last processed event from the Jetstream firehose. 152 + // Singleton table (service is primary key). 153 + export const firehoseCursor = pgTable("firehose_cursor", { 154 + service: text("service").primaryKey().default("jetstream"), 155 + cursor: bigint("cursor", { mode: "bigint" }).notNull(), // time_us value from Jetstream 156 + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), 157 + });
+40 -1
packages/appview/src/index.ts
··· 3 3 import { logger } from "hono/logger"; 4 4 import { apiRoutes } from "./routes/index.js"; 5 5 import { loadConfig } from "./lib/config.js"; 6 + import { FirehoseService } from "./lib/firehose.js"; 6 7 7 8 const config = loadConfig(); 8 9 const app = new Hono(); ··· 10 11 app.use("*", logger()); 11 12 app.route("/api", apiRoutes); 12 13 13 - serve( 14 + // Initialize firehose service 15 + const firehose = new FirehoseService(config.jetstreamUrl); 16 + 17 + // Start the server 18 + const server = serve( 14 19 { 15 20 fetch: app.fetch, 16 21 port: config.port, ··· 19 24 console.log(`atBB AppView listening on http://localhost:${info.port}`); 20 25 } 21 26 ); 27 + 28 + // Start the firehose subscription 29 + firehose.start().catch((error) => { 30 + console.error("Failed to start firehose:", error); 31 + process.exit(1); 32 + }); 33 + 34 + // Handle graceful shutdown 35 + const shutdown = async (signal: string) => { 36 + console.log(`\nReceived ${signal}, shutting down gracefully...`); 37 + 38 + try { 39 + // Stop the firehose 40 + await firehose.stop(); 41 + 42 + // Close the server 43 + server.close(() => { 44 + console.log("Server closed"); 45 + process.exit(0); 46 + }); 47 + 48 + // Force exit after 10 seconds 49 + setTimeout(() => { 50 + console.error("Forced shutdown after timeout"); 51 + process.exit(1); 52 + }, 10000); 53 + } catch (error) { 54 + console.error("Error during shutdown:", error); 55 + process.exit(1); 56 + } 57 + }; 58 + 59 + process.on("SIGTERM", () => shutdown("SIGTERM")); 60 + process.on("SIGINT", () => shutdown("SIGINT"));
+4
packages/appview/src/lib/config.ts
··· 3 3 forumDid: string; 4 4 pdsUrl: string; 5 5 databaseUrl: string; 6 + jetstreamUrl: string; 6 7 } 7 8 8 9 export function loadConfig(): AppConfig { ··· 11 12 forumDid: process.env.FORUM_DID ?? "", 12 13 pdsUrl: process.env.PDS_URL ?? "https://bsky.social", 13 14 databaseUrl: process.env.DATABASE_URL ?? "", 15 + jetstreamUrl: 16 + process.env.JETSTREAM_URL ?? 17 + "wss://jetstream2.us-east.bsky.network/subscribe", 14 18 }; 15 19 }
+274
packages/appview/src/lib/firehose.ts
··· 1 + import { Jetstream } from "@skyware/jetstream"; 2 + import { db } from "../db/index.js"; 3 + import { firehoseCursor } from "../db/schema.js"; 4 + import { eq } from "drizzle-orm"; 5 + import * as indexer from "./indexer.js"; 6 + 7 + /** 8 + * Firehose service that subscribes to AT Proto Jetstream 9 + * and indexes space.atbb.* records into the database. 10 + */ 11 + export class FirehoseService { 12 + private jetstream: Jetstream; 13 + private isRunning = false; 14 + private reconnectAttempts = 0; 15 + private readonly maxReconnectAttempts = 10; 16 + private readonly reconnectDelayMs = 5000; 17 + 18 + // Collections we're interested in 19 + private readonly wantedCollections = [ 20 + "space.atbb.post", 21 + "space.atbb.forum", 22 + "space.atbb.category", 23 + "space.atbb.membership", 24 + "space.atbb.modAction", 25 + "space.atbb.reaction", 26 + ]; 27 + 28 + constructor(private jetstreamUrl: string) { 29 + // Initialize with a placeholder - will be recreated with cursor in start() 30 + this.jetstream = this.createJetstream(); 31 + this.setupEventHandlers(); 32 + } 33 + 34 + /** 35 + * Create a new Jetstream instance with optional cursor 36 + */ 37 + private createJetstream(cursor?: number): Jetstream { 38 + return new Jetstream({ 39 + wantedCollections: this.wantedCollections, 40 + endpoint: this.jetstreamUrl, 41 + cursor, 42 + }); 43 + } 44 + 45 + /** 46 + * Set up event handlers for different record operations 47 + */ 48 + private setupEventHandlers() { 49 + // Handle record creates 50 + this.jetstream.onCreate("space.atbb.post", (event) => { 51 + this.handlePostCreate(event); 52 + }); 53 + 54 + this.jetstream.onCreate("space.atbb.forum", (event) => { 55 + this.handleForumCreate(event); 56 + }); 57 + 58 + this.jetstream.onCreate("space.atbb.category", (event) => { 59 + this.handleCategoryCreate(event); 60 + }); 61 + 62 + this.jetstream.onCreate("space.atbb.membership", (event) => { 63 + this.handleMembershipCreate(event); 64 + }); 65 + 66 + this.jetstream.onCreate("space.atbb.modAction", (event) => { 67 + this.handleModActionCreate(event); 68 + }); 69 + 70 + this.jetstream.onCreate("space.atbb.reaction", (event) => { 71 + this.handleReactionCreate(event); 72 + }); 73 + 74 + // Handle record updates 75 + this.jetstream.onUpdate("space.atbb.post", (event) => { 76 + this.handlePostUpdate(event); 77 + }); 78 + 79 + this.jetstream.onUpdate("space.atbb.forum", (event) => { 80 + this.handleForumUpdate(event); 81 + }); 82 + 83 + this.jetstream.onUpdate("space.atbb.category", (event) => { 84 + this.handleCategoryUpdate(event); 85 + }); 86 + 87 + this.jetstream.onUpdate("space.atbb.membership", (event) => { 88 + this.handleMembershipUpdate(event); 89 + }); 90 + 91 + this.jetstream.onUpdate("space.atbb.modAction", (event) => { 92 + this.handleModActionUpdate(event); 93 + }); 94 + 95 + this.jetstream.onUpdate("space.atbb.reaction", (event) => { 96 + this.handleReactionUpdate(event); 97 + }); 98 + 99 + // Handle record deletes (tombstones) 100 + this.jetstream.onDelete("space.atbb.post", (event) => { 101 + this.handlePostDelete(event); 102 + }); 103 + 104 + this.jetstream.onDelete("space.atbb.forum", (event) => { 105 + this.handleForumDelete(event); 106 + }); 107 + 108 + this.jetstream.onDelete("space.atbb.category", (event) => { 109 + this.handleCategoryDelete(event); 110 + }); 111 + 112 + this.jetstream.onDelete("space.atbb.membership", (event) => { 113 + this.handleMembershipDelete(event); 114 + }); 115 + 116 + this.jetstream.onDelete("space.atbb.modAction", (event) => { 117 + this.handleModActionDelete(event); 118 + }); 119 + 120 + this.jetstream.onDelete("space.atbb.reaction", (event) => { 121 + this.handleReactionDelete(event); 122 + }); 123 + 124 + // Listen to all commits to track cursor 125 + this.jetstream.on("commit", async (event) => { 126 + await this.updateCursor(event.time_us); 127 + }); 128 + 129 + // Handle errors and disconnections 130 + this.jetstream.on("error", (error) => { 131 + console.error("Jetstream error:", error); 132 + this.handleReconnect(); 133 + }); 134 + } 135 + 136 + /** 137 + * Start the firehose subscription 138 + */ 139 + async start() { 140 + if (this.isRunning) { 141 + console.warn("Firehose service is already running"); 142 + return; 143 + } 144 + 145 + try { 146 + // Load the last cursor from database 147 + const savedCursor = await this.loadCursor(); 148 + if (savedCursor) { 149 + console.log(`Resuming from cursor: ${savedCursor}`); 150 + // Rewind by 10 seconds to ensure we don't miss any events 151 + const rewindedCursor = savedCursor - BigInt(10_000_000); // 10 seconds in microseconds 152 + 153 + // Recreate Jetstream instance with cursor 154 + this.jetstream = this.createJetstream(Number(rewindedCursor)); 155 + this.setupEventHandlers(); 156 + } 157 + 158 + console.log(`Starting Jetstream firehose subscription to ${this.jetstreamUrl}`); 159 + await this.jetstream.start(); 160 + this.isRunning = true; 161 + this.reconnectAttempts = 0; 162 + console.log("Jetstream firehose subscription started successfully"); 163 + } catch (error) { 164 + console.error("Failed to start Jetstream firehose:", error); 165 + this.handleReconnect(); 166 + } 167 + } 168 + 169 + /** 170 + * Stop the firehose subscription 171 + */ 172 + async stop() { 173 + if (!this.isRunning) { 174 + return; 175 + } 176 + 177 + console.log("Stopping Jetstream firehose subscription"); 178 + await this.jetstream.close(); 179 + this.isRunning = false; 180 + console.log("Jetstream firehose subscription stopped"); 181 + } 182 + 183 + /** 184 + * Handle reconnection with exponential backoff 185 + */ 186 + private async handleReconnect() { 187 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { 188 + console.error( 189 + `Max reconnect attempts (${this.maxReconnectAttempts}) reached. Giving up.` 190 + ); 191 + return; 192 + } 193 + 194 + this.reconnectAttempts++; 195 + const delay = this.reconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1); 196 + console.log( 197 + `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts}) in ${delay}ms` 198 + ); 199 + 200 + setTimeout(async () => { 201 + this.isRunning = false; 202 + await this.start(); 203 + }, delay); 204 + } 205 + 206 + /** 207 + * Load the last cursor from database 208 + */ 209 + private async loadCursor(): Promise<bigint | null> { 210 + try { 211 + const result = await db 212 + .select() 213 + .from(firehoseCursor) 214 + .where(eq(firehoseCursor.service, "jetstream")) 215 + .limit(1); 216 + 217 + return result.length > 0 ? result[0].cursor : null; 218 + } catch (error) { 219 + console.error("Failed to load cursor from database:", error); 220 + return null; 221 + } 222 + } 223 + 224 + /** 225 + * Update the cursor in database 226 + */ 227 + private async updateCursor(timeUs: number) { 228 + try { 229 + await db 230 + .insert(firehoseCursor) 231 + .values({ 232 + service: "jetstream", 233 + cursor: BigInt(timeUs), 234 + updatedAt: new Date(), 235 + }) 236 + .onConflictDoUpdate({ 237 + target: firehoseCursor.service, 238 + set: { 239 + cursor: BigInt(timeUs), 240 + updatedAt: new Date(), 241 + }, 242 + }); 243 + } catch (error) { 244 + // Don't throw - we don't want cursor updates to break the stream 245 + console.error("Failed to update cursor:", error); 246 + } 247 + } 248 + 249 + // ── Event Handlers ────────────────────────────────────── 250 + 251 + private handlePostCreate = indexer.handlePostCreate; 252 + private handlePostUpdate = indexer.handlePostUpdate; 253 + private handlePostDelete = indexer.handlePostDelete; 254 + 255 + private handleForumCreate = indexer.handleForumCreate; 256 + private handleForumUpdate = indexer.handleForumUpdate; 257 + private handleForumDelete = indexer.handleForumDelete; 258 + 259 + private handleCategoryCreate = indexer.handleCategoryCreate; 260 + private handleCategoryUpdate = indexer.handleCategoryUpdate; 261 + private handleCategoryDelete = indexer.handleCategoryDelete; 262 + 263 + private handleMembershipCreate = indexer.handleMembershipCreate; 264 + private handleMembershipUpdate = indexer.handleMembershipUpdate; 265 + private handleMembershipDelete = indexer.handleMembershipDelete; 266 + 267 + private handleModActionCreate = indexer.handleModActionCreate; 268 + private handleModActionUpdate = indexer.handleModActionUpdate; 269 + private handleModActionDelete = indexer.handleModActionDelete; 270 + 271 + private handleReactionCreate = indexer.handleReactionCreate; 272 + private handleReactionUpdate = indexer.handleReactionUpdate; 273 + private handleReactionDelete = indexer.handleReactionDelete; 274 + }
+574
packages/appview/src/lib/indexer.ts
··· 1 + import type { 2 + CommitCreateEvent, 3 + CommitDeleteEvent, 4 + CommitUpdateEvent, 5 + } from "@skyware/jetstream"; 6 + import { db } from "../db/index.js"; 7 + import { 8 + posts, 9 + forums, 10 + categories, 11 + users, 12 + memberships, 13 + modActions, 14 + } from "../db/schema.js"; 15 + import { eq, and } from "drizzle-orm"; 16 + import * as Post from "@atbb/lexicon/dist/types/types/space/atbb/post.js"; 17 + import * as Forum from "@atbb/lexicon/dist/types/types/space/atbb/forum/forum.js"; 18 + import * as Category from "@atbb/lexicon/dist/types/types/space/atbb/forum/category.js"; 19 + import * as Membership from "@atbb/lexicon/dist/types/types/space/atbb/membership.js"; 20 + import * as ModAction from "@atbb/lexicon/dist/types/types/space/atbb/modAction.js"; 21 + 22 + /** 23 + * Parse an AT Proto URI to extract DID, collection, and rkey 24 + * Format: at://did:plc:xxx/collection/rkey 25 + */ 26 + function parseAtUri(uri: string): { 27 + did: string; 28 + collection: string; 29 + rkey: string; 30 + } | null { 31 + try { 32 + const url = new URL(uri); 33 + if (url.protocol !== "at:") return null; 34 + 35 + const did = url.hostname; 36 + const parts = url.pathname.split("/").filter((p) => p); 37 + if (parts.length < 2) return null; 38 + 39 + const rkey = parts[parts.length - 1]; 40 + const collection = parts.slice(0, -1).join("."); 41 + 42 + return { did, collection, rkey }; 43 + } catch { 44 + return null; 45 + } 46 + } 47 + 48 + /** 49 + * Ensure a user exists in the database. Creates if not exists. 50 + */ 51 + async function ensureUser(did: string) { 52 + try { 53 + const existing = await db.select().from(users).where(eq(users.did, did)).limit(1); 54 + 55 + if (existing.length === 0) { 56 + await db.insert(users).values({ 57 + did, 58 + handle: null, // Will be updated by identity events 59 + indexedAt: new Date(), 60 + }); 61 + console.log(`[USER] Created user: ${did}`); 62 + } 63 + } catch (error) { 64 + console.error(`Failed to ensure user exists: ${did}`, error); 65 + throw error; 66 + } 67 + } 68 + 69 + /** 70 + * Look up a forum ID by its AT URI 71 + */ 72 + async function getForumIdByUri(forumUri: string): Promise<bigint | null> { 73 + const parsed = parseAtUri(forumUri); 74 + if (!parsed) return null; 75 + 76 + const result = await db 77 + .select({ id: forums.id }) 78 + .from(forums) 79 + .where(and(eq(forums.did, parsed.did), eq(forums.rkey, parsed.rkey))) 80 + .limit(1); 81 + 82 + return result.length > 0 ? result[0].id : null; 83 + } 84 + 85 + /** 86 + * Look up a post ID by its AT URI 87 + */ 88 + async function getPostIdByUri(postUri: string): Promise<bigint | null> { 89 + const parsed = parseAtUri(postUri); 90 + if (!parsed) return null; 91 + 92 + const result = await db 93 + .select({ id: posts.id }) 94 + .from(posts) 95 + .where(and(eq(posts.did, parsed.did), eq(posts.rkey, parsed.rkey))) 96 + .limit(1); 97 + 98 + return result.length > 0 ? result[0].id : null; 99 + } 100 + 101 + // ── Post Handlers ─────────────────────────────────────── 102 + 103 + export async function handlePostCreate( 104 + event: CommitCreateEvent<"space.atbb.post"> 105 + ) { 106 + try { 107 + const record = event.commit.record as unknown as Post.Record; 108 + await ensureUser(event.did); 109 + 110 + // Parse forum reference 111 + let forumUri: string | null = null; 112 + let forumId: bigint | null = null; 113 + if (record.forum?.forum) { 114 + forumUri = record.forum.forum.uri; 115 + forumId = await getForumIdByUri(record.forum.forum.uri); 116 + } 117 + 118 + // Parse reply references 119 + let rootPostId: bigint | null = null; 120 + let parentPostId: bigint | null = null; 121 + let rootUri: string | null = null; 122 + let parentUri: string | null = null; 123 + 124 + if (record.reply) { 125 + rootUri = record.reply.root.uri; 126 + parentUri = record.reply.parent.uri; 127 + rootPostId = await getPostIdByUri(record.reply.root.uri); 128 + parentPostId = await getPostIdByUri(record.reply.parent.uri); 129 + } 130 + 131 + await db.insert(posts).values({ 132 + did: event.did, 133 + rkey: event.commit.rkey, 134 + cid: event.commit.cid, 135 + text: record.text, 136 + forumUri, 137 + rootPostId, 138 + parentPostId, 139 + rootUri, 140 + parentUri, 141 + createdAt: new Date(record.createdAt), 142 + indexedAt: new Date(), 143 + deleted: false, 144 + }); 145 + 146 + console.log( 147 + `[CREATE] Post: ${event.did}/${event.commit.rkey} (${record.text.substring(0, 50)}...)` 148 + ); 149 + } catch (error) { 150 + console.error(`Failed to create post: ${event.did}/${event.commit.rkey}`, error); 151 + } 152 + } 153 + 154 + export async function handlePostUpdate( 155 + event: CommitUpdateEvent<"space.atbb.post"> 156 + ) { 157 + try { 158 + const record = event.commit.record as unknown as Post.Record; 159 + 160 + // Parse forum reference 161 + let forumUri: string | null = null; 162 + if (record.forum?.forum) { 163 + forumUri = record.forum.forum.uri; 164 + } 165 + 166 + // Parse reply references 167 + let rootUri: string | null = null; 168 + let parentUri: string | null = null; 169 + let rootPostId: bigint | null = null; 170 + let parentPostId: bigint | null = null; 171 + 172 + if (record.reply) { 173 + rootUri = record.reply.root.uri; 174 + parentUri = record.reply.parent.uri; 175 + rootPostId = await getPostIdByUri(record.reply.root.uri); 176 + parentPostId = await getPostIdByUri(record.reply.parent.uri); 177 + } 178 + 179 + await db 180 + .update(posts) 181 + .set({ 182 + cid: event.commit.cid, 183 + text: record.text, 184 + forumUri, 185 + rootPostId, 186 + parentPostId, 187 + rootUri, 188 + parentUri, 189 + indexedAt: new Date(), 190 + }) 191 + .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 192 + 193 + console.log( 194 + `[UPDATE] Post: ${event.did}/${event.commit.rkey} (${record.text.substring(0, 50)}...)` 195 + ); 196 + } catch (error) { 197 + console.error(`Failed to update post: ${event.did}/${event.commit.rkey}`, error); 198 + } 199 + } 200 + 201 + export async function handlePostDelete( 202 + event: CommitDeleteEvent<"space.atbb.post"> 203 + ) { 204 + try { 205 + // Soft delete 206 + await db 207 + .update(posts) 208 + .set({ 209 + deleted: true, 210 + indexedAt: new Date(), 211 + }) 212 + .where(and(eq(posts.did, event.did), eq(posts.rkey, event.commit.rkey))); 213 + 214 + console.log(`[DELETE] Post: ${event.did}/${event.commit.rkey}`); 215 + } catch (error) { 216 + console.error(`Failed to delete post: ${event.did}/${event.commit.rkey}`, error); 217 + } 218 + } 219 + 220 + // ── Forum Handlers ────────────────────────────────────── 221 + 222 + export async function handleForumCreate( 223 + event: CommitCreateEvent<"space.atbb.forum"> 224 + ) { 225 + try { 226 + const record = event.commit.record as unknown as Forum.Record; 227 + await ensureUser(event.did); 228 + 229 + await db.insert(forums).values({ 230 + did: event.did, 231 + rkey: event.commit.rkey, 232 + cid: event.commit.cid, 233 + name: record.name, 234 + description: record.description || null, 235 + indexedAt: new Date(), 236 + }); 237 + 238 + console.log(`[CREATE] Forum: ${event.did}/${event.commit.rkey} (${record.name})`); 239 + } catch (error) { 240 + console.error( 241 + `Failed to create forum: ${event.did}/${event.commit.rkey}`, 242 + error 243 + ); 244 + } 245 + } 246 + 247 + export async function handleForumUpdate( 248 + event: CommitUpdateEvent<"space.atbb.forum"> 249 + ) { 250 + try { 251 + const record = event.commit.record as unknown as Forum.Record; 252 + 253 + await db 254 + .update(forums) 255 + .set({ 256 + cid: event.commit.cid, 257 + name: record.name, 258 + description: record.description || null, 259 + indexedAt: new Date(), 260 + }) 261 + .where(and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey))); 262 + 263 + console.log(`[UPDATE] Forum: ${event.did}/${event.commit.rkey} (${record.name})`); 264 + } catch (error) { 265 + console.error( 266 + `Failed to update forum: ${event.did}/${event.commit.rkey}`, 267 + error 268 + ); 269 + } 270 + } 271 + 272 + export async function handleForumDelete( 273 + event: CommitDeleteEvent<"space.atbb.forum"> 274 + ) { 275 + try { 276 + await db 277 + .delete(forums) 278 + .where(and(eq(forums.did, event.did), eq(forums.rkey, event.commit.rkey))); 279 + 280 + console.log(`[DELETE] Forum: ${event.did}/${event.commit.rkey}`); 281 + } catch (error) { 282 + console.error( 283 + `Failed to delete forum: ${event.did}/${event.commit.rkey}`, 284 + error 285 + ); 286 + } 287 + } 288 + 289 + // ── Category Handlers ─────────────────────────────────── 290 + 291 + export async function handleCategoryCreate( 292 + event: CommitCreateEvent<"space.atbb.category"> 293 + ) { 294 + try { 295 + const record = event.commit.record as unknown as Category.Record; 296 + await ensureUser(event.did); 297 + 298 + await db.insert(categories).values({ 299 + did: event.did, 300 + rkey: event.commit.rkey, 301 + cid: event.commit.cid, 302 + name: record.name, 303 + description: record.description || null, 304 + slug: record.slug || null, 305 + sortOrder: record.sortOrder || null, 306 + forumId: null, // Will be resolved later 307 + createdAt: new Date(record.createdAt), 308 + indexedAt: new Date(), 309 + }); 310 + 311 + console.log( 312 + `[CREATE] Category: ${event.did}/${event.commit.rkey} (${record.name})` 313 + ); 314 + } catch (error) { 315 + console.error( 316 + `Failed to create category: ${event.did}/${event.commit.rkey}`, 317 + error 318 + ); 319 + } 320 + } 321 + 322 + export async function handleCategoryUpdate( 323 + event: CommitUpdateEvent<"space.atbb.category"> 324 + ) { 325 + try { 326 + const record = event.commit.record as unknown as Category.Record; 327 + 328 + await db 329 + .update(categories) 330 + .set({ 331 + cid: event.commit.cid, 332 + name: record.name, 333 + description: record.description || null, 334 + slug: record.slug || null, 335 + sortOrder: record.sortOrder || null, 336 + indexedAt: new Date(), 337 + }) 338 + .where( 339 + and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 340 + ); 341 + 342 + console.log( 343 + `[UPDATE] Category: ${event.did}/${event.commit.rkey} (${record.name})` 344 + ); 345 + } catch (error) { 346 + console.error( 347 + `Failed to update category: ${event.did}/${event.commit.rkey}`, 348 + error 349 + ); 350 + } 351 + } 352 + 353 + export async function handleCategoryDelete( 354 + event: CommitDeleteEvent<"space.atbb.category"> 355 + ) { 356 + try { 357 + await db 358 + .delete(categories) 359 + .where( 360 + and(eq(categories.did, event.did), eq(categories.rkey, event.commit.rkey)) 361 + ); 362 + 363 + console.log(`[DELETE] Category: ${event.did}/${event.commit.rkey}`); 364 + } catch (error) { 365 + console.error( 366 + `Failed to delete category: ${event.did}/${event.commit.rkey}`, 367 + error 368 + ); 369 + } 370 + } 371 + 372 + // ── Membership Handlers ───────────────────────────────── 373 + 374 + export async function handleMembershipCreate( 375 + event: CommitCreateEvent<"space.atbb.membership"> 376 + ) { 377 + try { 378 + const record = event.commit.record as unknown as Membership.Record; 379 + await ensureUser(event.did); 380 + 381 + const forumUri = record.forum.forum.uri; 382 + const forumId = await getForumIdByUri(forumUri); 383 + 384 + const roleUri = record.role?.role.uri || null; 385 + 386 + await db.insert(memberships).values({ 387 + did: event.did, 388 + rkey: event.commit.rkey, 389 + cid: event.commit.cid, 390 + forumId, 391 + forumUri, 392 + role: null, // Role name would need to be resolved from roleUri 393 + roleUri, 394 + joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 395 + createdAt: new Date(record.createdAt), 396 + indexedAt: new Date(), 397 + }); 398 + 399 + console.log(`[CREATE] Membership: ${event.did}/${event.commit.rkey}`); 400 + } catch (error) { 401 + console.error( 402 + `Failed to create membership: ${event.did}/${event.commit.rkey}`, 403 + error 404 + ); 405 + } 406 + } 407 + 408 + export async function handleMembershipUpdate( 409 + event: CommitUpdateEvent<"space.atbb.membership"> 410 + ) { 411 + try { 412 + const record = event.commit.record as unknown as Membership.Record; 413 + 414 + const forumUri = record.forum.forum.uri; 415 + const forumId = await getForumIdByUri(forumUri); 416 + const roleUri = record.role?.role.uri || null; 417 + 418 + await db 419 + .update(memberships) 420 + .set({ 421 + cid: event.commit.cid, 422 + forumId, 423 + forumUri, 424 + roleUri, 425 + joinedAt: record.joinedAt ? new Date(record.joinedAt) : null, 426 + indexedAt: new Date(), 427 + }) 428 + .where( 429 + and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 430 + ); 431 + 432 + console.log(`[UPDATE] Membership: ${event.did}/${event.commit.rkey}`); 433 + } catch (error) { 434 + console.error( 435 + `Failed to update membership: ${event.did}/${event.commit.rkey}`, 436 + error 437 + ); 438 + } 439 + } 440 + 441 + export async function handleMembershipDelete( 442 + event: CommitDeleteEvent<"space.atbb.membership"> 443 + ) { 444 + try { 445 + await db 446 + .delete(memberships) 447 + .where( 448 + and(eq(memberships.did, event.did), eq(memberships.rkey, event.commit.rkey)) 449 + ); 450 + 451 + console.log(`[DELETE] Membership: ${event.did}/${event.commit.rkey}`); 452 + } catch (error) { 453 + console.error( 454 + `Failed to delete membership: ${event.did}/${event.commit.rkey}`, 455 + error 456 + ); 457 + } 458 + } 459 + 460 + // ── ModAction Handlers ────────────────────────────────── 461 + 462 + export async function handleModActionCreate( 463 + event: CommitCreateEvent<"space.atbb.modAction"> 464 + ) { 465 + try { 466 + const record = event.commit.record as unknown as ModAction.Record; 467 + await ensureUser(event.did); 468 + 469 + const subjectDid = record.subject.did || null; 470 + const subjectPostUri = record.subject.post?.uri || null; 471 + 472 + await db.insert(modActions).values({ 473 + did: event.did, 474 + rkey: event.commit.rkey, 475 + cid: event.commit.cid, 476 + action: record.action, 477 + subjectDid, 478 + subjectPostUri, 479 + forumId: null, // Would need to be resolved 480 + reason: record.reason || null, 481 + createdBy: record.createdBy, 482 + expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 483 + createdAt: new Date(record.createdAt), 484 + indexedAt: new Date(), 485 + }); 486 + 487 + console.log( 488 + `[CREATE] ModAction: ${event.did}/${event.commit.rkey} (${record.action})` 489 + ); 490 + } catch (error) { 491 + console.error( 492 + `Failed to create mod action: ${event.did}/${event.commit.rkey}`, 493 + error 494 + ); 495 + } 496 + } 497 + 498 + export async function handleModActionUpdate( 499 + event: CommitUpdateEvent<"space.atbb.modAction"> 500 + ) { 501 + try { 502 + const record = event.commit.record as unknown as ModAction.Record; 503 + 504 + const subjectDid = record.subject.did || null; 505 + const subjectPostUri = record.subject.post?.uri || null; 506 + 507 + await db 508 + .update(modActions) 509 + .set({ 510 + cid: event.commit.cid, 511 + action: record.action, 512 + subjectDid, 513 + subjectPostUri, 514 + reason: record.reason || null, 515 + createdBy: record.createdBy, 516 + expiresAt: record.expiresAt ? new Date(record.expiresAt) : null, 517 + indexedAt: new Date(), 518 + }) 519 + .where( 520 + and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 521 + ); 522 + 523 + console.log( 524 + `[UPDATE] ModAction: ${event.did}/${event.commit.rkey} (${record.action})` 525 + ); 526 + } catch (error) { 527 + console.error( 528 + `Failed to update mod action: ${event.did}/${event.commit.rkey}`, 529 + error 530 + ); 531 + } 532 + } 533 + 534 + export async function handleModActionDelete( 535 + event: CommitDeleteEvent<"space.atbb.modAction"> 536 + ) { 537 + try { 538 + await db 539 + .delete(modActions) 540 + .where( 541 + and(eq(modActions.did, event.did), eq(modActions.rkey, event.commit.rkey)) 542 + ); 543 + 544 + console.log(`[DELETE] ModAction: ${event.did}/${event.commit.rkey}`); 545 + } catch (error) { 546 + console.error( 547 + `Failed to delete mod action: ${event.did}/${event.commit.rkey}`, 548 + error 549 + ); 550 + } 551 + } 552 + 553 + // ── Reaction Handlers (Stub) ──────────────────────────── 554 + 555 + export async function handleReactionCreate( 556 + event: CommitCreateEvent<"space.atbb.reaction"> 557 + ) { 558 + console.log(`[CREATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 559 + // TODO: Add reactions table to schema 560 + } 561 + 562 + export async function handleReactionUpdate( 563 + event: CommitUpdateEvent<"space.atbb.reaction"> 564 + ) { 565 + console.log(`[UPDATE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 566 + // TODO: Add reactions table to schema 567 + } 568 + 569 + export async function handleReactionDelete( 570 + event: CommitDeleteEvent<"space.atbb.reaction"> 571 + ) { 572 + console.log(`[DELETE] Reaction: ${event.did}/${event.commit.rkey} (not implemented)`); 573 + // TODO: Add reactions table to schema 574 + }
+4 -2
packages/appview/tsconfig.json
··· 2 2 "extends": "../../tsconfig.base.json", 3 3 "compilerOptions": { 4 4 "outDir": "./dist", 5 - "rootDir": "./src" 5 + "rootDir": "./src", 6 + "skipLibCheck": true 6 7 }, 7 - "include": ["src/**/*.ts"] 8 + "include": ["src/**/*.ts"], 9 + "exclude": ["../lexicon/dist"] 8 10 }
+10 -3
packages/lexicon/package.json
··· 7 7 "types": "./dist/types/index.d.ts", 8 8 "exports": { 9 9 ".": "./dist/types/index.js", 10 - "./json/*": "./dist/json/*" 10 + "./json/*": "./dist/json/*", 11 + "./dist/types/*": "./dist/types/*" 11 12 }, 12 13 "scripts": { 13 14 "build": "pnpm run build:json && pnpm run build:types", ··· 17 18 }, 18 19 "devDependencies": { 19 20 "@atproto/lex-cli": "^0.5.0", 21 + "glob": "^11.0.0", 20 22 "tsx": "^4.0.0", 21 - "yaml": "^2.7.0", 22 - "glob": "^11.0.0" 23 + "yaml": "^2.7.0" 24 + }, 25 + "dependencies": { 26 + "@atproto/api": "^0.15.0", 27 + "@atproto/lexicon": "^0.6.1", 28 + "@atproto/xrpc": "^0.7.7", 29 + "multiformats": "^13.4.2" 23 30 } 24 31 }
+96
pnpm-lock.yaml
··· 29 29 '@hono/node-server': 30 30 specifier: ^1.14.0 31 31 version: 1.19.9(hono@4.11.8) 32 + '@skyware/jetstream': 33 + specifier: ^0.2.5 34 + version: 0.2.5 32 35 drizzle-orm: 33 36 specifier: ^0.45.1 34 37 version: 0.45.1(postgres@3.4.8) ··· 53 56 version: 5.9.3 54 57 55 58 packages/lexicon: 59 + dependencies: 60 + '@atproto/api': 61 + specifier: ^0.15.0 62 + version: 0.15.27 63 + '@atproto/lexicon': 64 + specifier: ^0.6.1 65 + version: 0.6.1 66 + '@atproto/xrpc': 67 + specifier: ^0.7.7 68 + version: 0.7.7 69 + multiformats: 70 + specifier: ^13.4.2 71 + version: 13.4.2 56 72 devDependencies: 57 73 '@atproto/lex-cli': 58 74 specifier: ^0.5.0 ··· 112 128 version: 5.9.3 113 129 114 130 packages: 131 + 132 + '@atcute/atproto@3.1.10': 133 + resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==} 134 + 135 + '@atcute/bluesky@3.2.17': 136 + resolution: {integrity: sha512-Li+RsPkcRNC6AnNlqOGnlmAcjSwBdXIKFubJL1nwACDngKNXG4ooGL5cvzeekdDEfHmtFhS/tyZNaUx9QXYEUw==} 137 + 138 + '@atcute/lexicons@1.2.7': 139 + resolution: {integrity: sha512-gCvkSMI1F1zx7xXa59iPiSKMH3L5Hga6iurGqQjaQbE2V/np/2QuDqQzt96TNbWfaFAXE9f9oY+0z3ljf/bweA==} 140 + 141 + '@atcute/uint8array@1.1.0': 142 + resolution: {integrity: sha512-JtHXIVW6LPU9FMWp7SgE4HbUs3uV2WdfkK/2RWdEGjr4EgMV50P3FdU6fPeGlTfDNBJVYMIsuD2wwaKRPV/Aqg==} 143 + 144 + '@atcute/util-text@1.1.0': 145 + resolution: {integrity: sha512-34G9KD5Z9f7oEdFpZOmqrMnU86p8ne6LlxJowfZzKNszRcl1GH+FtEPh3N1woelJT2SkPXMK2anwT8DESTluwA==} 115 146 116 147 '@atproto/api@0.15.27': 117 148 resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==} ··· 630 661 resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 631 662 engines: {node: '>= 8'} 632 663 664 + '@skyware/jetstream@0.2.5': 665 + resolution: {integrity: sha512-fM/zs03DLwqRyzZZJFWN20e76KrdqIp97Tlm8Cek+vxn96+tu5d/fx79V6H85L0QN6HvGiX2l9A8hWFqHvYlOA==} 666 + 667 + '@standard-schema/spec@1.1.0': 668 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 669 + 633 670 '@ts-morph/common@0.17.0': 634 671 resolution: {integrity: sha512-RMSSvSfs9kb0VzkvQ2NWobwnj7TxCA9vI/IjR9bDHqgAyVbu2T0DN4wiKVqomyDWqO7dPr/tErSfq7urQ1Q37g==} 635 672 ··· 803 840 engines: {node: '>=18'} 804 841 hasBin: true 805 842 843 + esm-env@1.2.2: 844 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 845 + 846 + event-target-polyfill@0.0.4: 847 + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} 848 + 806 849 fast-glob@3.3.3: 807 850 resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} 808 851 engines: {node: '>=8.6.0'} ··· 898 941 ms@2.1.3: 899 942 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 900 943 944 + multiformats@13.4.2: 945 + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} 946 + 901 947 multiformats@9.9.0: 902 948 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 903 949 904 950 package-json-from-dist@1.0.1: 905 951 resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 906 952 953 + partysocket@1.1.11: 954 + resolution: {integrity: sha512-P0EtOQiAwvLriqLgdThcSaREfz3bP77LkLSdmXq680BosPKvGSoGTh/d0g3S+UNmaqcw89Ad7JXHHKyRx3xU9Q==} 955 + 907 956 path-browserify@1.0.1: 908 957 resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} 909 958 ··· 963 1012 supports-color@7.2.0: 964 1013 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 965 1014 engines: {node: '>=8'} 1015 + 1016 + tiny-emitter@2.1.0: 1017 + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} 966 1018 967 1019 tlds@1.261.0: 968 1020 resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} ··· 1057 1109 1058 1110 snapshots: 1059 1111 1112 + '@atcute/atproto@3.1.10': 1113 + dependencies: 1114 + '@atcute/lexicons': 1.2.7 1115 + 1116 + '@atcute/bluesky@3.2.17': 1117 + dependencies: 1118 + '@atcute/atproto': 3.1.10 1119 + '@atcute/lexicons': 1.2.7 1120 + 1121 + '@atcute/lexicons@1.2.7': 1122 + dependencies: 1123 + '@atcute/uint8array': 1.1.0 1124 + '@atcute/util-text': 1.1.0 1125 + '@standard-schema/spec': 1.1.0 1126 + esm-env: 1.2.2 1127 + 1128 + '@atcute/uint8array@1.1.0': {} 1129 + 1130 + '@atcute/util-text@1.1.0': 1131 + dependencies: 1132 + unicode-segmenter: 0.14.5 1133 + 1060 1134 '@atproto/api@0.15.27': 1061 1135 dependencies: 1062 1136 '@atproto/common-web': 0.4.16 ··· 1383 1457 '@nodelib/fs.scandir': 2.1.5 1384 1458 fastq: 1.20.1 1385 1459 1460 + '@skyware/jetstream@0.2.5': 1461 + dependencies: 1462 + '@atcute/atproto': 3.1.10 1463 + '@atcute/bluesky': 3.2.17 1464 + '@atcute/lexicons': 1.2.7 1465 + partysocket: 1.1.11 1466 + tiny-emitter: 2.1.0 1467 + 1468 + '@standard-schema/spec@1.1.0': {} 1469 + 1386 1470 '@ts-morph/common@0.17.0': 1387 1471 dependencies: 1388 1472 fast-glob: 3.3.3 ··· 1540 1624 '@esbuild/win32-ia32': 0.27.3 1541 1625 '@esbuild/win32-x64': 0.27.3 1542 1626 1627 + esm-env@1.2.2: {} 1628 + 1629 + event-target-polyfill@0.0.4: {} 1630 + 1543 1631 fast-glob@3.3.3: 1544 1632 dependencies: 1545 1633 '@nodelib/fs.stat': 2.0.5 ··· 1624 1712 1625 1713 ms@2.1.3: {} 1626 1714 1715 + multiformats@13.4.2: {} 1716 + 1627 1717 multiformats@9.9.0: {} 1628 1718 1629 1719 package-json-from-dist@1.0.1: {} 1720 + 1721 + partysocket@1.1.11: 1722 + dependencies: 1723 + event-target-polyfill: 0.0.4 1630 1724 1631 1725 path-browserify@1.0.1: {} 1632 1726 ··· 1671 1765 supports-color@7.2.0: 1672 1766 dependencies: 1673 1767 has-flag: 4.0.0 1768 + 1769 + tiny-emitter@2.1.0: {} 1674 1770 1675 1771 tlds@1.261.0: {} 1676 1772