Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
6
fork

Configure Feed

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

perf: dashboard improvements

Hugo c6d51d03 0fbe4bad

+2046 -80
+3 -11
drizzle/0009_damp_microbe.sql
··· 1 - ALTER TABLE `feature_requests` RENAME COLUMN "label_tid" TO "label_updated_at";--> statement-breakpoint 2 - ALTER TABLE `kanban_tasks` RENAME COLUMN "label_tid" TO "label_updated_at";--> statement-breakpoint 3 - CREATE TABLE `label_pds_records` ( 4 - `entity_id` text NOT NULL, 5 - `entity_type` text NOT NULL, 6 - `actor_did` text NOT NULL, 7 - `rkey` text NOT NULL, 8 - PRIMARY KEY(`entity_id`, `entity_type`, `actor_did`) 9 - ); 10 - --> statement-breakpoint 11 - ALTER TABLE `kanban_columns` ADD `status_type` text DEFAULT 'backlog' NOT NULL; 1 + -- No-op: changes captured here were already applied by 0007 and 0008. 2 + -- Kept alongside its snapshot so drizzle-kit has a valid baseline for future migrations. 3 + SELECT 1;
+5
drizzle/0010_dark_red_skull.sql
··· 1 + CREATE TABLE `did_handles` ( 2 + `did` text PRIMARY KEY NOT NULL, 3 + `handle` text NOT NULL, 4 + `updated_at` text DEFAULT (datetime('now')) NOT NULL 5 + );
+1610
drizzle/meta/0010_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "8a2b0a3b-d03a-466d-b73f-141810627773", 5 + "prevId": "e3c5292c-aa87-4b07-8df2-08ed9e38c774", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_entry_counter": { 113 + "name": "sphere_entry_counter", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": true, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "last_number": { 123 + "name": "last_number", 124 + "type": "integer", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false, 128 + "default": 0 129 + } 130 + }, 131 + "indexes": {}, 132 + "foreignKeys": { 133 + "sphere_entry_counter_sphere_id_spheres_id_fk": { 134 + "name": "sphere_entry_counter_sphere_id_spheres_id_fk", 135 + "tableFrom": "sphere_entry_counter", 136 + "tableTo": "spheres", 137 + "columnsFrom": [ 138 + "sphere_id" 139 + ], 140 + "columnsTo": [ 141 + "id" 142 + ], 143 + "onDelete": "no action", 144 + "onUpdate": "no action" 145 + } 146 + }, 147 + "compositePrimaryKeys": {}, 148 + "uniqueConstraints": {}, 149 + "checkConstraints": {} 150 + }, 151 + "did_handles": { 152 + "name": "did_handles", 153 + "columns": { 154 + "did": { 155 + "name": "did", 156 + "type": "text", 157 + "primaryKey": true, 158 + "notNull": true, 159 + "autoincrement": false 160 + }, 161 + "handle": { 162 + "name": "handle", 163 + "type": "text", 164 + "primaryKey": false, 165 + "notNull": true, 166 + "autoincrement": false 167 + }, 168 + "updated_at": { 169 + "name": "updated_at", 170 + "type": "text", 171 + "primaryKey": false, 172 + "notNull": true, 173 + "autoincrement": false, 174 + "default": "(datetime('now'))" 175 + } 176 + }, 177 + "indexes": {}, 178 + "foreignKeys": {}, 179 + "compositePrimaryKeys": {}, 180 + "uniqueConstraints": {}, 181 + "checkConstraints": {} 182 + }, 183 + "entity_labels": { 184 + "name": "entity_labels", 185 + "columns": { 186 + "label_id": { 187 + "name": "label_id", 188 + "type": "text", 189 + "primaryKey": false, 190 + "notNull": true, 191 + "autoincrement": false 192 + }, 193 + "entity_id": { 194 + "name": "entity_id", 195 + "type": "text", 196 + "primaryKey": false, 197 + "notNull": true, 198 + "autoincrement": false 199 + }, 200 + "entity_type": { 201 + "name": "entity_type", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": true, 205 + "autoincrement": false 206 + } 207 + }, 208 + "indexes": { 209 + "idx_entity_labels_entity": { 210 + "name": "idx_entity_labels_entity", 211 + "columns": [ 212 + "entity_id", 213 + "entity_type" 214 + ], 215 + "isUnique": false 216 + } 217 + }, 218 + "foreignKeys": { 219 + "entity_labels_label_id_sphere_labels_id_fk": { 220 + "name": "entity_labels_label_id_sphere_labels_id_fk", 221 + "tableFrom": "entity_labels", 222 + "tableTo": "sphere_labels", 223 + "columnsFrom": [ 224 + "label_id" 225 + ], 226 + "columnsTo": [ 227 + "id" 228 + ], 229 + "onDelete": "cascade", 230 + "onUpdate": "no action" 231 + } 232 + }, 233 + "compositePrimaryKeys": { 234 + "entity_labels_label_id_entity_id_pk": { 235 + "columns": [ 236 + "label_id", 237 + "entity_id" 238 + ], 239 + "name": "entity_labels_label_id_entity_id_pk" 240 + } 241 + }, 242 + "uniqueConstraints": {}, 243 + "checkConstraints": {} 244 + }, 245 + "label_pds_records": { 246 + "name": "label_pds_records", 247 + "columns": { 248 + "entity_id": { 249 + "name": "entity_id", 250 + "type": "text", 251 + "primaryKey": false, 252 + "notNull": true, 253 + "autoincrement": false 254 + }, 255 + "entity_type": { 256 + "name": "entity_type", 257 + "type": "text", 258 + "primaryKey": false, 259 + "notNull": true, 260 + "autoincrement": false 261 + }, 262 + "actor_did": { 263 + "name": "actor_did", 264 + "type": "text", 265 + "primaryKey": false, 266 + "notNull": true, 267 + "autoincrement": false 268 + }, 269 + "rkey": { 270 + "name": "rkey", 271 + "type": "text", 272 + "primaryKey": false, 273 + "notNull": true, 274 + "autoincrement": false 275 + } 276 + }, 277 + "indexes": {}, 278 + "foreignKeys": {}, 279 + "compositePrimaryKeys": { 280 + "label_pds_records_entity_id_entity_type_actor_did_pk": { 281 + "columns": [ 282 + "entity_id", 283 + "entity_type", 284 + "actor_did" 285 + ], 286 + "name": "label_pds_records_entity_id_entity_type_actor_did_pk" 287 + } 288 + }, 289 + "uniqueConstraints": {}, 290 + "checkConstraints": {} 291 + }, 292 + "sphere_labels": { 293 + "name": "sphere_labels", 294 + "columns": { 295 + "id": { 296 + "name": "id", 297 + "type": "text", 298 + "primaryKey": true, 299 + "notNull": true, 300 + "autoincrement": false 301 + }, 302 + "sphere_id": { 303 + "name": "sphere_id", 304 + "type": "text", 305 + "primaryKey": false, 306 + "notNull": true, 307 + "autoincrement": false 308 + }, 309 + "name": { 310 + "name": "name", 311 + "type": "text", 312 + "primaryKey": false, 313 + "notNull": true, 314 + "autoincrement": false 315 + }, 316 + "description": { 317 + "name": "description", 318 + "type": "text", 319 + "primaryKey": false, 320 + "notNull": false, 321 + "autoincrement": false 322 + }, 323 + "color": { 324 + "name": "color", 325 + "type": "text", 326 + "primaryKey": false, 327 + "notNull": true, 328 + "autoincrement": false 329 + }, 330 + "position": { 331 + "name": "position", 332 + "type": "integer", 333 + "primaryKey": false, 334 + "notNull": true, 335 + "autoincrement": false, 336 + "default": 0 337 + }, 338 + "created_at": { 339 + "name": "created_at", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": true, 343 + "autoincrement": false, 344 + "default": "(datetime('now'))" 345 + } 346 + }, 347 + "indexes": { 348 + "idx_sphere_labels_sphere_name": { 349 + "name": "idx_sphere_labels_sphere_name", 350 + "columns": [ 351 + "sphere_id", 352 + "name" 353 + ], 354 + "isUnique": true 355 + }, 356 + "idx_sphere_labels_sphere_position": { 357 + "name": "idx_sphere_labels_sphere_position", 358 + "columns": [ 359 + "sphere_id", 360 + "position" 361 + ], 362 + "isUnique": false 363 + } 364 + }, 365 + "foreignKeys": { 366 + "sphere_labels_sphere_id_spheres_id_fk": { 367 + "name": "sphere_labels_sphere_id_spheres_id_fk", 368 + "tableFrom": "sphere_labels", 369 + "tableTo": "spheres", 370 + "columnsFrom": [ 371 + "sphere_id" 372 + ], 373 + "columnsTo": [ 374 + "id" 375 + ], 376 + "onDelete": "no action", 377 + "onUpdate": "no action" 378 + } 379 + }, 380 + "compositePrimaryKeys": {}, 381 + "uniqueConstraints": {}, 382 + "checkConstraints": {} 383 + }, 384 + "sphere_members": { 385 + "name": "sphere_members", 386 + "columns": { 387 + "sphere_id": { 388 + "name": "sphere_id", 389 + "type": "text", 390 + "primaryKey": false, 391 + "notNull": true, 392 + "autoincrement": false 393 + }, 394 + "did": { 395 + "name": "did", 396 + "type": "text", 397 + "primaryKey": false, 398 + "notNull": true, 399 + "autoincrement": false 400 + }, 401 + "role": { 402 + "name": "role", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'member'" 408 + }, 409 + "status": { 410 + "name": "status", 411 + "type": "text", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false, 415 + "default": "'invited'" 416 + }, 417 + "invited_by": { 418 + "name": "invited_by", 419 + "type": "text", 420 + "primaryKey": false, 421 + "notNull": false, 422 + "autoincrement": false 423 + }, 424 + "pds_uri": { 425 + "name": "pds_uri", 426 + "type": "text", 427 + "primaryKey": false, 428 + "notNull": false, 429 + "autoincrement": false 430 + }, 431 + "approval_pds_uri": { 432 + "name": "approval_pds_uri", 433 + "type": "text", 434 + "primaryKey": false, 435 + "notNull": false, 436 + "autoincrement": false 437 + }, 438 + "created_at": { 439 + "name": "created_at", 440 + "type": "text", 441 + "primaryKey": false, 442 + "notNull": true, 443 + "autoincrement": false, 444 + "default": "(datetime('now'))" 445 + } 446 + }, 447 + "indexes": { 448 + "idx_sphere_members_did": { 449 + "name": "idx_sphere_members_did", 450 + "columns": [ 451 + "did" 452 + ], 453 + "isUnique": false 454 + } 455 + }, 456 + "foreignKeys": { 457 + "sphere_members_sphere_id_spheres_id_fk": { 458 + "name": "sphere_members_sphere_id_spheres_id_fk", 459 + "tableFrom": "sphere_members", 460 + "tableTo": "spheres", 461 + "columnsFrom": [ 462 + "sphere_id" 463 + ], 464 + "columnsTo": [ 465 + "id" 466 + ], 467 + "onDelete": "no action", 468 + "onUpdate": "no action" 469 + } 470 + }, 471 + "compositePrimaryKeys": { 472 + "sphere_members_sphere_id_did_pk": { 473 + "columns": [ 474 + "sphere_id", 475 + "did" 476 + ], 477 + "name": "sphere_members_sphere_id_did_pk" 478 + } 479 + }, 480 + "uniqueConstraints": {}, 481 + "checkConstraints": {} 482 + }, 483 + "sphere_modules": { 484 + "name": "sphere_modules", 485 + "columns": { 486 + "sphere_id": { 487 + "name": "sphere_id", 488 + "type": "text", 489 + "primaryKey": false, 490 + "notNull": true, 491 + "autoincrement": false 492 + }, 493 + "module_name": { 494 + "name": "module_name", 495 + "type": "text", 496 + "primaryKey": false, 497 + "notNull": true, 498 + "autoincrement": false 499 + }, 500 + "enabled_at": { 501 + "name": "enabled_at", 502 + "type": "text", 503 + "primaryKey": false, 504 + "notNull": true, 505 + "autoincrement": false, 506 + "default": "(datetime('now'))" 507 + } 508 + }, 509 + "indexes": {}, 510 + "foreignKeys": { 511 + "sphere_modules_sphere_id_spheres_id_fk": { 512 + "name": "sphere_modules_sphere_id_spheres_id_fk", 513 + "tableFrom": "sphere_modules", 514 + "tableTo": "spheres", 515 + "columnsFrom": [ 516 + "sphere_id" 517 + ], 518 + "columnsTo": [ 519 + "id" 520 + ], 521 + "onDelete": "no action", 522 + "onUpdate": "no action" 523 + } 524 + }, 525 + "compositePrimaryKeys": { 526 + "sphere_modules_sphere_id_module_name_pk": { 527 + "columns": [ 528 + "sphere_id", 529 + "module_name" 530 + ], 531 + "name": "sphere_modules_sphere_id_module_name_pk" 532 + } 533 + }, 534 + "uniqueConstraints": {}, 535 + "checkConstraints": {} 536 + }, 537 + "sphere_permissions": { 538 + "name": "sphere_permissions", 539 + "columns": { 540 + "sphere_id": { 541 + "name": "sphere_id", 542 + "type": "text", 543 + "primaryKey": false, 544 + "notNull": true, 545 + "autoincrement": false 546 + }, 547 + "action_key": { 548 + "name": "action_key", 549 + "type": "text", 550 + "primaryKey": false, 551 + "notNull": true, 552 + "autoincrement": false 553 + }, 554 + "min_role": { 555 + "name": "min_role", 556 + "type": "text", 557 + "primaryKey": false, 558 + "notNull": true, 559 + "autoincrement": false 560 + }, 561 + "updated_at": { 562 + "name": "updated_at", 563 + "type": "text", 564 + "primaryKey": false, 565 + "notNull": true, 566 + "autoincrement": false, 567 + "default": "(datetime('now'))" 568 + } 569 + }, 570 + "indexes": {}, 571 + "foreignKeys": { 572 + "sphere_permissions_sphere_id_spheres_id_fk": { 573 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 574 + "tableFrom": "sphere_permissions", 575 + "tableTo": "spheres", 576 + "columnsFrom": [ 577 + "sphere_id" 578 + ], 579 + "columnsTo": [ 580 + "id" 581 + ], 582 + "onDelete": "no action", 583 + "onUpdate": "no action" 584 + } 585 + }, 586 + "compositePrimaryKeys": { 587 + "sphere_permissions_sphere_id_action_key_pk": { 588 + "columns": [ 589 + "sphere_id", 590 + "action_key" 591 + ], 592 + "name": "sphere_permissions_sphere_id_action_key_pk" 593 + } 594 + }, 595 + "uniqueConstraints": {}, 596 + "checkConstraints": {} 597 + }, 598 + "spheres": { 599 + "name": "spheres", 600 + "columns": { 601 + "id": { 602 + "name": "id", 603 + "type": "text", 604 + "primaryKey": true, 605 + "notNull": true, 606 + "autoincrement": false 607 + }, 608 + "handle": { 609 + "name": "handle", 610 + "type": "text", 611 + "primaryKey": false, 612 + "notNull": true, 613 + "autoincrement": false 614 + }, 615 + "name": { 616 + "name": "name", 617 + "type": "text", 618 + "primaryKey": false, 619 + "notNull": true, 620 + "autoincrement": false 621 + }, 622 + "description": { 623 + "name": "description", 624 + "type": "text", 625 + "primaryKey": false, 626 + "notNull": false, 627 + "autoincrement": false 628 + }, 629 + "visibility": { 630 + "name": "visibility", 631 + "type": "text", 632 + "primaryKey": false, 633 + "notNull": true, 634 + "autoincrement": false, 635 + "default": "'public'" 636 + }, 637 + "owner_did": { 638 + "name": "owner_did", 639 + "type": "text", 640 + "primaryKey": false, 641 + "notNull": true, 642 + "autoincrement": false 643 + }, 644 + "pds_uri": { 645 + "name": "pds_uri", 646 + "type": "text", 647 + "primaryKey": false, 648 + "notNull": false, 649 + "autoincrement": false 650 + }, 651 + "created_at": { 652 + "name": "created_at", 653 + "type": "text", 654 + "primaryKey": false, 655 + "notNull": true, 656 + "autoincrement": false, 657 + "default": "(datetime('now'))" 658 + }, 659 + "updated_at": { 660 + "name": "updated_at", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true, 664 + "autoincrement": false, 665 + "default": "(datetime('now'))" 666 + } 667 + }, 668 + "indexes": { 669 + "spheres_handle_unique": { 670 + "name": "spheres_handle_unique", 671 + "columns": [ 672 + "handle" 673 + ], 674 + "isUnique": true 675 + } 676 + }, 677 + "foreignKeys": {}, 678 + "compositePrimaryKeys": {}, 679 + "uniqueConstraints": {}, 680 + "checkConstraints": {} 681 + }, 682 + "feature_request_comment_votes": { 683 + "name": "feature_request_comment_votes", 684 + "columns": { 685 + "comment_id": { 686 + "name": "comment_id", 687 + "type": "text", 688 + "primaryKey": false, 689 + "notNull": true, 690 + "autoincrement": false 691 + }, 692 + "author_did": { 693 + "name": "author_did", 694 + "type": "text", 695 + "primaryKey": false, 696 + "notNull": true, 697 + "autoincrement": false 698 + }, 699 + "pds_uri": { 700 + "name": "pds_uri", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false, 704 + "autoincrement": false 705 + }, 706 + "created_at": { 707 + "name": "created_at", 708 + "type": "text", 709 + "primaryKey": false, 710 + "notNull": true, 711 + "autoincrement": false, 712 + "default": "(datetime('now'))" 713 + } 714 + }, 715 + "indexes": { 716 + "idx_feature_request_comment_votes_comment": { 717 + "name": "idx_feature_request_comment_votes_comment", 718 + "columns": [ 719 + "comment_id" 720 + ], 721 + "isUnique": false 722 + } 723 + }, 724 + "foreignKeys": { 725 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 726 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 727 + "tableFrom": "feature_request_comment_votes", 728 + "tableTo": "feature_request_comments", 729 + "columnsFrom": [ 730 + "comment_id" 731 + ], 732 + "columnsTo": [ 733 + "id" 734 + ], 735 + "onDelete": "no action", 736 + "onUpdate": "no action" 737 + } 738 + }, 739 + "compositePrimaryKeys": { 740 + "feature_request_comment_votes_comment_id_author_did_pk": { 741 + "columns": [ 742 + "comment_id", 743 + "author_did" 744 + ], 745 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 746 + } 747 + }, 748 + "uniqueConstraints": {}, 749 + "checkConstraints": {} 750 + }, 751 + "feature_request_comments": { 752 + "name": "feature_request_comments", 753 + "columns": { 754 + "id": { 755 + "name": "id", 756 + "type": "text", 757 + "primaryKey": true, 758 + "notNull": true, 759 + "autoincrement": false 760 + }, 761 + "request_id": { 762 + "name": "request_id", 763 + "type": "text", 764 + "primaryKey": false, 765 + "notNull": true, 766 + "autoincrement": false 767 + }, 768 + "author_did": { 769 + "name": "author_did", 770 + "type": "text", 771 + "primaryKey": false, 772 + "notNull": true, 773 + "autoincrement": false 774 + }, 775 + "content": { 776 + "name": "content", 777 + "type": "text", 778 + "primaryKey": false, 779 + "notNull": true, 780 + "autoincrement": false 781 + }, 782 + "pds_uri": { 783 + "name": "pds_uri", 784 + "type": "text", 785 + "primaryKey": false, 786 + "notNull": false, 787 + "autoincrement": false 788 + }, 789 + "updated_at": { 790 + "name": "updated_at", 791 + "type": "text", 792 + "primaryKey": false, 793 + "notNull": true, 794 + "autoincrement": false, 795 + "default": "(datetime('now'))" 796 + }, 797 + "hidden_at": { 798 + "name": "hidden_at", 799 + "type": "text", 800 + "primaryKey": false, 801 + "notNull": false, 802 + "autoincrement": false 803 + }, 804 + "moderated_by": { 805 + "name": "moderated_by", 806 + "type": "text", 807 + "primaryKey": false, 808 + "notNull": false, 809 + "autoincrement": false 810 + } 811 + }, 812 + "indexes": { 813 + "idx_feature_request_comments_request": { 814 + "name": "idx_feature_request_comments_request", 815 + "columns": [ 816 + "request_id" 817 + ], 818 + "isUnique": false 819 + }, 820 + "idx_feature_request_comments_author_request": { 821 + "name": "idx_feature_request_comments_author_request", 822 + "columns": [ 823 + "author_did", 824 + "request_id" 825 + ], 826 + "isUnique": false 827 + } 828 + }, 829 + "foreignKeys": { 830 + "feature_request_comments_request_id_feature_requests_id_fk": { 831 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 832 + "tableFrom": "feature_request_comments", 833 + "tableTo": "feature_requests", 834 + "columnsFrom": [ 835 + "request_id" 836 + ], 837 + "columnsTo": [ 838 + "id" 839 + ], 840 + "onDelete": "no action", 841 + "onUpdate": "no action" 842 + } 843 + }, 844 + "compositePrimaryKeys": {}, 845 + "uniqueConstraints": {}, 846 + "checkConstraints": {} 847 + }, 848 + "feature_request_statuses": { 849 + "name": "feature_request_statuses", 850 + "columns": { 851 + "id": { 852 + "name": "id", 853 + "type": "text", 854 + "primaryKey": true, 855 + "notNull": true, 856 + "autoincrement": false 857 + }, 858 + "request_id": { 859 + "name": "request_id", 860 + "type": "text", 861 + "primaryKey": false, 862 + "notNull": true, 863 + "autoincrement": false 864 + }, 865 + "author_did": { 866 + "name": "author_did", 867 + "type": "text", 868 + "primaryKey": false, 869 + "notNull": true, 870 + "autoincrement": false 871 + }, 872 + "status": { 873 + "name": "status", 874 + "type": "text", 875 + "primaryKey": false, 876 + "notNull": true, 877 + "autoincrement": false 878 + }, 879 + "pds_uri": { 880 + "name": "pds_uri", 881 + "type": "text", 882 + "primaryKey": false, 883 + "notNull": false, 884 + "autoincrement": false 885 + } 886 + }, 887 + "indexes": { 888 + "idx_feature_request_statuses_request": { 889 + "name": "idx_feature_request_statuses_request", 890 + "columns": [ 891 + "request_id" 892 + ], 893 + "isUnique": false 894 + } 895 + }, 896 + "foreignKeys": { 897 + "feature_request_statuses_request_id_feature_requests_id_fk": { 898 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 899 + "tableFrom": "feature_request_statuses", 900 + "tableTo": "feature_requests", 901 + "columnsFrom": [ 902 + "request_id" 903 + ], 904 + "columnsTo": [ 905 + "id" 906 + ], 907 + "onDelete": "no action", 908 + "onUpdate": "no action" 909 + } 910 + }, 911 + "compositePrimaryKeys": {}, 912 + "uniqueConstraints": {}, 913 + "checkConstraints": {} 914 + }, 915 + "feature_request_votes": { 916 + "name": "feature_request_votes", 917 + "columns": { 918 + "request_id": { 919 + "name": "request_id", 920 + "type": "text", 921 + "primaryKey": false, 922 + "notNull": true, 923 + "autoincrement": false 924 + }, 925 + "author_did": { 926 + "name": "author_did", 927 + "type": "text", 928 + "primaryKey": false, 929 + "notNull": true, 930 + "autoincrement": false 931 + }, 932 + "pds_uri": { 933 + "name": "pds_uri", 934 + "type": "text", 935 + "primaryKey": false, 936 + "notNull": false, 937 + "autoincrement": false 938 + }, 939 + "created_at": { 940 + "name": "created_at", 941 + "type": "text", 942 + "primaryKey": false, 943 + "notNull": true, 944 + "autoincrement": false, 945 + "default": "(datetime('now'))" 946 + } 947 + }, 948 + "indexes": { 949 + "idx_feature_request_votes_request": { 950 + "name": "idx_feature_request_votes_request", 951 + "columns": [ 952 + "request_id" 953 + ], 954 + "isUnique": false 955 + } 956 + }, 957 + "foreignKeys": { 958 + "feature_request_votes_request_id_feature_requests_id_fk": { 959 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 960 + "tableFrom": "feature_request_votes", 961 + "tableTo": "feature_requests", 962 + "columnsFrom": [ 963 + "request_id" 964 + ], 965 + "columnsTo": [ 966 + "id" 967 + ], 968 + "onDelete": "no action", 969 + "onUpdate": "no action" 970 + } 971 + }, 972 + "compositePrimaryKeys": { 973 + "feature_request_votes_request_id_author_did_pk": { 974 + "columns": [ 975 + "request_id", 976 + "author_did" 977 + ], 978 + "name": "feature_request_votes_request_id_author_did_pk" 979 + } 980 + }, 981 + "uniqueConstraints": {}, 982 + "checkConstraints": {} 983 + }, 984 + "feature_requests": { 985 + "name": "feature_requests", 986 + "columns": { 987 + "id": { 988 + "name": "id", 989 + "type": "text", 990 + "primaryKey": true, 991 + "notNull": true, 992 + "autoincrement": false 993 + }, 994 + "sphere_id": { 995 + "name": "sphere_id", 996 + "type": "text", 997 + "primaryKey": false, 998 + "notNull": true, 999 + "autoincrement": false 1000 + }, 1001 + "number": { 1002 + "name": "number", 1003 + "type": "integer", 1004 + "primaryKey": false, 1005 + "notNull": true, 1006 + "autoincrement": false 1007 + }, 1008 + "author_did": { 1009 + "name": "author_did", 1010 + "type": "text", 1011 + "primaryKey": false, 1012 + "notNull": true, 1013 + "autoincrement": false 1014 + }, 1015 + "title": { 1016 + "name": "title", 1017 + "type": "text", 1018 + "primaryKey": false, 1019 + "notNull": true, 1020 + "autoincrement": false 1021 + }, 1022 + "description": { 1023 + "name": "description", 1024 + "type": "text", 1025 + "primaryKey": false, 1026 + "notNull": true, 1027 + "autoincrement": false 1028 + }, 1029 + "status": { 1030 + "name": "status", 1031 + "type": "text", 1032 + "primaryKey": false, 1033 + "notNull": true, 1034 + "autoincrement": false, 1035 + "default": "'requested'" 1036 + }, 1037 + "duplicate_of_id": { 1038 + "name": "duplicate_of_id", 1039 + "type": "text", 1040 + "primaryKey": false, 1041 + "notNull": false, 1042 + "autoincrement": false 1043 + }, 1044 + "pds_uri": { 1045 + "name": "pds_uri", 1046 + "type": "text", 1047 + "primaryKey": false, 1048 + "notNull": false, 1049 + "autoincrement": false 1050 + }, 1051 + "hidden_at": { 1052 + "name": "hidden_at", 1053 + "type": "text", 1054 + "primaryKey": false, 1055 + "notNull": false, 1056 + "autoincrement": false 1057 + }, 1058 + "moderated_by": { 1059 + "name": "moderated_by", 1060 + "type": "text", 1061 + "primaryKey": false, 1062 + "notNull": false, 1063 + "autoincrement": false 1064 + }, 1065 + "label_updated_at": { 1066 + "name": "label_updated_at", 1067 + "type": "text", 1068 + "primaryKey": false, 1069 + "notNull": false, 1070 + "autoincrement": false 1071 + }, 1072 + "updated_at": { 1073 + "name": "updated_at", 1074 + "type": "text", 1075 + "primaryKey": false, 1076 + "notNull": true, 1077 + "autoincrement": false, 1078 + "default": "(datetime('now'))" 1079 + } 1080 + }, 1081 + "indexes": { 1082 + "idx_feature_requests_sphere_number": { 1083 + "name": "idx_feature_requests_sphere_number", 1084 + "columns": [ 1085 + "sphere_id", 1086 + "number" 1087 + ], 1088 + "isUnique": true 1089 + }, 1090 + "idx_feature_requests_sphere": { 1091 + "name": "idx_feature_requests_sphere", 1092 + "columns": [ 1093 + "sphere_id" 1094 + ], 1095 + "isUnique": false 1096 + }, 1097 + "idx_feature_requests_status": { 1098 + "name": "idx_feature_requests_status", 1099 + "columns": [ 1100 + "status" 1101 + ], 1102 + "isUnique": false 1103 + } 1104 + }, 1105 + "foreignKeys": { 1106 + "feature_requests_sphere_id_spheres_id_fk": { 1107 + "name": "feature_requests_sphere_id_spheres_id_fk", 1108 + "tableFrom": "feature_requests", 1109 + "tableTo": "spheres", 1110 + "columnsFrom": [ 1111 + "sphere_id" 1112 + ], 1113 + "columnsTo": [ 1114 + "id" 1115 + ], 1116 + "onDelete": "no action", 1117 + "onUpdate": "no action" 1118 + } 1119 + }, 1120 + "compositePrimaryKeys": {}, 1121 + "uniqueConstraints": {}, 1122 + "checkConstraints": {} 1123 + }, 1124 + "feed_posts": { 1125 + "name": "feed_posts", 1126 + "columns": { 1127 + "id": { 1128 + "name": "id", 1129 + "type": "text", 1130 + "primaryKey": true, 1131 + "notNull": true, 1132 + "autoincrement": false 1133 + }, 1134 + "author_did": { 1135 + "name": "author_did", 1136 + "type": "text", 1137 + "primaryKey": false, 1138 + "notNull": true, 1139 + "autoincrement": false 1140 + }, 1141 + "content": { 1142 + "name": "content", 1143 + "type": "text", 1144 + "primaryKey": false, 1145 + "notNull": true, 1146 + "autoincrement": false 1147 + }, 1148 + "parent_id": { 1149 + "name": "parent_id", 1150 + "type": "text", 1151 + "primaryKey": false, 1152 + "notNull": false, 1153 + "autoincrement": false 1154 + }, 1155 + "pds_uri": { 1156 + "name": "pds_uri", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false, 1160 + "autoincrement": false 1161 + }, 1162 + "updated_at": { 1163 + "name": "updated_at", 1164 + "type": "text", 1165 + "primaryKey": false, 1166 + "notNull": true, 1167 + "autoincrement": false, 1168 + "default": "(datetime('now'))" 1169 + } 1170 + }, 1171 + "indexes": { 1172 + "idx_feed_posts_parent": { 1173 + "name": "idx_feed_posts_parent", 1174 + "columns": [ 1175 + "parent_id" 1176 + ], 1177 + "isUnique": false 1178 + } 1179 + }, 1180 + "foreignKeys": {}, 1181 + "compositePrimaryKeys": {}, 1182 + "uniqueConstraints": {}, 1183 + "checkConstraints": {} 1184 + }, 1185 + "kanban_columns": { 1186 + "name": "kanban_columns", 1187 + "columns": { 1188 + "id": { 1189 + "name": "id", 1190 + "type": "text", 1191 + "primaryKey": true, 1192 + "notNull": true, 1193 + "autoincrement": false 1194 + }, 1195 + "sphere_id": { 1196 + "name": "sphere_id", 1197 + "type": "text", 1198 + "primaryKey": false, 1199 + "notNull": true, 1200 + "autoincrement": false 1201 + }, 1202 + "slug": { 1203 + "name": "slug", 1204 + "type": "text", 1205 + "primaryKey": false, 1206 + "notNull": true, 1207 + "autoincrement": false 1208 + }, 1209 + "label": { 1210 + "name": "label", 1211 + "type": "text", 1212 + "primaryKey": false, 1213 + "notNull": true, 1214 + "autoincrement": false 1215 + }, 1216 + "status_type": { 1217 + "name": "status_type", 1218 + "type": "text", 1219 + "primaryKey": false, 1220 + "notNull": true, 1221 + "autoincrement": false, 1222 + "default": "'backlog'" 1223 + }, 1224 + "position": { 1225 + "name": "position", 1226 + "type": "integer", 1227 + "primaryKey": false, 1228 + "notNull": true, 1229 + "autoincrement": false 1230 + }, 1231 + "created_at": { 1232 + "name": "created_at", 1233 + "type": "text", 1234 + "primaryKey": false, 1235 + "notNull": true, 1236 + "autoincrement": false, 1237 + "default": "(datetime('now'))" 1238 + } 1239 + }, 1240 + "indexes": { 1241 + "idx_kanban_columns_sphere_slug": { 1242 + "name": "idx_kanban_columns_sphere_slug", 1243 + "columns": [ 1244 + "sphere_id", 1245 + "slug" 1246 + ], 1247 + "isUnique": true 1248 + }, 1249 + "idx_kanban_columns_sphere_position": { 1250 + "name": "idx_kanban_columns_sphere_position", 1251 + "columns": [ 1252 + "sphere_id", 1253 + "position" 1254 + ], 1255 + "isUnique": false 1256 + } 1257 + }, 1258 + "foreignKeys": { 1259 + "kanban_columns_sphere_id_spheres_id_fk": { 1260 + "name": "kanban_columns_sphere_id_spheres_id_fk", 1261 + "tableFrom": "kanban_columns", 1262 + "tableTo": "spheres", 1263 + "columnsFrom": [ 1264 + "sphere_id" 1265 + ], 1266 + "columnsTo": [ 1267 + "id" 1268 + ], 1269 + "onDelete": "no action", 1270 + "onUpdate": "no action" 1271 + } 1272 + }, 1273 + "compositePrimaryKeys": {}, 1274 + "uniqueConstraints": {}, 1275 + "checkConstraints": {} 1276 + }, 1277 + "kanban_task_comments": { 1278 + "name": "kanban_task_comments", 1279 + "columns": { 1280 + "id": { 1281 + "name": "id", 1282 + "type": "text", 1283 + "primaryKey": true, 1284 + "notNull": true, 1285 + "autoincrement": false 1286 + }, 1287 + "task_id": { 1288 + "name": "task_id", 1289 + "type": "text", 1290 + "primaryKey": false, 1291 + "notNull": true, 1292 + "autoincrement": false 1293 + }, 1294 + "author_did": { 1295 + "name": "author_did", 1296 + "type": "text", 1297 + "primaryKey": false, 1298 + "notNull": true, 1299 + "autoincrement": false 1300 + }, 1301 + "content": { 1302 + "name": "content", 1303 + "type": "text", 1304 + "primaryKey": false, 1305 + "notNull": true, 1306 + "autoincrement": false 1307 + }, 1308 + "pds_uri": { 1309 + "name": "pds_uri", 1310 + "type": "text", 1311 + "primaryKey": false, 1312 + "notNull": false, 1313 + "autoincrement": false 1314 + }, 1315 + "updated_at": { 1316 + "name": "updated_at", 1317 + "type": "text", 1318 + "primaryKey": false, 1319 + "notNull": true, 1320 + "autoincrement": false, 1321 + "default": "(datetime('now'))" 1322 + }, 1323 + "hidden_at": { 1324 + "name": "hidden_at", 1325 + "type": "text", 1326 + "primaryKey": false, 1327 + "notNull": false, 1328 + "autoincrement": false 1329 + }, 1330 + "moderated_by": { 1331 + "name": "moderated_by", 1332 + "type": "text", 1333 + "primaryKey": false, 1334 + "notNull": false, 1335 + "autoincrement": false 1336 + } 1337 + }, 1338 + "indexes": { 1339 + "idx_kanban_task_comments_task": { 1340 + "name": "idx_kanban_task_comments_task", 1341 + "columns": [ 1342 + "task_id" 1343 + ], 1344 + "isUnique": false 1345 + }, 1346 + "idx_kanban_task_comments_author_task": { 1347 + "name": "idx_kanban_task_comments_author_task", 1348 + "columns": [ 1349 + "author_did", 1350 + "task_id" 1351 + ], 1352 + "isUnique": false 1353 + } 1354 + }, 1355 + "foreignKeys": { 1356 + "kanban_task_comments_task_id_kanban_tasks_id_fk": { 1357 + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", 1358 + "tableFrom": "kanban_task_comments", 1359 + "tableTo": "kanban_tasks", 1360 + "columnsFrom": [ 1361 + "task_id" 1362 + ], 1363 + "columnsTo": [ 1364 + "id" 1365 + ], 1366 + "onDelete": "no action", 1367 + "onUpdate": "no action" 1368 + } 1369 + }, 1370 + "compositePrimaryKeys": {}, 1371 + "uniqueConstraints": {}, 1372 + "checkConstraints": {} 1373 + }, 1374 + "kanban_task_status_changes": { 1375 + "name": "kanban_task_status_changes", 1376 + "columns": { 1377 + "id": { 1378 + "name": "id", 1379 + "type": "text", 1380 + "primaryKey": true, 1381 + "notNull": true, 1382 + "autoincrement": false 1383 + }, 1384 + "task_id": { 1385 + "name": "task_id", 1386 + "type": "text", 1387 + "primaryKey": false, 1388 + "notNull": true, 1389 + "autoincrement": false 1390 + }, 1391 + "author_did": { 1392 + "name": "author_did", 1393 + "type": "text", 1394 + "primaryKey": false, 1395 + "notNull": true, 1396 + "autoincrement": false 1397 + }, 1398 + "status": { 1399 + "name": "status", 1400 + "type": "text", 1401 + "primaryKey": false, 1402 + "notNull": true, 1403 + "autoincrement": false 1404 + }, 1405 + "pds_uri": { 1406 + "name": "pds_uri", 1407 + "type": "text", 1408 + "primaryKey": false, 1409 + "notNull": false, 1410 + "autoincrement": false 1411 + } 1412 + }, 1413 + "indexes": { 1414 + "idx_kanban_task_status_changes_task": { 1415 + "name": "idx_kanban_task_status_changes_task", 1416 + "columns": [ 1417 + "task_id" 1418 + ], 1419 + "isUnique": false 1420 + } 1421 + }, 1422 + "foreignKeys": { 1423 + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { 1424 + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", 1425 + "tableFrom": "kanban_task_status_changes", 1426 + "tableTo": "kanban_tasks", 1427 + "columnsFrom": [ 1428 + "task_id" 1429 + ], 1430 + "columnsTo": [ 1431 + "id" 1432 + ], 1433 + "onDelete": "no action", 1434 + "onUpdate": "no action" 1435 + } 1436 + }, 1437 + "compositePrimaryKeys": {}, 1438 + "uniqueConstraints": {}, 1439 + "checkConstraints": {} 1440 + }, 1441 + "kanban_tasks": { 1442 + "name": "kanban_tasks", 1443 + "columns": { 1444 + "id": { 1445 + "name": "id", 1446 + "type": "text", 1447 + "primaryKey": true, 1448 + "notNull": true, 1449 + "autoincrement": false 1450 + }, 1451 + "sphere_id": { 1452 + "name": "sphere_id", 1453 + "type": "text", 1454 + "primaryKey": false, 1455 + "notNull": true, 1456 + "autoincrement": false 1457 + }, 1458 + "number": { 1459 + "name": "number", 1460 + "type": "integer", 1461 + "primaryKey": false, 1462 + "notNull": true, 1463 + "autoincrement": false 1464 + }, 1465 + "author_did": { 1466 + "name": "author_did", 1467 + "type": "text", 1468 + "primaryKey": false, 1469 + "notNull": true, 1470 + "autoincrement": false 1471 + }, 1472 + "title": { 1473 + "name": "title", 1474 + "type": "text", 1475 + "primaryKey": false, 1476 + "notNull": true, 1477 + "autoincrement": false 1478 + }, 1479 + "description": { 1480 + "name": "description", 1481 + "type": "text", 1482 + "primaryKey": false, 1483 + "notNull": true, 1484 + "autoincrement": false, 1485 + "default": "''" 1486 + }, 1487 + "status": { 1488 + "name": "status", 1489 + "type": "text", 1490 + "primaryKey": false, 1491 + "notNull": true, 1492 + "autoincrement": false, 1493 + "default": "'backlog'" 1494 + }, 1495 + "position": { 1496 + "name": "position", 1497 + "type": "integer", 1498 + "primaryKey": false, 1499 + "notNull": true, 1500 + "autoincrement": false, 1501 + "default": 0 1502 + }, 1503 + "assignee_did": { 1504 + "name": "assignee_did", 1505 + "type": "text", 1506 + "primaryKey": false, 1507 + "notNull": false, 1508 + "autoincrement": false 1509 + }, 1510 + "pds_uri": { 1511 + "name": "pds_uri", 1512 + "type": "text", 1513 + "primaryKey": false, 1514 + "notNull": false, 1515 + "autoincrement": false 1516 + }, 1517 + "hidden_at": { 1518 + "name": "hidden_at", 1519 + "type": "text", 1520 + "primaryKey": false, 1521 + "notNull": false, 1522 + "autoincrement": false 1523 + }, 1524 + "moderated_by": { 1525 + "name": "moderated_by", 1526 + "type": "text", 1527 + "primaryKey": false, 1528 + "notNull": false, 1529 + "autoincrement": false 1530 + }, 1531 + "label_updated_at": { 1532 + "name": "label_updated_at", 1533 + "type": "text", 1534 + "primaryKey": false, 1535 + "notNull": false, 1536 + "autoincrement": false 1537 + }, 1538 + "updated_at": { 1539 + "name": "updated_at", 1540 + "type": "text", 1541 + "primaryKey": false, 1542 + "notNull": true, 1543 + "autoincrement": false, 1544 + "default": "(datetime('now'))" 1545 + } 1546 + }, 1547 + "indexes": { 1548 + "idx_kanban_tasks_sphere_number": { 1549 + "name": "idx_kanban_tasks_sphere_number", 1550 + "columns": [ 1551 + "sphere_id", 1552 + "number" 1553 + ], 1554 + "isUnique": true 1555 + }, 1556 + "idx_kanban_tasks_sphere": { 1557 + "name": "idx_kanban_tasks_sphere", 1558 + "columns": [ 1559 + "sphere_id" 1560 + ], 1561 + "isUnique": false 1562 + }, 1563 + "idx_kanban_tasks_status": { 1564 + "name": "idx_kanban_tasks_status", 1565 + "columns": [ 1566 + "status" 1567 + ], 1568 + "isUnique": false 1569 + }, 1570 + "idx_kanban_tasks_sphere_status_position": { 1571 + "name": "idx_kanban_tasks_sphere_status_position", 1572 + "columns": [ 1573 + "sphere_id", 1574 + "status", 1575 + "position" 1576 + ], 1577 + "isUnique": false 1578 + } 1579 + }, 1580 + "foreignKeys": { 1581 + "kanban_tasks_sphere_id_spheres_id_fk": { 1582 + "name": "kanban_tasks_sphere_id_spheres_id_fk", 1583 + "tableFrom": "kanban_tasks", 1584 + "tableTo": "spheres", 1585 + "columnsFrom": [ 1586 + "sphere_id" 1587 + ], 1588 + "columnsTo": [ 1589 + "id" 1590 + ], 1591 + "onDelete": "no action", 1592 + "onUpdate": "no action" 1593 + } 1594 + }, 1595 + "compositePrimaryKeys": {}, 1596 + "uniqueConstraints": {}, 1597 + "checkConstraints": {} 1598 + } 1599 + }, 1600 + "views": {}, 1601 + "enums": {}, 1602 + "_meta": { 1603 + "schemas": {}, 1604 + "tables": {}, 1605 + "columns": {} 1606 + }, 1607 + "internal": { 1608 + "indexes": {} 1609 + } 1610 + }
+7
drizzle/meta/_journal.json
··· 71 71 "when": 1776516974088, 72 72 "tag": "0009_damp_microbe", 73 73 "breakpoints": true 74 + }, 75 + { 76 + "idx": 10, 77 + "version": "6", 78 + "when": 1776547678213, 79 + "tag": "0010_dark_red_skull", 80 + "breakpoints": true 74 81 } 75 82 ] 76 83 }
+11
packages/app/src/api/sphere-dashboard.ts
··· 16 16 title: string; 17 17 status: FrStatus; 18 18 voteCount: number; 19 + authorDid: string; 19 20 authorHandle: string | null; 20 21 createdAt: string; 21 22 } ··· 33 34 status: string; 34 35 statusType: KanbanStatusType; 35 36 statusLabel: string; 37 + authorDid: string; 36 38 authorHandle: string | null; 37 39 createdAt: string; 38 40 updatedAt: string; ··· 51 53 export function getFluxStats() { 52 54 return moduleFetch<FluxStats>("/kanban/stats"); 53 55 } 56 + 57 + export async function getHandlesForDids(dids: string[]): Promise<Record<string, string>> { 58 + const unique = [...new Set(dids)].filter((d) => d.length > 0); 59 + if (unique.length === 0) return {}; 60 + const res = await fetch(`/api/handles?dids=${encodeURIComponent(unique.join(","))}`); 61 + if (!res.ok) return {}; 62 + const json = (await res.json()) as { handles?: Record<string, string> }; 63 + return json.handles ?? {}; 64 + }
+53 -2
packages/app/src/pages/sphere.tsx
··· 1 1 import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 2 3 import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 4 import { useQuery } from "@exosphere/client/hooks"; 4 5 import { spherePath } from "@exosphere/client/router"; ··· 9 10 import { 10 11 getInfuseStats, 11 12 getFluxStats, 13 + getHandlesForDids, 12 14 type InfuseStats, 13 15 type FluxStats, 14 16 type KanbanStatusType, 15 17 type FrStatus, 16 18 } from "../api/sphere-dashboard.ts"; 17 19 20 + // Module-level cache so navigating between widgets/pages reuses resolved handles. 21 + const handleCache = new Map<string, string>(); 22 + 23 + function useDidHandles(dids: string[]): Record<string, string> { 24 + const handles = useSignal<Record<string, string>>({}); 25 + const key = [...new Set(dids)].sort().join(","); 26 + useEffect(() => { 27 + // Seed from cache first so cache hits render without waiting on the network. 28 + const seeded: Record<string, string> = {}; 29 + const missing: string[] = []; 30 + for (const did of new Set(dids)) { 31 + if (!did) continue; 32 + const cached = handleCache.get(did); 33 + if (cached) seeded[did] = cached; 34 + else missing.push(did); 35 + } 36 + if (Object.keys(seeded).length > 0) { 37 + handles.value = { ...handles.value, ...seeded }; 38 + } 39 + if (missing.length === 0) return; 40 + let cancelled = false; 41 + getHandlesForDids(missing).then((resolved) => { 42 + if (cancelled) return; 43 + for (const [did, handle] of Object.entries(resolved)) handleCache.set(did, handle); 44 + handles.value = { ...handles.value, ...resolved }; 45 + }); 46 + return () => { 47 + cancelled = true; 48 + }; 49 + }, [key]); 50 + return handles.value; 51 + } 52 + 18 53 const FR_STATUS_SEGMENTS: { key: FrStatus; label: string }[] = [ 19 54 { key: "requested", label: "Requested" }, 20 55 { key: "approved", label: "Approved" }, ··· 69 104 initialData: ssr, 70 105 cacheKey: `infuse-stats:${handle}`, 71 106 }); 107 + // Only fetch handles the server didn't already have cached — avoids a flash 108 + // for the common warm-cache case. 109 + const missingDids = 110 + data?.latestRequests.filter((r) => !r.authorHandle).map((r) => r.authorDid) ?? []; 111 + const handleMap = useDidHandles(missingDids); 72 112 73 113 return ( 74 114 <section class={s.widget} aria-label="Infuse"> ··· 123 163 <Link href={spherePath(`/infuse/${req.number}`)} class={s.listTitle}> 124 164 {req.title} 125 165 </Link> 126 - {req.authorHandle && <span class={s.listSub}>by @{req.authorHandle}</span>} 166 + {(req.authorHandle ?? handleMap[req.authorDid]) && ( 167 + <span class={s.listSub}> 168 + by @{req.authorHandle ?? handleMap[req.authorDid]} 169 + </span> 170 + )} 127 171 </div> 128 172 <div class={s.listRightMeta}> 129 173 <span class={FR_PILL_BY_STATUS[req.status] ?? s.statusPillNeutral}> ··· 208 252 initialData: ssr, 209 253 cacheKey: `flux-stats:${handle}`, 210 254 }); 255 + const missingDids = 256 + data?.latestTasks.filter((t) => !t.authorHandle).map((t) => t.authorDid) ?? []; 257 + const handleMap = useDidHandles(missingDids); 211 258 212 259 return ( 213 260 <section class={s.widget} aria-label="Flux"> ··· 249 296 <Link href={spherePath(`/flux/${task.number}`)} class={s.listTitle}> 250 297 {task.title} 251 298 </Link> 252 - {task.authorHandle && <span class={s.listSub}>@{task.authorHandle}</span>} 299 + {(task.authorHandle ?? handleMap[task.authorDid]) && ( 300 + <span class={s.listSub}> 301 + @{task.authorHandle ?? handleMap[task.authorDid]} 302 + </span> 303 + )} 253 304 </div> 254 305 <div class={s.listRightMeta}> 255 306 <span class={FLUX_STATUS_PILL[task.statusType] ?? s.statusPillNeutral}>
+6 -11
packages/app/src/server.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { serveStatic } from "hono/bun"; 3 3 import { getCookie } from "hono/cookie"; 4 - import { getOAuthClient, oauthRoutes } from "@exosphere/core/auth"; 4 + import { getOAuthClient, oauthRoutes, resolveAuthenticatedHandle } from "@exosphere/core/auth"; 5 + import { handlesApi } from "@exosphere/core/identity"; 5 6 import { createSphereRoutes, getCurrentSphere, sphereContext } from "@exosphere/core/sphere"; 6 7 import { isMultiSphere } from "@exosphere/core/config"; 7 8 import { startJetstream, stopCursorFlushing } from "@exosphere/indexer"; ··· 16 17 17 18 // Mount OAuth routes 18 19 app.route("/api/oauth", oauthRoutes); 20 + 21 + // Mount batch DID → handle resolver 22 + app.route("/api/handles", handlesApi); 19 23 20 24 // Register modules — sphere context is injected by middleware in both modes 21 25 if (isMultiSphere) { ··· 116 120 try { 117 121 const client = await getOAuthClient(); 118 122 const session = await client.restore(sid); 119 - let handle: string | null = null; 120 - try { 121 - const res = await session.fetchHandler( 122 - `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`, 123 - ); 124 - if (res.ok) { 125 - const repo = (await res.json()) as { handle: string }; 126 - handle = repo.handle; 127 - } 128 - } catch {} 123 + const handle = await resolveAuthenticatedHandle(session); 129 124 authData = { authenticated: true, did: session.did, handle }; 130 125 } catch { 131 126 /* invalid session */
+12 -8
packages/app/src/ssr-prefetch-orchestrator.ts
··· 45 45 46 46 if (hasSphere) { 47 47 const prefetches = ssrPrefetch(url, sphereHandle); 48 - for (const prefetch of prefetches) { 49 - try { 50 - const res = await fetchApi(prefetch.apiUrl); 51 - if (res.ok) pageData[prefetch.key] = await res.json(); 52 - } catch { 53 - /* prefetch failed — client will fetch */ 54 - } 55 - } 48 + // Sibling prefetches are independent — fan them out in parallel so the 49 + // slowest one determines the latency, not the sum. 50 + await Promise.all( 51 + prefetches.map(async (prefetch) => { 52 + try { 53 + const res = await fetchApi(prefetch.apiUrl); 54 + if (res.ok) pageData[prefetch.key] = await res.json(); 55 + } catch { 56 + /* prefetch failed — client will fetch */ 57 + } 58 + }), 59 + ); 56 60 57 61 const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api"; 58 62
+64
packages/core/src/auth/handle.ts
··· 1 + import { getCachedDidHandle, setDidHandle } from "../identity/index.ts"; 2 + 3 + // Minimal shape of what we need from an OAuth session — `fetchHandler` is 4 + // authenticated + bound to the user's PDS, which is the authoritative source 5 + // for their handle. We prefer it over the PLC directory for the user's own DID. 6 + export interface AuthenticatedSession { 7 + did: string; 8 + fetchHandler: (url: string) => Promise<Response>; 9 + } 10 + 11 + export async function describeRepoHandle(session: AuthenticatedSession): Promise<string | null> { 12 + try { 13 + const res = await session.fetchHandler( 14 + `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`, 15 + ); 16 + if (res.ok) { 17 + const repo = (await res.json()) as { handle: string }; 18 + // Normalize empty/missing handles to null so callers don't render "@". 19 + return repo.handle || null; 20 + } 21 + } catch {} 22 + return null; 23 + } 24 + 25 + // Coalesce concurrent describeRepo refreshes for the same DID — two SSRs 26 + // landing on a cold/stale entry simultaneously must not double-hit the PDS. 27 + const inFlightRefresh = new Map<string, Promise<string | null>>(); 28 + 29 + /** 30 + * PDS-authoritative refresh for an authenticated user's own handle: calls 31 + * `describeRepo` and persists to the cache. Concurrent calls for the same DID 32 + * share a single in-flight promise. 33 + */ 34 + export function refreshAuthenticatedHandle(session: AuthenticatedSession): Promise<string | null> { 35 + const pending = inFlightRefresh.get(session.did); 36 + if (pending) return pending; 37 + const p = describeRepoHandle(session) 38 + .then((fresh) => { 39 + if (fresh) setDidHandle(session.did, fresh); 40 + return fresh; 41 + }) 42 + .finally(() => inFlightRefresh.delete(session.did)); 43 + inFlightRefresh.set(session.did, p); 44 + return p; 45 + } 46 + 47 + /** 48 + * Resolve the authenticated user's handle — cache-first with PDS fallback. 49 + * - Cache hit (fresh): return immediately. 50 + * - Cache hit (stale): return cached value, refresh via PDS in the background. 51 + * - Cache miss: block on PDS `describeRepo`, persist, return. 52 + */ 53 + export async function resolveAuthenticatedHandle( 54 + session: AuthenticatedSession, 55 + ): Promise<string | null> { 56 + const cached = getCachedDidHandle(session.did); 57 + if (cached) { 58 + if (cached.stale) { 59 + refreshAuthenticatedHandle(session).catch(() => {}); 60 + } 61 + return cached.handle; 62 + } 63 + return refreshAuthenticatedHandle(session); 64 + }
+6
packages/core/src/auth/index.ts
··· 2 2 export { oauthRoutes } from "./routes.ts"; 3 3 export { requireAuth, optionalAuth } from "./middleware.ts"; 4 4 export type { AuthEnv } from "./middleware.ts"; 5 + export { 6 + describeRepoHandle, 7 + refreshAuthenticatedHandle, 8 + resolveAuthenticatedHandle, 9 + } from "./handle.ts"; 10 + export type { AuthenticatedSession } from "./handle.ts";
+8 -10
packages/core/src/auth/routes.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { setCookie, getCookie, deleteCookie } from "hono/cookie"; 3 3 import { getOAuthClient, resolveHandle, rewritePdsUrl } from "./client.ts"; 4 + import { refreshAuthenticatedHandle, resolveAuthenticatedHandle } from "./handle.ts"; 4 5 5 6 const auth = new Hono(); 6 7 ··· 50 51 maxAge: 60 * 60 * 24 * 30, // 30 days 51 52 }); 52 53 54 + // Force-refresh the user's handle in the cache on every signin. Gives 55 + // users a way to pull a renamed handle through without waiting for TTL. 56 + refreshAuthenticatedHandle(session).catch(() => { 57 + /* defensive — refreshAuthenticatedHandle already swallows internally */ 58 + }); 59 + 53 60 return c.redirect("/"); 54 61 } catch (err) { 55 62 console.error("[oauth/callback] error:", err instanceof Error ? err.message : err); ··· 67 74 try { 68 75 const client = await getOAuthClient(); 69 76 const session = await client.restore(did); 70 - let handle: string | undefined; 71 - try { 72 - const res = await session.fetchHandler( 73 - `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`, 74 - ); 75 - if (res.ok) { 76 - const repo = (await res.json()) as { handle: string }; 77 - handle = repo.handle; 78 - } 79 - } catch {} 77 + const handle = (await resolveAuthenticatedHandle(session)) ?? undefined; 80 78 return c.json({ authenticated: true, did: session.did, handle }); 81 79 } catch { 82 80 deleteCookie(c, "sid", { path: "/" });
+12
packages/core/src/db/schema/identity.ts
··· 1 + import { sqliteTable, text } from "drizzle-orm/sqlite-core"; 2 + import { sql } from "drizzle-orm"; 3 + 4 + // Persistent DID → handle cache. Read synchronously on SSR paths; refreshed 5 + // lazily via stale-while-revalidate by `resolveDidHandles`. 6 + export const didHandles = sqliteTable("did_handles", { 7 + did: text("did").primaryKey(), 8 + handle: text("handle").notNull(), 9 + updatedAt: text("updated_at") 10 + .notNull() 11 + .default(sql`(datetime('now'))`), 12 + });
+1
packages/core/src/db/schema/index.ts
··· 5 5 export { sphereEntryCounter } from "./entry-counter.ts"; 6 6 export { sphereLabels, entityLabels, labelPdsRecords } from "./labels.ts"; 7 7 export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts"; 8 + export { didHandles } from "./identity.ts";
+8 -1
packages/core/src/identity/index.ts
··· 1 - export { resolveDidHandles } from "./resolve-handles.ts"; 1 + export { 2 + resolveDidHandles, 3 + getCachedDidHandle, 4 + warmDidHandles, 5 + setDidHandle, 6 + collectCachedHandles, 7 + } from "./resolve-handles.ts"; 8 + export { handlesApi } from "./routes.ts";
+183 -28
packages/core/src/identity/resolve-handles.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { getDb } from "../db/index.ts"; 3 + import { didHandles } from "../db/schema/identity.ts"; 4 + 1 5 const pdsUrl = process.env.PDS_URL?.replace(/\/$/, ""); 2 6 3 - /** Simple TTL cache for DID → handle mappings. */ 4 - const cache = new Map<string, { handle: string; expiresAt: number }>(); 5 - const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes 7 + // Short TTL — handles rarely change, but when they do we want the UI to catch 8 + // up within a day or two. Stale entries are served optimistically and 9 + // revalidated in the background (stale-while-revalidate). 10 + const TTL_MS = 24 * 60 * 60 * 1000; // 24h 11 + 12 + // Soft cap on the in-memory cache so a long-running process doesn't grow the 13 + // map unbounded. The DB is the source of truth — eviction only costs an extra 14 + // DB read the next time a victim DID is requested. 15 + const MAX_MEM_ENTRIES = 5000; 16 + 17 + interface CachedEntry { 18 + handle: string; 19 + updatedAtMs: number; 20 + } 21 + 22 + // In-memory layer in front of the DB for hot-path reuse within a process. 23 + // Map preserves insertion order — we bump entries to the end on every read or 24 + // write to get LRU semantics; `.keys().next().value` then drops the true 25 + // least-recently-used entry on eviction. 26 + const memCache = new Map<string, CachedEntry>(); 27 + 28 + function touchMemCache(did: string, entry: CachedEntry): void { 29 + memCache.delete(did); 30 + memCache.set(did, entry); 31 + } 32 + 33 + function evictIfNeeded(): void { 34 + while (memCache.size > MAX_MEM_ENTRIES) { 35 + const oldest = memCache.keys().next().value; 36 + if (oldest === undefined) break; 37 + memCache.delete(oldest); 38 + } 39 + } 40 + 41 + // SQLite's `datetime('now')` default yields "YYYY-MM-DD HH:MM:SS" (UTC, no 42 + // timezone marker). Our own writes produce full ISO with `Z`. Normalize so 43 + // Date.parse reliably reads both — defensive against external writers. 44 + function parseTimestamp(s: string): number { 45 + const hasTimezone = /[Zz]|[+-]\d\d:?\d\d$/.test(s); 46 + return Date.parse(hasTimezone ? s : s.replace(" ", "T") + "Z"); 47 + } 48 + 49 + // Coalesce concurrent resolutions for the same DID into one network fetch. 50 + const inFlight = new Map<string, Promise<string | null>>(); 51 + 52 + function isStale(updatedAtMs: number): boolean { 53 + return Date.now() - updatedAtMs > TTL_MS; 54 + } 55 + 56 + function writeCache(did: string, handle: string, updatedAtMs: number): void { 57 + touchMemCache(did, { handle, updatedAtMs }); 58 + evictIfNeeded(); 59 + try { 60 + const iso = new Date(updatedAtMs).toISOString(); 61 + getDb() 62 + .insert(didHandles) 63 + .values({ did, handle, updatedAt: iso }) 64 + .onConflictDoUpdate({ 65 + target: didHandles.did, 66 + set: { handle, updatedAt: iso }, 67 + }) 68 + .run(); 69 + } catch { 70 + // DB write failing must not break the in-memory cache or the caller. 71 + } 72 + } 6 73 7 74 /** 8 - * Resolve a single DID to its AT Protocol handle. 9 - * In local dev (PDS_URL set), queries the local PDS. 10 - * In production, resolves via the PLC directory. 75 + * Synchronous peek across memory + DB. Returns the cached handle even when 76 + * stale — callers are expected to kick off a background refresh (see 77 + * {@link warmDidHandles}) so the next request serves fresh data. Returns 78 + * `null` only for DIDs we have never resolved. 11 79 */ 12 - async function resolveSingle(did: string): Promise<string | null> { 13 - const cached = cache.get(did); 14 - if (cached && cached.expiresAt > Date.now()) return cached.handle; 80 + export function getCachedDidHandle(did: string): { handle: string; stale: boolean } | null { 81 + if (!did) return null; 15 82 16 - const handle = pdsUrl ? await resolveViaPds(did) : await resolveViaPlcDirectory(did); 83 + const mem = memCache.get(did); 84 + if (mem) { 85 + touchMemCache(did, mem); 86 + return { handle: mem.handle, stale: isStale(mem.updatedAtMs) }; 87 + } 17 88 18 - if (handle) { 19 - cache.set(did, { handle, expiresAt: Date.now() + CACHE_TTL_MS }); 89 + let row: { handle: string; updatedAt: string } | undefined; 90 + try { 91 + row = getDb() 92 + .select({ handle: didHandles.handle, updatedAt: didHandles.updatedAt }) 93 + .from(didHandles) 94 + .where(eq(didHandles.did, did)) 95 + .get(); 96 + } catch (err) { 97 + // Graceful degrade: missing table / DB outage falls through to the network 98 + // resolve path. Surface the cause so prod issues aren't silent. 99 + console.error("[identity] did_handles read failed:", err); 100 + return null; 20 101 } 21 - return handle; 102 + if (!row) return null; 103 + 104 + const updatedAtMs = parseTimestamp(row.updatedAt); 105 + // memCache was just confirmed empty for this DID — direct insert, no delete. 106 + memCache.set(did, { handle: row.handle, updatedAtMs }); 107 + evictIfNeeded(); 108 + return { handle: row.handle, stale: isStale(updatedAtMs) }; 22 109 } 23 110 24 111 async function resolveViaPds(did: string): Promise<string | null> { ··· 46 133 return null; 47 134 } 48 135 136 + async function fetchAndStore(did: string): Promise<string | null> { 137 + const handle = pdsUrl ? await resolveViaPds(did) : await resolveViaPlcDirectory(did); 138 + if (!handle) return null; 139 + writeCache(did, handle, Date.now()); 140 + return handle; 141 + } 142 + 143 + function resolveSingle(did: string): Promise<string | null> { 144 + const pending = inFlight.get(did); 145 + if (pending) return pending; 146 + const p = fetchAndStore(did).finally(() => inFlight.delete(did)); 147 + inFlight.set(did, p); 148 + return p; 149 + } 150 + 49 151 /** 50 - * Batch-resolve an array of DIDs to handles. 51 - * Returns a Map<did, handle>. DIDs that cannot be resolved are omitted. 152 + * Write a freshly-resolved DID → handle into the cache. Used by the auth flow 153 + * to force-refresh a user's own handle on signin, bypassing the TTL. 154 + */ 155 + export function setDidHandle(did: string, handle: string): void { 156 + if (!did || !handle) return; 157 + writeCache(did, handle, Date.now()); 158 + } 159 + 160 + /** 161 + * Single pass over a row set: returns a `did → handle` map for the cached 162 + * entries (fresh + stale) and the list of DIDs that need a background refresh 163 + * (missing or stale). Used by SSR endpoints that want one `getCachedDidHandle` 164 + * call per DID. 165 + */ 166 + export function collectCachedHandles<T>( 167 + rows: readonly T[], 168 + getDid: (row: T) => string, 169 + ): { handles: Map<string, string>; toWarm: string[] } { 170 + const handles = new Map<string, string>(); 171 + const toWarm: string[] = []; 172 + for (const row of rows) { 173 + const did = getDid(row); 174 + if (!did) continue; 175 + const cached = getCachedDidHandle(did); 176 + if (cached) { 177 + handles.set(did, cached.handle); 178 + if (cached.stale) toWarm.push(did); 179 + } else { 180 + toWarm.push(did); 181 + } 182 + } 183 + return { handles, toWarm }; 184 + } 185 + 186 + /** 187 + * Kick off background refreshes for DIDs that are missing or stale. Never 188 + * throws, never waits — safe to call from the SSR response path. 189 + */ 190 + export function warmDidHandles(dids: Iterable<string>): void { 191 + for (const did of new Set(dids)) { 192 + if (!did) continue; 193 + const cached = getCachedDidHandle(did); 194 + if (cached && !cached.stale) continue; 195 + resolveSingle(did).catch(() => {}); 196 + } 197 + } 198 + 199 + /** 200 + * Batch-resolve DIDs to handles. Returns stale cached values immediately (and 201 + * warms them in the background); awaits network resolution only for DIDs we 202 + * have never seen. 52 203 */ 53 204 export async function resolveDidHandles(dids: string[]): Promise<Map<string, string>> { 54 - const unique = [...new Set(dids)]; 205 + const unique = [...new Set(dids)].filter((d) => d.length > 0); 55 206 const results = new Map<string, string>(); 56 - const toResolve: string[] = []; 207 + const toFetch: string[] = []; 208 + const toRevalidate: string[] = []; 57 209 58 - // Check cache first 59 210 for (const did of unique) { 60 - const cached = cache.get(did); 61 - if (cached && cached.expiresAt > Date.now()) { 211 + const cached = getCachedDidHandle(did); 212 + if (cached) { 62 213 results.set(did, cached.handle); 214 + if (cached.stale) toRevalidate.push(did); 63 215 } else { 64 - toResolve.push(did); 216 + toFetch.push(did); 65 217 } 66 218 } 67 219 68 - // Resolve remaining in parallel 69 - if (toResolve.length > 0) { 70 - const settled = await Promise.allSettled(toResolve.map((did) => resolveSingle(did))); 71 - for (let i = 0; i < toResolve.length; i++) { 72 - const result = settled[i]; 73 - if (result.status === "fulfilled" && result.value) { 74 - results.set(toResolve[i], result.value); 220 + for (const did of toRevalidate) { 221 + resolveSingle(did).catch(() => {}); 222 + } 223 + 224 + if (toFetch.length > 0) { 225 + const settled = await Promise.allSettled(toFetch.map((d) => resolveSingle(d))); 226 + for (let i = 0; i < toFetch.length; i++) { 227 + const r = settled[i]; 228 + if (r.status === "fulfilled" && r.value) { 229 + results.set(toFetch[i], r.value); 75 230 } 76 231 } 77 232 }
+26
packages/core/src/identity/routes.ts
··· 1 + import { Hono } from "hono"; 2 + import { resolveDidHandles } from "./resolve-handles.ts"; 3 + 4 + const MAX_DIDS_PER_REQUEST = 50; 5 + 6 + const app = new Hono(); 7 + 8 + // Batch DID → handle resolution. Clients call this after hydration to keep 9 + // SSR off the external PLC directory path. 10 + app.get("/", async (c) => { 11 + const raw = c.req.query("dids") ?? ""; 12 + const dids = raw 13 + .split(",") 14 + .map((d) => d.trim()) 15 + .filter((d) => d.length > 0) 16 + .slice(0, MAX_DIDS_PER_REQUEST); 17 + 18 + if (dids.length === 0) return c.json({ handles: {} }); 19 + 20 + const handleMap = await resolveDidHandles(dids); 21 + const handles: Record<string, string> = {}; 22 + for (const [did, handle] of handleMap) handles[did] = handle; 23 + return c.json({ handles }); 24 + }); 25 + 26 + export { app as handlesApi };
+17 -3
packages/feature-requests/src/__tests__/db-operations.test.ts
··· 103 103 pdsUri: null, 104 104 }); 105 105 106 - // Same id — onConflictDoNothing, returns existing row 107 - const dup = insertFeatureRequest({ 106 + // Same id, same author, new title/description — updates the existing row 107 + const updated = insertFeatureRequest({ 108 108 id: "fr-1", 109 109 sphereId: SPHERE_ID, 110 110 authorDid: AUTHOR_DID, 111 + title: "Updated", 112 + description: "New desc", 113 + pdsUri: null, 114 + }); 115 + expect(updated).toBeDefined(); 116 + expect(updated!.title).toBe("Updated"); 117 + expect(updated!.description).toBe("New desc"); 118 + 119 + // Same id, different author — returns existing row unchanged 120 + const dup = insertFeatureRequest({ 121 + id: "fr-1", 122 + sphereId: SPHERE_ID, 123 + authorDid: "did:plc:other", 111 124 title: "Duplicate", 112 125 description: "Desc", 113 126 pdsUri: null, 114 127 }); 115 128 expect(dup).toBeDefined(); 116 - expect(dup!.title).toBe("Original"); 129 + expect(dup!.title).toBe("Updated"); 130 + expect(dup!.authorDid).toBe(AUTHOR_DID); 117 131 }); 118 132 }); 119 133
+7 -3
packages/feature-requests/src/api/stats.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { getDb } from "@exosphere/core/db"; 3 3 import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; 4 - import { resolveDidHandles } from "@exosphere/core/identity"; 4 + import { collectCachedHandles, warmDidHandles } from "@exosphere/core/identity"; 5 5 import { tidToDate } from "@exosphere/core/pds"; 6 6 import type { AuthEnv } from "@exosphere/core/auth"; 7 7 import type { SphereEnv } from "@exosphere/core/types"; ··· 67 67 .limit(LATEST_LIMIT) 68 68 .all(); 69 69 70 - const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); 70 + // Synchronously pull cached handles for the response; warm stale/missing 71 + // DIDs in the background for the next request. 72 + const { handles, toWarm } = collectCachedHandles(latestRows, (r) => r.authorDid); 73 + if (toWarm.length) warmDidHandles(toWarm); 71 74 72 75 const latestRequests = latestRows.map((r) => ({ 73 76 id: r.id, ··· 75 78 title: r.title, 76 79 status: r.status, 77 80 voteCount: r.voteCount, 78 - authorHandle: handleMap.get(r.authorDid) ?? null, 81 + authorDid: r.authorDid, 82 + authorHandle: handles.get(r.authorDid) ?? null, 79 83 createdAt: tidToDate(r.id), 80 84 })); 81 85
+7 -3
packages/kanban/src/api/stats.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { getDb } from "@exosphere/core/db"; 3 3 import { eq, and, sql, count, desc } from "@exosphere/core/db/drizzle"; 4 - import { resolveDidHandles } from "@exosphere/core/identity"; 4 + import { collectCachedHandles, warmDidHandles } from "@exosphere/core/identity"; 5 5 import { tidToDate } from "@exosphere/core/pds"; 6 6 import type { AuthEnv } from "@exosphere/core/auth"; 7 7 import type { SphereEnv } from "@exosphere/core/types"; ··· 88 88 .limit(LATEST_TASKS_LIMIT) 89 89 .all(); 90 90 91 - const handleMap = await resolveDidHandles(latestRows.map((r) => r.authorDid)); 91 + // Synchronously pull cached handles for the response; warm stale/missing 92 + // DIDs in the background for the next request. 93 + const { handles, toWarm } = collectCachedHandles(latestRows, (r) => r.authorDid); 94 + if (toWarm.length) warmDidHandles(toWarm); 92 95 93 96 const latestTasks = latestRows.map((r) => ({ 94 97 id: r.id, ··· 97 100 status: r.status, 98 101 statusType: r.statusType as StatusType, 99 102 statusLabel: r.statusLabel, 100 - authorHandle: handleMap.get(r.authorDid) ?? null, 103 + authorDid: r.authorDid, 104 + authorHandle: handles.get(r.authorDid) ?? null, 101 105 createdAt: tidToDate(r.id), 102 106 updatedAt: r.updatedAt, 103 107 }));