A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

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

feat(notifications): scalable SSE notification system + real XRPC shout/like mutations

Backend
- notifications table (recipient, actor, type, shout/subject refs, read) + migration 0018
- notifications.service: create/list/getUnreadCount/markSeen; publishes to a
per-user NATS subject (rocksky.notification.<userId>) so SSE fan-out scales
horizontally without websockets; never throws so it can't break a mutation
- emit notifications from the shared service layer so BOTH the legacy Hono REST
routes and the XRPC handlers trigger them: like song/scrobble, follow,
comment on scrobble, comment on profile, reply, react on comment
- app.rocksky.notification.{listNotifications,getUnreadCount,updateSeen} lexicons
+ generated types + XRPC handlers
- SSE endpoint GET /notifications/stream (token via query param, heartbeat,
initial unread count) subscribed to the user's NATS subject
- implement the previously-stubbed XRPC mutations (likeShout, likeSong,
dislikeShout, dislikeSong, createShout, replyShout, reportShout, removeShout)
by delegating to the same services the Hono app uses

Web
- NotificationBell (bell + red unread badge, popover inbox) in the navbar,
visible only to authenticated users; live via EventSource

Web-mobile
- replace the "For You" bottom-nav tab with a notifications bell tab (red badge),
move Recommendations into the profile menu; notifications screen + live badge

+7362 -282
+23
apps/api/drizzle/0018_notifications.sql
··· 1 + CREATE TABLE IF NOT EXISTS "notifications" ( 2 + "xata_id" text PRIMARY KEY DEFAULT xata_id() NOT NULL, 3 + "user_id" text NOT NULL, 4 + "actor_id" text NOT NULL, 5 + "type" text NOT NULL, 6 + "shout_id" text, 7 + "subject_uri" text, 8 + "read" boolean DEFAULT false NOT NULL, 9 + "read_at" timestamp with time zone, 10 + "xata_createdat" timestamp DEFAULT now() NOT NULL 11 + ); 12 + --> statement-breakpoint 13 + DO $$ BEGIN 14 + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_xata_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("xata_id") ON DELETE no action ON UPDATE no action; 15 + EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint 16 + DO $$ BEGIN 17 + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_users_xata_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("xata_id") ON DELETE no action ON UPDATE no action; 18 + EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint 19 + DO $$ BEGIN 20 + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_shout_id_shouts_xata_id_fk" FOREIGN KEY ("shout_id") REFERENCES "public"."shouts"("xata_id") ON DELETE no action ON UPDATE no action; 21 + EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint 22 + CREATE INDEX IF NOT EXISTS "notifications_user_id_read_idx" ON "notifications" USING btree ("user_id","read");--> statement-breakpoint 23 + CREATE INDEX IF NOT EXISTS "notifications_user_id_createdat_idx" ON "notifications" USING btree ("user_id","xata_createdat");
+4837
apps/api/drizzle/meta/0018_snapshot.json
··· 1 + { 2 + "id": "2f14023b-5a2c-4fc7-960c-e1393b6cdc1e", 3 + "prevId": "bc6d2af5-1c42-4413-a37b-1e4eba1413f6", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.access_tokens": { 8 + "name": "access_tokens", 9 + "schema": "", 10 + "columns": { 11 + "xata_id": { 12 + "name": "xata_id", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true, 16 + "default": "xata_id()" 17 + }, 18 + "user_id": { 19 + "name": "user_id", 20 + "type": "text", 21 + "primaryKey": false, 22 + "notNull": true 23 + }, 24 + "name": { 25 + "name": "name", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true 29 + }, 30 + "jti": { 31 + "name": "jti", 32 + "type": "text", 33 + "primaryKey": false, 34 + "notNull": true 35 + }, 36 + "token_encrypted": { 37 + "name": "token_encrypted", 38 + "type": "text", 39 + "primaryKey": false, 40 + "notNull": true 41 + }, 42 + "last_four": { 43 + "name": "last_four", 44 + "type": "text", 45 + "primaryKey": false, 46 + "notNull": true 47 + }, 48 + "last_used_at": { 49 + "name": "last_used_at", 50 + "type": "timestamp", 51 + "primaryKey": false, 52 + "notNull": false 53 + }, 54 + "xata_createdat": { 55 + "name": "xata_createdat", 56 + "type": "timestamp", 57 + "primaryKey": false, 58 + "notNull": true, 59 + "default": "now()" 60 + }, 61 + "xata_updatedat": { 62 + "name": "xata_updatedat", 63 + "type": "timestamp", 64 + "primaryKey": false, 65 + "notNull": true, 66 + "default": "now()" 67 + } 68 + }, 69 + "indexes": { 70 + "access_tokens_user_id_idx": { 71 + "name": "access_tokens_user_id_idx", 72 + "columns": [ 73 + { 74 + "expression": "user_id", 75 + "isExpression": false, 76 + "asc": true, 77 + "nulls": "last" 78 + } 79 + ], 80 + "isUnique": false, 81 + "concurrently": false, 82 + "method": "btree", 83 + "with": {} 84 + } 85 + }, 86 + "foreignKeys": { 87 + "access_tokens_user_id_users_xata_id_fk": { 88 + "name": "access_tokens_user_id_users_xata_id_fk", 89 + "tableFrom": "access_tokens", 90 + "tableTo": "users", 91 + "columnsFrom": [ 92 + "user_id" 93 + ], 94 + "columnsTo": [ 95 + "xata_id" 96 + ], 97 + "onDelete": "no action", 98 + "onUpdate": "no action" 99 + } 100 + }, 101 + "compositePrimaryKeys": {}, 102 + "uniqueConstraints": { 103 + "access_tokens_jti_unique": { 104 + "name": "access_tokens_jti_unique", 105 + "nullsNotDistinct": false, 106 + "columns": [ 107 + "jti" 108 + ] 109 + } 110 + }, 111 + "policies": {}, 112 + "checkConstraints": {}, 113 + "isRLSEnabled": false 114 + }, 115 + "public.album_tracks": { 116 + "name": "album_tracks", 117 + "schema": "", 118 + "columns": { 119 + "xata_id": { 120 + "name": "xata_id", 121 + "type": "text", 122 + "primaryKey": true, 123 + "notNull": true, 124 + "default": "xata_id()" 125 + }, 126 + "album_id": { 127 + "name": "album_id", 128 + "type": "text", 129 + "primaryKey": false, 130 + "notNull": true 131 + }, 132 + "track_id": { 133 + "name": "track_id", 134 + "type": "text", 135 + "primaryKey": false, 136 + "notNull": true 137 + }, 138 + "xata_createdat": { 139 + "name": "xata_createdat", 140 + "type": "timestamp", 141 + "primaryKey": false, 142 + "notNull": true, 143 + "default": "now()" 144 + }, 145 + "xata_updatedat": { 146 + "name": "xata_updatedat", 147 + "type": "timestamp", 148 + "primaryKey": false, 149 + "notNull": true, 150 + "default": "now()" 151 + }, 152 + "xata_version": { 153 + "name": "xata_version", 154 + "type": "integer", 155 + "primaryKey": false, 156 + "notNull": false 157 + } 158 + }, 159 + "indexes": { 160 + "album_tracks_album_id_idx": { 161 + "name": "album_tracks_album_id_idx", 162 + "columns": [ 163 + { 164 + "expression": "album_id", 165 + "isExpression": false, 166 + "asc": true, 167 + "nulls": "last" 168 + } 169 + ], 170 + "isUnique": false, 171 + "concurrently": false, 172 + "method": "btree", 173 + "with": {} 174 + }, 175 + "album_tracks_track_id_idx": { 176 + "name": "album_tracks_track_id_idx", 177 + "columns": [ 178 + { 179 + "expression": "track_id", 180 + "isExpression": false, 181 + "asc": true, 182 + "nulls": "last" 183 + } 184 + ], 185 + "isUnique": false, 186 + "concurrently": false, 187 + "method": "btree", 188 + "with": {} 189 + } 190 + }, 191 + "foreignKeys": { 192 + "album_tracks_album_id_albums_xata_id_fk": { 193 + "name": "album_tracks_album_id_albums_xata_id_fk", 194 + "tableFrom": "album_tracks", 195 + "tableTo": "albums", 196 + "columnsFrom": [ 197 + "album_id" 198 + ], 199 + "columnsTo": [ 200 + "xata_id" 201 + ], 202 + "onDelete": "no action", 203 + "onUpdate": "no action" 204 + }, 205 + "album_tracks_track_id_tracks_xata_id_fk": { 206 + "name": "album_tracks_track_id_tracks_xata_id_fk", 207 + "tableFrom": "album_tracks", 208 + "tableTo": "tracks", 209 + "columnsFrom": [ 210 + "track_id" 211 + ], 212 + "columnsTo": [ 213 + "xata_id" 214 + ], 215 + "onDelete": "no action", 216 + "onUpdate": "no action" 217 + } 218 + }, 219 + "compositePrimaryKeys": {}, 220 + "uniqueConstraints": {}, 221 + "policies": {}, 222 + "checkConstraints": {}, 223 + "isRLSEnabled": false 224 + }, 225 + "public.albums": { 226 + "name": "albums", 227 + "schema": "", 228 + "columns": { 229 + "xata_id": { 230 + "name": "xata_id", 231 + "type": "text", 232 + "primaryKey": true, 233 + "notNull": true, 234 + "default": "xata_id()" 235 + }, 236 + "title": { 237 + "name": "title", 238 + "type": "text", 239 + "primaryKey": false, 240 + "notNull": true 241 + }, 242 + "artist": { 243 + "name": "artist", 244 + "type": "text", 245 + "primaryKey": false, 246 + "notNull": true 247 + }, 248 + "release_date": { 249 + "name": "release_date", 250 + "type": "text", 251 + "primaryKey": false, 252 + "notNull": false 253 + }, 254 + "year": { 255 + "name": "year", 256 + "type": "integer", 257 + "primaryKey": false, 258 + "notNull": false 259 + }, 260 + "album_art": { 261 + "name": "album_art", 262 + "type": "text", 263 + "primaryKey": false, 264 + "notNull": false 265 + }, 266 + "uri": { 267 + "name": "uri", 268 + "type": "text", 269 + "primaryKey": false, 270 + "notNull": false 271 + }, 272 + "artist_uri": { 273 + "name": "artist_uri", 274 + "type": "text", 275 + "primaryKey": false, 276 + "notNull": false 277 + }, 278 + "apple_music_link": { 279 + "name": "apple_music_link", 280 + "type": "text", 281 + "primaryKey": false, 282 + "notNull": false 283 + }, 284 + "spotify_link": { 285 + "name": "spotify_link", 286 + "type": "text", 287 + "primaryKey": false, 288 + "notNull": false 289 + }, 290 + "tidal_link": { 291 + "name": "tidal_link", 292 + "type": "text", 293 + "primaryKey": false, 294 + "notNull": false 295 + }, 296 + "youtube_link": { 297 + "name": "youtube_link", 298 + "type": "text", 299 + "primaryKey": false, 300 + "notNull": false 301 + }, 302 + "sha256": { 303 + "name": "sha256", 304 + "type": "text", 305 + "primaryKey": false, 306 + "notNull": true 307 + }, 308 + "xata_createdat": { 309 + "name": "xata_createdat", 310 + "type": "timestamp", 311 + "primaryKey": false, 312 + "notNull": true, 313 + "default": "now()" 314 + }, 315 + "xata_updatedat": { 316 + "name": "xata_updatedat", 317 + "type": "timestamp", 318 + "primaryKey": false, 319 + "notNull": true, 320 + "default": "now()" 321 + }, 322 + "xata_version": { 323 + "name": "xata_version", 324 + "type": "integer", 325 + "primaryKey": false, 326 + "notNull": false 327 + } 328 + }, 329 + "indexes": {}, 330 + "foreignKeys": {}, 331 + "compositePrimaryKeys": {}, 332 + "uniqueConstraints": { 333 + "albums_uri_unique": { 334 + "name": "albums_uri_unique", 335 + "nullsNotDistinct": false, 336 + "columns": [ 337 + "uri" 338 + ] 339 + }, 340 + "albums_apple_music_link_unique": { 341 + "name": "albums_apple_music_link_unique", 342 + "nullsNotDistinct": false, 343 + "columns": [ 344 + "apple_music_link" 345 + ] 346 + }, 347 + "albums_spotify_link_unique": { 348 + "name": "albums_spotify_link_unique", 349 + "nullsNotDistinct": false, 350 + "columns": [ 351 + "spotify_link" 352 + ] 353 + }, 354 + "albums_tidal_link_unique": { 355 + "name": "albums_tidal_link_unique", 356 + "nullsNotDistinct": false, 357 + "columns": [ 358 + "tidal_link" 359 + ] 360 + }, 361 + "albums_youtube_link_unique": { 362 + "name": "albums_youtube_link_unique", 363 + "nullsNotDistinct": false, 364 + "columns": [ 365 + "youtube_link" 366 + ] 367 + }, 368 + "albums_sha256_unique": { 369 + "name": "albums_sha256_unique", 370 + "nullsNotDistinct": false, 371 + "columns": [ 372 + "sha256" 373 + ] 374 + } 375 + }, 376 + "policies": {}, 377 + "checkConstraints": {}, 378 + "isRLSEnabled": false 379 + }, 380 + "public.api_keys": { 381 + "name": "api_keys", 382 + "schema": "", 383 + "columns": { 384 + "xata_id": { 385 + "name": "xata_id", 386 + "type": "text", 387 + "primaryKey": true, 388 + "notNull": true, 389 + "default": "xata_id()" 390 + }, 391 + "name": { 392 + "name": "name", 393 + "type": "text", 394 + "primaryKey": false, 395 + "notNull": true 396 + }, 397 + "api_key": { 398 + "name": "api_key", 399 + "type": "text", 400 + "primaryKey": false, 401 + "notNull": true 402 + }, 403 + "shared_secret": { 404 + "name": "shared_secret", 405 + "type": "text", 406 + "primaryKey": false, 407 + "notNull": true 408 + }, 409 + "description": { 410 + "name": "description", 411 + "type": "text", 412 + "primaryKey": false, 413 + "notNull": false 414 + }, 415 + "enabled": { 416 + "name": "enabled", 417 + "type": "boolean", 418 + "primaryKey": false, 419 + "notNull": true, 420 + "default": true 421 + }, 422 + "user_id": { 423 + "name": "user_id", 424 + "type": "text", 425 + "primaryKey": false, 426 + "notNull": true 427 + }, 428 + "xata_createdat": { 429 + "name": "xata_createdat", 430 + "type": "timestamp", 431 + "primaryKey": false, 432 + "notNull": true, 433 + "default": "now()" 434 + }, 435 + "xata_updatedat": { 436 + "name": "xata_updatedat", 437 + "type": "timestamp", 438 + "primaryKey": false, 439 + "notNull": true, 440 + "default": "now()" 441 + } 442 + }, 443 + "indexes": {}, 444 + "foreignKeys": { 445 + "api_keys_user_id_users_xata_id_fk": { 446 + "name": "api_keys_user_id_users_xata_id_fk", 447 + "tableFrom": "api_keys", 448 + "tableTo": "users", 449 + "columnsFrom": [ 450 + "user_id" 451 + ], 452 + "columnsTo": [ 453 + "xata_id" 454 + ], 455 + "onDelete": "no action", 456 + "onUpdate": "no action" 457 + } 458 + }, 459 + "compositePrimaryKeys": {}, 460 + "uniqueConstraints": {}, 461 + "policies": {}, 462 + "checkConstraints": {}, 463 + "isRLSEnabled": false 464 + }, 465 + "public.artist_albums": { 466 + "name": "artist_albums", 467 + "schema": "", 468 + "columns": { 469 + "xata_id": { 470 + "name": "xata_id", 471 + "type": "text", 472 + "primaryKey": true, 473 + "notNull": true, 474 + "default": "xata_id()" 475 + }, 476 + "artist_id": { 477 + "name": "artist_id", 478 + "type": "text", 479 + "primaryKey": false, 480 + "notNull": true 481 + }, 482 + "album_id": { 483 + "name": "album_id", 484 + "type": "text", 485 + "primaryKey": false, 486 + "notNull": true 487 + }, 488 + "xata_createdat": { 489 + "name": "xata_createdat", 490 + "type": "timestamp", 491 + "primaryKey": false, 492 + "notNull": true, 493 + "default": "now()" 494 + }, 495 + "xata_updatedat": { 496 + "name": "xata_updatedat", 497 + "type": "timestamp", 498 + "primaryKey": false, 499 + "notNull": true, 500 + "default": "now()" 501 + }, 502 + "xata_version": { 503 + "name": "xata_version", 504 + "type": "integer", 505 + "primaryKey": false, 506 + "notNull": false 507 + } 508 + }, 509 + "indexes": { 510 + "artist_albums_artist_id_idx": { 511 + "name": "artist_albums_artist_id_idx", 512 + "columns": [ 513 + { 514 + "expression": "artist_id", 515 + "isExpression": false, 516 + "asc": true, 517 + "nulls": "last" 518 + } 519 + ], 520 + "isUnique": false, 521 + "concurrently": false, 522 + "method": "btree", 523 + "with": {} 524 + }, 525 + "artist_albums_album_id_idx": { 526 + "name": "artist_albums_album_id_idx", 527 + "columns": [ 528 + { 529 + "expression": "album_id", 530 + "isExpression": false, 531 + "asc": true, 532 + "nulls": "last" 533 + } 534 + ], 535 + "isUnique": false, 536 + "concurrently": false, 537 + "method": "btree", 538 + "with": {} 539 + } 540 + }, 541 + "foreignKeys": { 542 + "artist_albums_artist_id_artists_xata_id_fk": { 543 + "name": "artist_albums_artist_id_artists_xata_id_fk", 544 + "tableFrom": "artist_albums", 545 + "tableTo": "artists", 546 + "columnsFrom": [ 547 + "artist_id" 548 + ], 549 + "columnsTo": [ 550 + "xata_id" 551 + ], 552 + "onDelete": "no action", 553 + "onUpdate": "no action" 554 + }, 555 + "artist_albums_album_id_albums_xata_id_fk": { 556 + "name": "artist_albums_album_id_albums_xata_id_fk", 557 + "tableFrom": "artist_albums", 558 + "tableTo": "albums", 559 + "columnsFrom": [ 560 + "album_id" 561 + ], 562 + "columnsTo": [ 563 + "xata_id" 564 + ], 565 + "onDelete": "no action", 566 + "onUpdate": "no action" 567 + } 568 + }, 569 + "compositePrimaryKeys": {}, 570 + "uniqueConstraints": {}, 571 + "policies": {}, 572 + "checkConstraints": {}, 573 + "isRLSEnabled": false 574 + }, 575 + "public.artist_tracks": { 576 + "name": "artist_tracks", 577 + "schema": "", 578 + "columns": { 579 + "xata_id": { 580 + "name": "xata_id", 581 + "type": "text", 582 + "primaryKey": true, 583 + "notNull": true, 584 + "default": "xata_id()" 585 + }, 586 + "artist_id": { 587 + "name": "artist_id", 588 + "type": "text", 589 + "primaryKey": false, 590 + "notNull": true 591 + }, 592 + "track_id": { 593 + "name": "track_id", 594 + "type": "text", 595 + "primaryKey": false, 596 + "notNull": true 597 + }, 598 + "xata_createdat": { 599 + "name": "xata_createdat", 600 + "type": "timestamp", 601 + "primaryKey": false, 602 + "notNull": true, 603 + "default": "now()" 604 + }, 605 + "xata_updatedat": { 606 + "name": "xata_updatedat", 607 + "type": "timestamp", 608 + "primaryKey": false, 609 + "notNull": true, 610 + "default": "now()" 611 + }, 612 + "xata_version": { 613 + "name": "xata_version", 614 + "type": "integer", 615 + "primaryKey": false, 616 + "notNull": false 617 + } 618 + }, 619 + "indexes": { 620 + "artist_tracks_artist_id_idx": { 621 + "name": "artist_tracks_artist_id_idx", 622 + "columns": [ 623 + { 624 + "expression": "artist_id", 625 + "isExpression": false, 626 + "asc": true, 627 + "nulls": "last" 628 + } 629 + ], 630 + "isUnique": false, 631 + "concurrently": false, 632 + "method": "btree", 633 + "with": {} 634 + }, 635 + "artist_tracks_track_id_idx": { 636 + "name": "artist_tracks_track_id_idx", 637 + "columns": [ 638 + { 639 + "expression": "track_id", 640 + "isExpression": false, 641 + "asc": true, 642 + "nulls": "last" 643 + } 644 + ], 645 + "isUnique": false, 646 + "concurrently": false, 647 + "method": "btree", 648 + "with": {} 649 + } 650 + }, 651 + "foreignKeys": { 652 + "artist_tracks_artist_id_artists_xata_id_fk": { 653 + "name": "artist_tracks_artist_id_artists_xata_id_fk", 654 + "tableFrom": "artist_tracks", 655 + "tableTo": "artists", 656 + "columnsFrom": [ 657 + "artist_id" 658 + ], 659 + "columnsTo": [ 660 + "xata_id" 661 + ], 662 + "onDelete": "no action", 663 + "onUpdate": "no action" 664 + }, 665 + "artist_tracks_track_id_tracks_xata_id_fk": { 666 + "name": "artist_tracks_track_id_tracks_xata_id_fk", 667 + "tableFrom": "artist_tracks", 668 + "tableTo": "tracks", 669 + "columnsFrom": [ 670 + "track_id" 671 + ], 672 + "columnsTo": [ 673 + "xata_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.artists": { 686 + "name": "artists", 687 + "schema": "", 688 + "columns": { 689 + "xata_id": { 690 + "name": "xata_id", 691 + "type": "text", 692 + "primaryKey": true, 693 + "notNull": true, 694 + "default": "xata_id()" 695 + }, 696 + "name": { 697 + "name": "name", 698 + "type": "text", 699 + "primaryKey": false, 700 + "notNull": true 701 + }, 702 + "biography": { 703 + "name": "biography", 704 + "type": "text", 705 + "primaryKey": false, 706 + "notNull": false 707 + }, 708 + "born": { 709 + "name": "born", 710 + "type": "timestamp", 711 + "primaryKey": false, 712 + "notNull": false 713 + }, 714 + "born_in": { 715 + "name": "born_in", 716 + "type": "text", 717 + "primaryKey": false, 718 + "notNull": false 719 + }, 720 + "died": { 721 + "name": "died", 722 + "type": "timestamp", 723 + "primaryKey": false, 724 + "notNull": false 725 + }, 726 + "picture": { 727 + "name": "picture", 728 + "type": "text", 729 + "primaryKey": false, 730 + "notNull": false 731 + }, 732 + "sha256": { 733 + "name": "sha256", 734 + "type": "text", 735 + "primaryKey": false, 736 + "notNull": true 737 + }, 738 + "uri": { 739 + "name": "uri", 740 + "type": "text", 741 + "primaryKey": false, 742 + "notNull": false 743 + }, 744 + "apple_music_link": { 745 + "name": "apple_music_link", 746 + "type": "text", 747 + "primaryKey": false, 748 + "notNull": false 749 + }, 750 + "spotify_link": { 751 + "name": "spotify_link", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": false 755 + }, 756 + "tidal_link": { 757 + "name": "tidal_link", 758 + "type": "text", 759 + "primaryKey": false, 760 + "notNull": false 761 + }, 762 + "youtube_link": { 763 + "name": "youtube_link", 764 + "type": "text", 765 + "primaryKey": false, 766 + "notNull": false 767 + }, 768 + "genres": { 769 + "name": "genres", 770 + "type": "text[]", 771 + "primaryKey": false, 772 + "notNull": false 773 + }, 774 + "xata_createdat": { 775 + "name": "xata_createdat", 776 + "type": "timestamp", 777 + "primaryKey": false, 778 + "notNull": true, 779 + "default": "now()" 780 + }, 781 + "xata_updatedat": { 782 + "name": "xata_updatedat", 783 + "type": "timestamp", 784 + "primaryKey": false, 785 + "notNull": true, 786 + "default": "now()" 787 + }, 788 + "xata_version": { 789 + "name": "xata_version", 790 + "type": "integer", 791 + "primaryKey": false, 792 + "notNull": false 793 + } 794 + }, 795 + "indexes": {}, 796 + "foreignKeys": {}, 797 + "compositePrimaryKeys": {}, 798 + "uniqueConstraints": { 799 + "artists_sha256_unique": { 800 + "name": "artists_sha256_unique", 801 + "nullsNotDistinct": false, 802 + "columns": [ 803 + "sha256" 804 + ] 805 + }, 806 + "artists_uri_unique": { 807 + "name": "artists_uri_unique", 808 + "nullsNotDistinct": false, 809 + "columns": [ 810 + "uri" 811 + ] 812 + } 813 + }, 814 + "policies": {}, 815 + "checkConstraints": {}, 816 + "isRLSEnabled": false 817 + }, 818 + "public.dropbox_accounts": { 819 + "name": "dropbox_accounts", 820 + "schema": "", 821 + "columns": { 822 + "xata_id": { 823 + "name": "xata_id", 824 + "type": "text", 825 + "primaryKey": true, 826 + "notNull": true, 827 + "default": "xata_id()" 828 + }, 829 + "email": { 830 + "name": "email", 831 + "type": "text", 832 + "primaryKey": false, 833 + "notNull": true 834 + }, 835 + "is_beta_user": { 836 + "name": "is_beta_user", 837 + "type": "boolean", 838 + "primaryKey": false, 839 + "notNull": true, 840 + "default": false 841 + }, 842 + "user_id": { 843 + "name": "user_id", 844 + "type": "text", 845 + "primaryKey": false, 846 + "notNull": true 847 + }, 848 + "xata_version": { 849 + "name": "xata_version", 850 + "type": "text", 851 + "primaryKey": false, 852 + "notNull": false 853 + }, 854 + "xata_createdat": { 855 + "name": "xata_createdat", 856 + "type": "timestamp", 857 + "primaryKey": false, 858 + "notNull": true, 859 + "default": "now()" 860 + }, 861 + "xata_updatedat": { 862 + "name": "xata_updatedat", 863 + "type": "timestamp", 864 + "primaryKey": false, 865 + "notNull": true, 866 + "default": "now()" 867 + } 868 + }, 869 + "indexes": {}, 870 + "foreignKeys": { 871 + "dropbox_accounts_user_id_users_xata_id_fk": { 872 + "name": "dropbox_accounts_user_id_users_xata_id_fk", 873 + "tableFrom": "dropbox_accounts", 874 + "tableTo": "users", 875 + "columnsFrom": [ 876 + "user_id" 877 + ], 878 + "columnsTo": [ 879 + "xata_id" 880 + ], 881 + "onDelete": "no action", 882 + "onUpdate": "no action" 883 + } 884 + }, 885 + "compositePrimaryKeys": {}, 886 + "uniqueConstraints": { 887 + "dropbox_accounts_email_unique": { 888 + "name": "dropbox_accounts_email_unique", 889 + "nullsNotDistinct": false, 890 + "columns": [ 891 + "email" 892 + ] 893 + } 894 + }, 895 + "policies": {}, 896 + "checkConstraints": {}, 897 + "isRLSEnabled": false 898 + }, 899 + "public.dropbox_directories": { 900 + "name": "dropbox_directories", 901 + "schema": "", 902 + "columns": { 903 + "xata_id": { 904 + "name": "xata_id", 905 + "type": "text", 906 + "primaryKey": true, 907 + "notNull": true, 908 + "default": "xata_id()" 909 + }, 910 + "name": { 911 + "name": "name", 912 + "type": "text", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "path": { 917 + "name": "path", 918 + "type": "text", 919 + "primaryKey": false, 920 + "notNull": true 921 + }, 922 + "parent_id": { 923 + "name": "parent_id", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": false 927 + }, 928 + "dropbox_id": { 929 + "name": "dropbox_id", 930 + "type": "text", 931 + "primaryKey": false, 932 + "notNull": true 933 + }, 934 + "file_id": { 935 + "name": "file_id", 936 + "type": "text", 937 + "primaryKey": false, 938 + "notNull": true 939 + }, 940 + "xata_version": { 941 + "name": "xata_version", 942 + "type": "text", 943 + "primaryKey": false, 944 + "notNull": false 945 + }, 946 + "xata_createdat": { 947 + "name": "xata_createdat", 948 + "type": "timestamp", 949 + "primaryKey": false, 950 + "notNull": true, 951 + "default": "now()" 952 + }, 953 + "xata_updatedat": { 954 + "name": "xata_updatedat", 955 + "type": "timestamp", 956 + "primaryKey": false, 957 + "notNull": true, 958 + "default": "now()" 959 + } 960 + }, 961 + "indexes": {}, 962 + "foreignKeys": { 963 + "dropbox_directories_parent_id_dropbox_directories_xata_id_fk": { 964 + "name": "dropbox_directories_parent_id_dropbox_directories_xata_id_fk", 965 + "tableFrom": "dropbox_directories", 966 + "tableTo": "dropbox_directories", 967 + "columnsFrom": [ 968 + "parent_id" 969 + ], 970 + "columnsTo": [ 971 + "xata_id" 972 + ], 973 + "onDelete": "no action", 974 + "onUpdate": "no action" 975 + } 976 + }, 977 + "compositePrimaryKeys": {}, 978 + "uniqueConstraints": { 979 + "dropbox_directories_file_id_unique": { 980 + "name": "dropbox_directories_file_id_unique", 981 + "nullsNotDistinct": false, 982 + "columns": [ 983 + "file_id" 984 + ] 985 + } 986 + }, 987 + "policies": {}, 988 + "checkConstraints": {}, 989 + "isRLSEnabled": false 990 + }, 991 + "public.dropbox_paths": { 992 + "name": "dropbox_paths", 993 + "schema": "", 994 + "columns": { 995 + "xata_id": { 996 + "name": "xata_id", 997 + "type": "text", 998 + "primaryKey": true, 999 + "notNull": true, 1000 + "default": "xata_id()" 1001 + }, 1002 + "path": { 1003 + "name": "path", 1004 + "type": "text", 1005 + "primaryKey": false, 1006 + "notNull": true 1007 + }, 1008 + "name": { 1009 + "name": "name", 1010 + "type": "text", 1011 + "primaryKey": false, 1012 + "notNull": true 1013 + }, 1014 + "dropbox_id": { 1015 + "name": "dropbox_id", 1016 + "type": "text", 1017 + "primaryKey": false, 1018 + "notNull": true 1019 + }, 1020 + "track_id": { 1021 + "name": "track_id", 1022 + "type": "text", 1023 + "primaryKey": false, 1024 + "notNull": true 1025 + }, 1026 + "directory_id": { 1027 + "name": "directory_id", 1028 + "type": "text", 1029 + "primaryKey": false, 1030 + "notNull": false 1031 + }, 1032 + "file_id": { 1033 + "name": "file_id", 1034 + "type": "text", 1035 + "primaryKey": false, 1036 + "notNull": true 1037 + }, 1038 + "xata_version": { 1039 + "name": "xata_version", 1040 + "type": "text", 1041 + "primaryKey": false, 1042 + "notNull": false 1043 + }, 1044 + "xata_createdat": { 1045 + "name": "xata_createdat", 1046 + "type": "timestamp", 1047 + "primaryKey": false, 1048 + "notNull": true, 1049 + "default": "now()" 1050 + }, 1051 + "xata_updatedat": { 1052 + "name": "xata_updatedat", 1053 + "type": "timestamp", 1054 + "primaryKey": false, 1055 + "notNull": true, 1056 + "default": "now()" 1057 + } 1058 + }, 1059 + "indexes": {}, 1060 + "foreignKeys": { 1061 + "dropbox_paths_directory_id_dropbox_directories_xata_id_fk": { 1062 + "name": "dropbox_paths_directory_id_dropbox_directories_xata_id_fk", 1063 + "tableFrom": "dropbox_paths", 1064 + "tableTo": "dropbox_directories", 1065 + "columnsFrom": [ 1066 + "directory_id" 1067 + ], 1068 + "columnsTo": [ 1069 + "xata_id" 1070 + ], 1071 + "onDelete": "no action", 1072 + "onUpdate": "no action" 1073 + } 1074 + }, 1075 + "compositePrimaryKeys": {}, 1076 + "uniqueConstraints": { 1077 + "dropbox_paths_file_id_unique": { 1078 + "name": "dropbox_paths_file_id_unique", 1079 + "nullsNotDistinct": false, 1080 + "columns": [ 1081 + "file_id" 1082 + ] 1083 + } 1084 + }, 1085 + "policies": {}, 1086 + "checkConstraints": {}, 1087 + "isRLSEnabled": false 1088 + }, 1089 + "public.dropbox_tokens": { 1090 + "name": "dropbox_tokens", 1091 + "schema": "", 1092 + "columns": { 1093 + "xata_id": { 1094 + "name": "xata_id", 1095 + "type": "text", 1096 + "primaryKey": true, 1097 + "notNull": true, 1098 + "default": "xata_id()" 1099 + }, 1100 + "refresh_token": { 1101 + "name": "refresh_token", 1102 + "type": "text", 1103 + "primaryKey": false, 1104 + "notNull": true 1105 + }, 1106 + "xata_createdat": { 1107 + "name": "xata_createdat", 1108 + "type": "timestamp", 1109 + "primaryKey": false, 1110 + "notNull": true, 1111 + "default": "now()" 1112 + }, 1113 + "xata_updatedat": { 1114 + "name": "xata_updatedat", 1115 + "type": "timestamp", 1116 + "primaryKey": false, 1117 + "notNull": true, 1118 + "default": "now()" 1119 + } 1120 + }, 1121 + "indexes": {}, 1122 + "foreignKeys": {}, 1123 + "compositePrimaryKeys": {}, 1124 + "uniqueConstraints": {}, 1125 + "policies": {}, 1126 + "checkConstraints": {}, 1127 + "isRLSEnabled": false 1128 + }, 1129 + "public.dropbox": { 1130 + "name": "dropbox", 1131 + "schema": "", 1132 + "columns": { 1133 + "xata_id": { 1134 + "name": "xata_id", 1135 + "type": "text", 1136 + "primaryKey": true, 1137 + "notNull": true, 1138 + "default": "xata_id()" 1139 + }, 1140 + "user_id": { 1141 + "name": "user_id", 1142 + "type": "text", 1143 + "primaryKey": false, 1144 + "notNull": true 1145 + }, 1146 + "dropbox_token_id": { 1147 + "name": "dropbox_token_id", 1148 + "type": "text", 1149 + "primaryKey": false, 1150 + "notNull": true 1151 + }, 1152 + "xata_version": { 1153 + "name": "xata_version", 1154 + "type": "text", 1155 + "primaryKey": false, 1156 + "notNull": false 1157 + }, 1158 + "xata_createdat": { 1159 + "name": "xata_createdat", 1160 + "type": "timestamp", 1161 + "primaryKey": false, 1162 + "notNull": true, 1163 + "default": "now()" 1164 + }, 1165 + "xata_updatedat": { 1166 + "name": "xata_updatedat", 1167 + "type": "timestamp", 1168 + "primaryKey": false, 1169 + "notNull": true, 1170 + "default": "now()" 1171 + } 1172 + }, 1173 + "indexes": {}, 1174 + "foreignKeys": { 1175 + "dropbox_user_id_users_xata_id_fk": { 1176 + "name": "dropbox_user_id_users_xata_id_fk", 1177 + "tableFrom": "dropbox", 1178 + "tableTo": "users", 1179 + "columnsFrom": [ 1180 + "user_id" 1181 + ], 1182 + "columnsTo": [ 1183 + "xata_id" 1184 + ], 1185 + "onDelete": "no action", 1186 + "onUpdate": "no action" 1187 + }, 1188 + "dropbox_dropbox_token_id_dropbox_tokens_xata_id_fk": { 1189 + "name": "dropbox_dropbox_token_id_dropbox_tokens_xata_id_fk", 1190 + "tableFrom": "dropbox", 1191 + "tableTo": "dropbox_tokens", 1192 + "columnsFrom": [ 1193 + "dropbox_token_id" 1194 + ], 1195 + "columnsTo": [ 1196 + "xata_id" 1197 + ], 1198 + "onDelete": "no action", 1199 + "onUpdate": "no action" 1200 + } 1201 + }, 1202 + "compositePrimaryKeys": {}, 1203 + "uniqueConstraints": {}, 1204 + "policies": {}, 1205 + "checkConstraints": {}, 1206 + "isRLSEnabled": false 1207 + }, 1208 + "public.feeds": { 1209 + "name": "feeds", 1210 + "schema": "", 1211 + "columns": { 1212 + "xata_id": { 1213 + "name": "xata_id", 1214 + "type": "text", 1215 + "primaryKey": true, 1216 + "notNull": true, 1217 + "default": "xata_id()" 1218 + }, 1219 + "display_name": { 1220 + "name": "display_name", 1221 + "type": "text", 1222 + "primaryKey": false, 1223 + "notNull": true 1224 + }, 1225 + "description": { 1226 + "name": "description", 1227 + "type": "text", 1228 + "primaryKey": false, 1229 + "notNull": false 1230 + }, 1231 + "did": { 1232 + "name": "did", 1233 + "type": "text", 1234 + "primaryKey": false, 1235 + "notNull": true 1236 + }, 1237 + "uri": { 1238 + "name": "uri", 1239 + "type": "text", 1240 + "primaryKey": false, 1241 + "notNull": true 1242 + }, 1243 + "avatar": { 1244 + "name": "avatar", 1245 + "type": "text", 1246 + "primaryKey": false, 1247 + "notNull": false 1248 + }, 1249 + "user_id": { 1250 + "name": "user_id", 1251 + "type": "text", 1252 + "primaryKey": false, 1253 + "notNull": true 1254 + }, 1255 + "xata_version": { 1256 + "name": "xata_version", 1257 + "type": "integer", 1258 + "primaryKey": false, 1259 + "notNull": false 1260 + }, 1261 + "xata_createdat": { 1262 + "name": "xata_createdat", 1263 + "type": "timestamp", 1264 + "primaryKey": false, 1265 + "notNull": true, 1266 + "default": "now()" 1267 + }, 1268 + "xata_updatedat": { 1269 + "name": "xata_updatedat", 1270 + "type": "timestamp", 1271 + "primaryKey": false, 1272 + "notNull": true, 1273 + "default": "now()" 1274 + } 1275 + }, 1276 + "indexes": {}, 1277 + "foreignKeys": { 1278 + "feeds_user_id_users_xata_id_fk": { 1279 + "name": "feeds_user_id_users_xata_id_fk", 1280 + "tableFrom": "feeds", 1281 + "tableTo": "users", 1282 + "columnsFrom": [ 1283 + "user_id" 1284 + ], 1285 + "columnsTo": [ 1286 + "xata_id" 1287 + ], 1288 + "onDelete": "no action", 1289 + "onUpdate": "no action" 1290 + } 1291 + }, 1292 + "compositePrimaryKeys": {}, 1293 + "uniqueConstraints": { 1294 + "feeds_uri_unique": { 1295 + "name": "feeds_uri_unique", 1296 + "nullsNotDistinct": false, 1297 + "columns": [ 1298 + "uri" 1299 + ] 1300 + } 1301 + }, 1302 + "policies": {}, 1303 + "checkConstraints": {}, 1304 + "isRLSEnabled": false 1305 + }, 1306 + "public.follows": { 1307 + "name": "follows", 1308 + "schema": "", 1309 + "columns": { 1310 + "xata_id": { 1311 + "name": "xata_id", 1312 + "type": "text", 1313 + "primaryKey": true, 1314 + "notNull": true, 1315 + "default": "xata_id()" 1316 + }, 1317 + "uri": { 1318 + "name": "uri", 1319 + "type": "text", 1320 + "primaryKey": false, 1321 + "notNull": true 1322 + }, 1323 + "follower_did": { 1324 + "name": "follower_did", 1325 + "type": "text", 1326 + "primaryKey": false, 1327 + "notNull": true 1328 + }, 1329 + "subject_did": { 1330 + "name": "subject_did", 1331 + "type": "text", 1332 + "primaryKey": false, 1333 + "notNull": true 1334 + }, 1335 + "xata_version": { 1336 + "name": "xata_version", 1337 + "type": "integer", 1338 + "primaryKey": false, 1339 + "notNull": false 1340 + }, 1341 + "xata_createdat": { 1342 + "name": "xata_createdat", 1343 + "type": "timestamp", 1344 + "primaryKey": false, 1345 + "notNull": true, 1346 + "default": "now()" 1347 + }, 1348 + "xata_updatedat": { 1349 + "name": "xata_updatedat", 1350 + "type": "timestamp", 1351 + "primaryKey": false, 1352 + "notNull": true, 1353 + "default": "now()" 1354 + } 1355 + }, 1356 + "indexes": { 1357 + "follows_follower_subject_unique": { 1358 + "name": "follows_follower_subject_unique", 1359 + "columns": [ 1360 + { 1361 + "expression": "follower_did", 1362 + "isExpression": false, 1363 + "asc": true, 1364 + "nulls": "last" 1365 + }, 1366 + { 1367 + "expression": "subject_did", 1368 + "isExpression": false, 1369 + "asc": true, 1370 + "nulls": "last" 1371 + } 1372 + ], 1373 + "isUnique": true, 1374 + "concurrently": false, 1375 + "method": "btree", 1376 + "with": {} 1377 + } 1378 + }, 1379 + "foreignKeys": {}, 1380 + "compositePrimaryKeys": {}, 1381 + "uniqueConstraints": { 1382 + "follows_uri_unique": { 1383 + "name": "follows_uri_unique", 1384 + "nullsNotDistinct": false, 1385 + "columns": [ 1386 + "uri" 1387 + ] 1388 + } 1389 + }, 1390 + "policies": {}, 1391 + "checkConstraints": {}, 1392 + "isRLSEnabled": false 1393 + }, 1394 + "public.google_drive_accounts": { 1395 + "name": "google_drive_accounts", 1396 + "schema": "", 1397 + "columns": { 1398 + "xata_id": { 1399 + "name": "xata_id", 1400 + "type": "text", 1401 + "primaryKey": true, 1402 + "notNull": true, 1403 + "default": "xata_id()" 1404 + }, 1405 + "email": { 1406 + "name": "email", 1407 + "type": "text", 1408 + "primaryKey": false, 1409 + "notNull": true 1410 + }, 1411 + "is_beta_user": { 1412 + "name": "is_beta_user", 1413 + "type": "boolean", 1414 + "primaryKey": false, 1415 + "notNull": true, 1416 + "default": false 1417 + }, 1418 + "user_id": { 1419 + "name": "user_id", 1420 + "type": "text", 1421 + "primaryKey": false, 1422 + "notNull": true 1423 + }, 1424 + "xata_version": { 1425 + "name": "xata_version", 1426 + "type": "text", 1427 + "primaryKey": false, 1428 + "notNull": false 1429 + }, 1430 + "xata_createdat": { 1431 + "name": "xata_createdat", 1432 + "type": "timestamp", 1433 + "primaryKey": false, 1434 + "notNull": true, 1435 + "default": "now()" 1436 + }, 1437 + "xata_updatedat": { 1438 + "name": "xata_updatedat", 1439 + "type": "timestamp", 1440 + "primaryKey": false, 1441 + "notNull": true, 1442 + "default": "now()" 1443 + } 1444 + }, 1445 + "indexes": {}, 1446 + "foreignKeys": { 1447 + "google_drive_accounts_user_id_users_xata_id_fk": { 1448 + "name": "google_drive_accounts_user_id_users_xata_id_fk", 1449 + "tableFrom": "google_drive_accounts", 1450 + "tableTo": "users", 1451 + "columnsFrom": [ 1452 + "user_id" 1453 + ], 1454 + "columnsTo": [ 1455 + "xata_id" 1456 + ], 1457 + "onDelete": "no action", 1458 + "onUpdate": "no action" 1459 + } 1460 + }, 1461 + "compositePrimaryKeys": {}, 1462 + "uniqueConstraints": { 1463 + "google_drive_accounts_email_unique": { 1464 + "name": "google_drive_accounts_email_unique", 1465 + "nullsNotDistinct": false, 1466 + "columns": [ 1467 + "email" 1468 + ] 1469 + } 1470 + }, 1471 + "policies": {}, 1472 + "checkConstraints": {}, 1473 + "isRLSEnabled": false 1474 + }, 1475 + "public.google_drive_directories": { 1476 + "name": "google_drive_directories", 1477 + "schema": "", 1478 + "columns": { 1479 + "xata_id": { 1480 + "name": "xata_id", 1481 + "type": "text", 1482 + "primaryKey": true, 1483 + "notNull": true, 1484 + "default": "xata_id()" 1485 + }, 1486 + "name": { 1487 + "name": "name", 1488 + "type": "text", 1489 + "primaryKey": false, 1490 + "notNull": true 1491 + }, 1492 + "path": { 1493 + "name": "path", 1494 + "type": "text", 1495 + "primaryKey": false, 1496 + "notNull": true 1497 + }, 1498 + "parent_id": { 1499 + "name": "parent_id", 1500 + "type": "text", 1501 + "primaryKey": false, 1502 + "notNull": false 1503 + }, 1504 + "google_drive_id": { 1505 + "name": "google_drive_id", 1506 + "type": "text", 1507 + "primaryKey": false, 1508 + "notNull": true 1509 + }, 1510 + "file_id": { 1511 + "name": "file_id", 1512 + "type": "text", 1513 + "primaryKey": false, 1514 + "notNull": true 1515 + }, 1516 + "xata_version": { 1517 + "name": "xata_version", 1518 + "type": "text", 1519 + "primaryKey": false, 1520 + "notNull": false 1521 + }, 1522 + "xata_createdat": { 1523 + "name": "xata_createdat", 1524 + "type": "timestamp", 1525 + "primaryKey": false, 1526 + "notNull": true, 1527 + "default": "now()" 1528 + }, 1529 + "xata_updatedat": { 1530 + "name": "xata_updatedat", 1531 + "type": "timestamp", 1532 + "primaryKey": false, 1533 + "notNull": true, 1534 + "default": "now()" 1535 + } 1536 + }, 1537 + "indexes": {}, 1538 + "foreignKeys": { 1539 + "google_drive_directories_parent_id_google_drive_directories_xata_id_fk": { 1540 + "name": "google_drive_directories_parent_id_google_drive_directories_xata_id_fk", 1541 + "tableFrom": "google_drive_directories", 1542 + "tableTo": "google_drive_directories", 1543 + "columnsFrom": [ 1544 + "parent_id" 1545 + ], 1546 + "columnsTo": [ 1547 + "xata_id" 1548 + ], 1549 + "onDelete": "no action", 1550 + "onUpdate": "no action" 1551 + } 1552 + }, 1553 + "compositePrimaryKeys": {}, 1554 + "uniqueConstraints": { 1555 + "google_drive_directories_file_id_unique": { 1556 + "name": "google_drive_directories_file_id_unique", 1557 + "nullsNotDistinct": false, 1558 + "columns": [ 1559 + "file_id" 1560 + ] 1561 + } 1562 + }, 1563 + "policies": {}, 1564 + "checkConstraints": {}, 1565 + "isRLSEnabled": false 1566 + }, 1567 + "public.google_drive_paths": { 1568 + "name": "google_drive_paths", 1569 + "schema": "", 1570 + "columns": { 1571 + "xata_id": { 1572 + "name": "xata_id", 1573 + "type": "text", 1574 + "primaryKey": true, 1575 + "notNull": true, 1576 + "default": "xata_id()" 1577 + }, 1578 + "google_drive_id": { 1579 + "name": "google_drive_id", 1580 + "type": "text", 1581 + "primaryKey": false, 1582 + "notNull": true 1583 + }, 1584 + "track_id": { 1585 + "name": "track_id", 1586 + "type": "text", 1587 + "primaryKey": false, 1588 + "notNull": true 1589 + }, 1590 + "name": { 1591 + "name": "name", 1592 + "type": "text", 1593 + "primaryKey": false, 1594 + "notNull": true 1595 + }, 1596 + "directory_id": { 1597 + "name": "directory_id", 1598 + "type": "text", 1599 + "primaryKey": false, 1600 + "notNull": false 1601 + }, 1602 + "file_id": { 1603 + "name": "file_id", 1604 + "type": "text", 1605 + "primaryKey": false, 1606 + "notNull": true 1607 + }, 1608 + "xata_version": { 1609 + "name": "xata_version", 1610 + "type": "text", 1611 + "primaryKey": false, 1612 + "notNull": false 1613 + }, 1614 + "xata_createdat": { 1615 + "name": "xata_createdat", 1616 + "type": "timestamp", 1617 + "primaryKey": false, 1618 + "notNull": true, 1619 + "default": "now()" 1620 + }, 1621 + "xata_updatedat": { 1622 + "name": "xata_updatedat", 1623 + "type": "timestamp", 1624 + "primaryKey": false, 1625 + "notNull": true, 1626 + "default": "now()" 1627 + } 1628 + }, 1629 + "indexes": {}, 1630 + "foreignKeys": { 1631 + "google_drive_paths_directory_id_google_drive_directories_xata_id_fk": { 1632 + "name": "google_drive_paths_directory_id_google_drive_directories_xata_id_fk", 1633 + "tableFrom": "google_drive_paths", 1634 + "tableTo": "google_drive_directories", 1635 + "columnsFrom": [ 1636 + "directory_id" 1637 + ], 1638 + "columnsTo": [ 1639 + "xata_id" 1640 + ], 1641 + "onDelete": "no action", 1642 + "onUpdate": "no action" 1643 + } 1644 + }, 1645 + "compositePrimaryKeys": {}, 1646 + "uniqueConstraints": { 1647 + "google_drive_paths_file_id_unique": { 1648 + "name": "google_drive_paths_file_id_unique", 1649 + "nullsNotDistinct": false, 1650 + "columns": [ 1651 + "file_id" 1652 + ] 1653 + } 1654 + }, 1655 + "policies": {}, 1656 + "checkConstraints": {}, 1657 + "isRLSEnabled": false 1658 + }, 1659 + "public.google_drive_tokens": { 1660 + "name": "google_drive_tokens", 1661 + "schema": "", 1662 + "columns": { 1663 + "xata_id": { 1664 + "name": "xata_id", 1665 + "type": "text", 1666 + "primaryKey": true, 1667 + "notNull": true, 1668 + "default": "xata_id()" 1669 + }, 1670 + "refresh_token": { 1671 + "name": "refresh_token", 1672 + "type": "text", 1673 + "primaryKey": false, 1674 + "notNull": true 1675 + }, 1676 + "xata_createdat": { 1677 + "name": "xata_createdat", 1678 + "type": "timestamp", 1679 + "primaryKey": false, 1680 + "notNull": true, 1681 + "default": "now()" 1682 + }, 1683 + "xata_updatedat": { 1684 + "name": "xata_updatedat", 1685 + "type": "timestamp", 1686 + "primaryKey": false, 1687 + "notNull": true, 1688 + "default": "now()" 1689 + } 1690 + }, 1691 + "indexes": {}, 1692 + "foreignKeys": {}, 1693 + "compositePrimaryKeys": {}, 1694 + "uniqueConstraints": {}, 1695 + "policies": {}, 1696 + "checkConstraints": {}, 1697 + "isRLSEnabled": false 1698 + }, 1699 + "public.google_drive": { 1700 + "name": "google_drive", 1701 + "schema": "", 1702 + "columns": { 1703 + "xata_id": { 1704 + "name": "xata_id", 1705 + "type": "text", 1706 + "primaryKey": true, 1707 + "notNull": true, 1708 + "default": "xata_id()" 1709 + }, 1710 + "google_drive_token_id": { 1711 + "name": "google_drive_token_id", 1712 + "type": "text", 1713 + "primaryKey": false, 1714 + "notNull": true 1715 + }, 1716 + "user_id": { 1717 + "name": "user_id", 1718 + "type": "text", 1719 + "primaryKey": false, 1720 + "notNull": true 1721 + }, 1722 + "xata_version": { 1723 + "name": "xata_version", 1724 + "type": "text", 1725 + "primaryKey": false, 1726 + "notNull": false 1727 + }, 1728 + "xata_createdat": { 1729 + "name": "xata_createdat", 1730 + "type": "timestamp", 1731 + "primaryKey": false, 1732 + "notNull": true, 1733 + "default": "now()" 1734 + }, 1735 + "xata_updatedat": { 1736 + "name": "xata_updatedat", 1737 + "type": "timestamp", 1738 + "primaryKey": false, 1739 + "notNull": true, 1740 + "default": "now()" 1741 + } 1742 + }, 1743 + "indexes": {}, 1744 + "foreignKeys": { 1745 + "google_drive_google_drive_token_id_google_drive_tokens_xata_id_fk": { 1746 + "name": "google_drive_google_drive_token_id_google_drive_tokens_xata_id_fk", 1747 + "tableFrom": "google_drive", 1748 + "tableTo": "google_drive_tokens", 1749 + "columnsFrom": [ 1750 + "google_drive_token_id" 1751 + ], 1752 + "columnsTo": [ 1753 + "xata_id" 1754 + ], 1755 + "onDelete": "no action", 1756 + "onUpdate": "no action" 1757 + }, 1758 + "google_drive_user_id_users_xata_id_fk": { 1759 + "name": "google_drive_user_id_users_xata_id_fk", 1760 + "tableFrom": "google_drive", 1761 + "tableTo": "users", 1762 + "columnsFrom": [ 1763 + "user_id" 1764 + ], 1765 + "columnsTo": [ 1766 + "xata_id" 1767 + ], 1768 + "onDelete": "no action", 1769 + "onUpdate": "no action" 1770 + } 1771 + }, 1772 + "compositePrimaryKeys": {}, 1773 + "uniqueConstraints": {}, 1774 + "policies": {}, 1775 + "checkConstraints": {}, 1776 + "isRLSEnabled": false 1777 + }, 1778 + "public.import_jobs": { 1779 + "name": "import_jobs", 1780 + "schema": "", 1781 + "columns": { 1782 + "xata_id": { 1783 + "name": "xata_id", 1784 + "type": "text", 1785 + "primaryKey": true, 1786 + "notNull": true, 1787 + "default": "xata_id()" 1788 + }, 1789 + "user_id": { 1790 + "name": "user_id", 1791 + "type": "text", 1792 + "primaryKey": false, 1793 + "notNull": false 1794 + }, 1795 + "type": { 1796 + "name": "type", 1797 + "type": "text", 1798 + "primaryKey": false, 1799 + "notNull": true 1800 + }, 1801 + "status": { 1802 + "name": "status", 1803 + "type": "text", 1804 + "primaryKey": false, 1805 + "notNull": true, 1806 + "default": "'pending'" 1807 + }, 1808 + "total": { 1809 + "name": "total", 1810 + "type": "integer", 1811 + "primaryKey": false, 1812 + "notNull": false, 1813 + "default": 0 1814 + }, 1815 + "processed": { 1816 + "name": "processed", 1817 + "type": "integer", 1818 + "primaryKey": false, 1819 + "notNull": false, 1820 + "default": 0 1821 + }, 1822 + "failed": { 1823 + "name": "failed", 1824 + "type": "integer", 1825 + "primaryKey": false, 1826 + "notNull": false, 1827 + "default": 0 1828 + }, 1829 + "errors": { 1830 + "name": "errors", 1831 + "type": "text", 1832 + "primaryKey": false, 1833 + "notNull": false 1834 + }, 1835 + "xata_createdat": { 1836 + "name": "xata_createdat", 1837 + "type": "timestamp", 1838 + "primaryKey": false, 1839 + "notNull": true, 1840 + "default": "now()" 1841 + }, 1842 + "xata_updatedat": { 1843 + "name": "xata_updatedat", 1844 + "type": "timestamp", 1845 + "primaryKey": false, 1846 + "notNull": true, 1847 + "default": "now()" 1848 + }, 1849 + "xata_version": { 1850 + "name": "xata_version", 1851 + "type": "integer", 1852 + "primaryKey": false, 1853 + "notNull": false 1854 + } 1855 + }, 1856 + "indexes": { 1857 + "import_jobs_user_id_idx": { 1858 + "name": "import_jobs_user_id_idx", 1859 + "columns": [ 1860 + { 1861 + "expression": "user_id", 1862 + "isExpression": false, 1863 + "asc": true, 1864 + "nulls": "last" 1865 + } 1866 + ], 1867 + "isUnique": false, 1868 + "concurrently": false, 1869 + "method": "btree", 1870 + "with": {} 1871 + }, 1872 + "import_jobs_status_idx": { 1873 + "name": "import_jobs_status_idx", 1874 + "columns": [ 1875 + { 1876 + "expression": "status", 1877 + "isExpression": false, 1878 + "asc": true, 1879 + "nulls": "last" 1880 + } 1881 + ], 1882 + "isUnique": false, 1883 + "concurrently": false, 1884 + "method": "btree", 1885 + "with": {} 1886 + } 1887 + }, 1888 + "foreignKeys": { 1889 + "import_jobs_user_id_users_xata_id_fk": { 1890 + "name": "import_jobs_user_id_users_xata_id_fk", 1891 + "tableFrom": "import_jobs", 1892 + "tableTo": "users", 1893 + "columnsFrom": [ 1894 + "user_id" 1895 + ], 1896 + "columnsTo": [ 1897 + "xata_id" 1898 + ], 1899 + "onDelete": "no action", 1900 + "onUpdate": "no action" 1901 + } 1902 + }, 1903 + "compositePrimaryKeys": {}, 1904 + "uniqueConstraints": {}, 1905 + "policies": {}, 1906 + "checkConstraints": {}, 1907 + "isRLSEnabled": false 1908 + }, 1909 + "public.loved_tracks": { 1910 + "name": "loved_tracks", 1911 + "schema": "", 1912 + "columns": { 1913 + "xata_id": { 1914 + "name": "xata_id", 1915 + "type": "text", 1916 + "primaryKey": true, 1917 + "notNull": true, 1918 + "default": "xata_id()" 1919 + }, 1920 + "user_id": { 1921 + "name": "user_id", 1922 + "type": "text", 1923 + "primaryKey": false, 1924 + "notNull": true 1925 + }, 1926 + "track_id": { 1927 + "name": "track_id", 1928 + "type": "text", 1929 + "primaryKey": false, 1930 + "notNull": true 1931 + }, 1932 + "uri": { 1933 + "name": "uri", 1934 + "type": "text", 1935 + "primaryKey": false, 1936 + "notNull": false 1937 + }, 1938 + "xata_createdat": { 1939 + "name": "xata_createdat", 1940 + "type": "timestamp", 1941 + "primaryKey": false, 1942 + "notNull": true, 1943 + "default": "now()" 1944 + } 1945 + }, 1946 + "indexes": { 1947 + "loved_tracks_track_id_idx": { 1948 + "name": "loved_tracks_track_id_idx", 1949 + "columns": [ 1950 + { 1951 + "expression": "track_id", 1952 + "isExpression": false, 1953 + "asc": true, 1954 + "nulls": "last" 1955 + } 1956 + ], 1957 + "isUnique": false, 1958 + "concurrently": false, 1959 + "method": "btree", 1960 + "with": {} 1961 + } 1962 + }, 1963 + "foreignKeys": { 1964 + "loved_tracks_user_id_users_xata_id_fk": { 1965 + "name": "loved_tracks_user_id_users_xata_id_fk", 1966 + "tableFrom": "loved_tracks", 1967 + "tableTo": "users", 1968 + "columnsFrom": [ 1969 + "user_id" 1970 + ], 1971 + "columnsTo": [ 1972 + "xata_id" 1973 + ], 1974 + "onDelete": "no action", 1975 + "onUpdate": "no action" 1976 + }, 1977 + "loved_tracks_track_id_tracks_xata_id_fk": { 1978 + "name": "loved_tracks_track_id_tracks_xata_id_fk", 1979 + "tableFrom": "loved_tracks", 1980 + "tableTo": "tracks", 1981 + "columnsFrom": [ 1982 + "track_id" 1983 + ], 1984 + "columnsTo": [ 1985 + "xata_id" 1986 + ], 1987 + "onDelete": "no action", 1988 + "onUpdate": "no action" 1989 + } 1990 + }, 1991 + "compositePrimaryKeys": {}, 1992 + "uniqueConstraints": { 1993 + "loved_tracks_uri_unique": { 1994 + "name": "loved_tracks_uri_unique", 1995 + "nullsNotDistinct": false, 1996 + "columns": [ 1997 + "uri" 1998 + ] 1999 + } 2000 + }, 2001 + "policies": {}, 2002 + "checkConstraints": {}, 2003 + "isRLSEnabled": false 2004 + }, 2005 + "public.mirror_sources": { 2006 + "name": "mirror_sources", 2007 + "schema": "", 2008 + "columns": { 2009 + "xata_id": { 2010 + "name": "xata_id", 2011 + "type": "text", 2012 + "primaryKey": true, 2013 + "notNull": true, 2014 + "default": "xata_id()" 2015 + }, 2016 + "user_id": { 2017 + "name": "user_id", 2018 + "type": "text", 2019 + "primaryKey": false, 2020 + "notNull": true 2021 + }, 2022 + "provider": { 2023 + "name": "provider", 2024 + "type": "text", 2025 + "primaryKey": false, 2026 + "notNull": true 2027 + }, 2028 + "enabled": { 2029 + "name": "enabled", 2030 + "type": "boolean", 2031 + "primaryKey": false, 2032 + "notNull": true, 2033 + "default": false 2034 + }, 2035 + "external_username": { 2036 + "name": "external_username", 2037 + "type": "text", 2038 + "primaryKey": false, 2039 + "notNull": false 2040 + }, 2041 + "encrypted_api_key": { 2042 + "name": "encrypted_api_key", 2043 + "type": "text", 2044 + "primaryKey": false, 2045 + "notNull": false 2046 + }, 2047 + "last_polled_at": { 2048 + "name": "last_polled_at", 2049 + "type": "timestamp", 2050 + "primaryKey": false, 2051 + "notNull": false 2052 + }, 2053 + "last_scrobble_seen_at": { 2054 + "name": "last_scrobble_seen_at", 2055 + "type": "timestamp", 2056 + "primaryKey": false, 2057 + "notNull": false 2058 + }, 2059 + "xata_createdat": { 2060 + "name": "xata_createdat", 2061 + "type": "timestamp", 2062 + "primaryKey": false, 2063 + "notNull": true, 2064 + "default": "now()" 2065 + }, 2066 + "xata_updatedat": { 2067 + "name": "xata_updatedat", 2068 + "type": "timestamp", 2069 + "primaryKey": false, 2070 + "notNull": true, 2071 + "default": "now()" 2072 + }, 2073 + "xata_version": { 2074 + "name": "xata_version", 2075 + "type": "integer", 2076 + "primaryKey": false, 2077 + "notNull": false 2078 + } 2079 + }, 2080 + "indexes": { 2081 + "mirror_sources_user_provider_idx": { 2082 + "name": "mirror_sources_user_provider_idx", 2083 + "columns": [ 2084 + { 2085 + "expression": "user_id", 2086 + "isExpression": false, 2087 + "asc": true, 2088 + "nulls": "last" 2089 + }, 2090 + { 2091 + "expression": "provider", 2092 + "isExpression": false, 2093 + "asc": true, 2094 + "nulls": "last" 2095 + } 2096 + ], 2097 + "isUnique": true, 2098 + "concurrently": false, 2099 + "method": "btree", 2100 + "with": {} 2101 + }, 2102 + "mirror_sources_enabled_provider_idx": { 2103 + "name": "mirror_sources_enabled_provider_idx", 2104 + "columns": [ 2105 + { 2106 + "expression": "enabled", 2107 + "isExpression": false, 2108 + "asc": true, 2109 + "nulls": "last" 2110 + }, 2111 + { 2112 + "expression": "provider", 2113 + "isExpression": false, 2114 + "asc": true, 2115 + "nulls": "last" 2116 + } 2117 + ], 2118 + "isUnique": false, 2119 + "concurrently": false, 2120 + "method": "btree", 2121 + "with": {} 2122 + } 2123 + }, 2124 + "foreignKeys": { 2125 + "mirror_sources_user_id_users_xata_id_fk": { 2126 + "name": "mirror_sources_user_id_users_xata_id_fk", 2127 + "tableFrom": "mirror_sources", 2128 + "tableTo": "users", 2129 + "columnsFrom": [ 2130 + "user_id" 2131 + ], 2132 + "columnsTo": [ 2133 + "xata_id" 2134 + ], 2135 + "onDelete": "no action", 2136 + "onUpdate": "no action" 2137 + } 2138 + }, 2139 + "compositePrimaryKeys": {}, 2140 + "uniqueConstraints": {}, 2141 + "policies": {}, 2142 + "checkConstraints": {}, 2143 + "isRLSEnabled": false 2144 + }, 2145 + "public.navidrome_playlist_tracks": { 2146 + "name": "navidrome_playlist_tracks", 2147 + "schema": "", 2148 + "columns": { 2149 + "xata_id": { 2150 + "name": "xata_id", 2151 + "type": "text", 2152 + "primaryKey": true, 2153 + "notNull": true, 2154 + "default": "xata_id()" 2155 + }, 2156 + "playlist_id": { 2157 + "name": "playlist_id", 2158 + "type": "text", 2159 + "primaryKey": false, 2160 + "notNull": true 2161 + }, 2162 + "track_id": { 2163 + "name": "track_id", 2164 + "type": "text", 2165 + "primaryKey": false, 2166 + "notNull": true 2167 + }, 2168 + "xata_createdat": { 2169 + "name": "xata_createdat", 2170 + "type": "timestamp with time zone", 2171 + "primaryKey": false, 2172 + "notNull": true, 2173 + "default": "now()" 2174 + } 2175 + }, 2176 + "indexes": {}, 2177 + "foreignKeys": { 2178 + "navidrome_playlist_tracks_playlist_id_navidrome_playlists_xata_id_fk": { 2179 + "name": "navidrome_playlist_tracks_playlist_id_navidrome_playlists_xata_id_fk", 2180 + "tableFrom": "navidrome_playlist_tracks", 2181 + "tableTo": "navidrome_playlists", 2182 + "columnsFrom": [ 2183 + "playlist_id" 2184 + ], 2185 + "columnsTo": [ 2186 + "xata_id" 2187 + ], 2188 + "onDelete": "no action", 2189 + "onUpdate": "no action" 2190 + }, 2191 + "navidrome_playlist_tracks_track_id_tracks_xata_id_fk": { 2192 + "name": "navidrome_playlist_tracks_track_id_tracks_xata_id_fk", 2193 + "tableFrom": "navidrome_playlist_tracks", 2194 + "tableTo": "tracks", 2195 + "columnsFrom": [ 2196 + "track_id" 2197 + ], 2198 + "columnsTo": [ 2199 + "xata_id" 2200 + ], 2201 + "onDelete": "no action", 2202 + "onUpdate": "no action" 2203 + } 2204 + }, 2205 + "compositePrimaryKeys": {}, 2206 + "uniqueConstraints": {}, 2207 + "policies": {}, 2208 + "checkConstraints": {}, 2209 + "isRLSEnabled": false 2210 + }, 2211 + "public.navidrome_playlists": { 2212 + "name": "navidrome_playlists", 2213 + "schema": "", 2214 + "columns": { 2215 + "xata_id": { 2216 + "name": "xata_id", 2217 + "type": "text", 2218 + "primaryKey": true, 2219 + "notNull": true, 2220 + "default": "xata_id()" 2221 + }, 2222 + "name": { 2223 + "name": "name", 2224 + "type": "text", 2225 + "primaryKey": false, 2226 + "notNull": true 2227 + }, 2228 + "description": { 2229 + "name": "description", 2230 + "type": "text", 2231 + "primaryKey": false, 2232 + "notNull": false 2233 + }, 2234 + "user_id": { 2235 + "name": "user_id", 2236 + "type": "text", 2237 + "primaryKey": false, 2238 + "notNull": true 2239 + }, 2240 + "xata_createdat": { 2241 + "name": "xata_createdat", 2242 + "type": "timestamp with time zone", 2243 + "primaryKey": false, 2244 + "notNull": true, 2245 + "default": "now()" 2246 + }, 2247 + "xata_updatedat": { 2248 + "name": "xata_updatedat", 2249 + "type": "timestamp with time zone", 2250 + "primaryKey": false, 2251 + "notNull": true, 2252 + "default": "now()" 2253 + } 2254 + }, 2255 + "indexes": {}, 2256 + "foreignKeys": { 2257 + "navidrome_playlists_user_id_users_xata_id_fk": { 2258 + "name": "navidrome_playlists_user_id_users_xata_id_fk", 2259 + "tableFrom": "navidrome_playlists", 2260 + "tableTo": "users", 2261 + "columnsFrom": [ 2262 + "user_id" 2263 + ], 2264 + "columnsTo": [ 2265 + "xata_id" 2266 + ], 2267 + "onDelete": "no action", 2268 + "onUpdate": "no action" 2269 + } 2270 + }, 2271 + "compositePrimaryKeys": {}, 2272 + "uniqueConstraints": {}, 2273 + "policies": {}, 2274 + "checkConstraints": {}, 2275 + "isRLSEnabled": false 2276 + }, 2277 + "public.notifications": { 2278 + "name": "notifications", 2279 + "schema": "", 2280 + "columns": { 2281 + "xata_id": { 2282 + "name": "xata_id", 2283 + "type": "text", 2284 + "primaryKey": true, 2285 + "notNull": true, 2286 + "default": "xata_id()" 2287 + }, 2288 + "user_id": { 2289 + "name": "user_id", 2290 + "type": "text", 2291 + "primaryKey": false, 2292 + "notNull": true 2293 + }, 2294 + "actor_id": { 2295 + "name": "actor_id", 2296 + "type": "text", 2297 + "primaryKey": false, 2298 + "notNull": true 2299 + }, 2300 + "type": { 2301 + "name": "type", 2302 + "type": "text", 2303 + "primaryKey": false, 2304 + "notNull": true 2305 + }, 2306 + "shout_id": { 2307 + "name": "shout_id", 2308 + "type": "text", 2309 + "primaryKey": false, 2310 + "notNull": false 2311 + }, 2312 + "subject_uri": { 2313 + "name": "subject_uri", 2314 + "type": "text", 2315 + "primaryKey": false, 2316 + "notNull": false 2317 + }, 2318 + "read": { 2319 + "name": "read", 2320 + "type": "boolean", 2321 + "primaryKey": false, 2322 + "notNull": true, 2323 + "default": false 2324 + }, 2325 + "read_at": { 2326 + "name": "read_at", 2327 + "type": "timestamp with time zone", 2328 + "primaryKey": false, 2329 + "notNull": false 2330 + }, 2331 + "xata_createdat": { 2332 + "name": "xata_createdat", 2333 + "type": "timestamp", 2334 + "primaryKey": false, 2335 + "notNull": true, 2336 + "default": "now()" 2337 + } 2338 + }, 2339 + "indexes": { 2340 + "notifications_user_id_read_idx": { 2341 + "name": "notifications_user_id_read_idx", 2342 + "columns": [ 2343 + { 2344 + "expression": "user_id", 2345 + "isExpression": false, 2346 + "asc": true, 2347 + "nulls": "last" 2348 + }, 2349 + { 2350 + "expression": "read", 2351 + "isExpression": false, 2352 + "asc": true, 2353 + "nulls": "last" 2354 + } 2355 + ], 2356 + "isUnique": false, 2357 + "concurrently": false, 2358 + "method": "btree", 2359 + "with": {} 2360 + }, 2361 + "notifications_user_id_createdat_idx": { 2362 + "name": "notifications_user_id_createdat_idx", 2363 + "columns": [ 2364 + { 2365 + "expression": "user_id", 2366 + "isExpression": false, 2367 + "asc": true, 2368 + "nulls": "last" 2369 + }, 2370 + { 2371 + "expression": "xata_createdat", 2372 + "isExpression": false, 2373 + "asc": true, 2374 + "nulls": "last" 2375 + } 2376 + ], 2377 + "isUnique": false, 2378 + "concurrently": false, 2379 + "method": "btree", 2380 + "with": {} 2381 + } 2382 + }, 2383 + "foreignKeys": { 2384 + "notifications_user_id_users_xata_id_fk": { 2385 + "name": "notifications_user_id_users_xata_id_fk", 2386 + "tableFrom": "notifications", 2387 + "tableTo": "users", 2388 + "columnsFrom": [ 2389 + "user_id" 2390 + ], 2391 + "columnsTo": [ 2392 + "xata_id" 2393 + ], 2394 + "onDelete": "no action", 2395 + "onUpdate": "no action" 2396 + }, 2397 + "notifications_actor_id_users_xata_id_fk": { 2398 + "name": "notifications_actor_id_users_xata_id_fk", 2399 + "tableFrom": "notifications", 2400 + "tableTo": "users", 2401 + "columnsFrom": [ 2402 + "actor_id" 2403 + ], 2404 + "columnsTo": [ 2405 + "xata_id" 2406 + ], 2407 + "onDelete": "no action", 2408 + "onUpdate": "no action" 2409 + }, 2410 + "notifications_shout_id_shouts_xata_id_fk": { 2411 + "name": "notifications_shout_id_shouts_xata_id_fk", 2412 + "tableFrom": "notifications", 2413 + "tableTo": "shouts", 2414 + "columnsFrom": [ 2415 + "shout_id" 2416 + ], 2417 + "columnsTo": [ 2418 + "xata_id" 2419 + ], 2420 + "onDelete": "no action", 2421 + "onUpdate": "no action" 2422 + } 2423 + }, 2424 + "compositePrimaryKeys": {}, 2425 + "uniqueConstraints": {}, 2426 + "policies": {}, 2427 + "checkConstraints": {}, 2428 + "isRLSEnabled": false 2429 + }, 2430 + "public.playlist_tracks": { 2431 + "name": "playlist_tracks", 2432 + "schema": "", 2433 + "columns": { 2434 + "xata_id": { 2435 + "name": "xata_id", 2436 + "type": "text", 2437 + "primaryKey": true, 2438 + "notNull": true, 2439 + "default": "xata_id()" 2440 + }, 2441 + "playlist_id": { 2442 + "name": "playlist_id", 2443 + "type": "text", 2444 + "primaryKey": false, 2445 + "notNull": true 2446 + }, 2447 + "track_id": { 2448 + "name": "track_id", 2449 + "type": "text", 2450 + "primaryKey": false, 2451 + "notNull": true 2452 + }, 2453 + "xata_createdat": { 2454 + "name": "xata_createdat", 2455 + "type": "timestamp", 2456 + "primaryKey": false, 2457 + "notNull": true, 2458 + "default": "now()" 2459 + } 2460 + }, 2461 + "indexes": {}, 2462 + "foreignKeys": { 2463 + "playlist_tracks_playlist_id_playlists_xata_id_fk": { 2464 + "name": "playlist_tracks_playlist_id_playlists_xata_id_fk", 2465 + "tableFrom": "playlist_tracks", 2466 + "tableTo": "playlists", 2467 + "columnsFrom": [ 2468 + "playlist_id" 2469 + ], 2470 + "columnsTo": [ 2471 + "xata_id" 2472 + ], 2473 + "onDelete": "no action", 2474 + "onUpdate": "no action" 2475 + }, 2476 + "playlist_tracks_track_id_tracks_xata_id_fk": { 2477 + "name": "playlist_tracks_track_id_tracks_xata_id_fk", 2478 + "tableFrom": "playlist_tracks", 2479 + "tableTo": "tracks", 2480 + "columnsFrom": [ 2481 + "track_id" 2482 + ], 2483 + "columnsTo": [ 2484 + "xata_id" 2485 + ], 2486 + "onDelete": "no action", 2487 + "onUpdate": "no action" 2488 + } 2489 + }, 2490 + "compositePrimaryKeys": {}, 2491 + "uniqueConstraints": {}, 2492 + "policies": {}, 2493 + "checkConstraints": {}, 2494 + "isRLSEnabled": false 2495 + }, 2496 + "public.playlists": { 2497 + "name": "playlists", 2498 + "schema": "", 2499 + "columns": { 2500 + "xata_id": { 2501 + "name": "xata_id", 2502 + "type": "text", 2503 + "primaryKey": true, 2504 + "notNull": true, 2505 + "default": "xata_id()" 2506 + }, 2507 + "name": { 2508 + "name": "name", 2509 + "type": "text", 2510 + "primaryKey": false, 2511 + "notNull": true 2512 + }, 2513 + "picture": { 2514 + "name": "picture", 2515 + "type": "text", 2516 + "primaryKey": false, 2517 + "notNull": false 2518 + }, 2519 + "description": { 2520 + "name": "description", 2521 + "type": "text", 2522 + "primaryKey": false, 2523 + "notNull": false 2524 + }, 2525 + "uri": { 2526 + "name": "uri", 2527 + "type": "text", 2528 + "primaryKey": false, 2529 + "notNull": false 2530 + }, 2531 + "spotify_link": { 2532 + "name": "spotify_link", 2533 + "type": "text", 2534 + "primaryKey": false, 2535 + "notNull": false 2536 + }, 2537 + "tidal_link": { 2538 + "name": "tidal_link", 2539 + "type": "text", 2540 + "primaryKey": false, 2541 + "notNull": false 2542 + }, 2543 + "apple_music_link": { 2544 + "name": "apple_music_link", 2545 + "type": "text", 2546 + "primaryKey": false, 2547 + "notNull": false 2548 + }, 2549 + "created_by": { 2550 + "name": "created_by", 2551 + "type": "text", 2552 + "primaryKey": false, 2553 + "notNull": true 2554 + }, 2555 + "xata_createdat": { 2556 + "name": "xata_createdat", 2557 + "type": "timestamp", 2558 + "primaryKey": false, 2559 + "notNull": true, 2560 + "default": "now()" 2561 + }, 2562 + "xata_updatedat": { 2563 + "name": "xata_updatedat", 2564 + "type": "timestamp", 2565 + "primaryKey": false, 2566 + "notNull": true, 2567 + "default": "now()" 2568 + } 2569 + }, 2570 + "indexes": {}, 2571 + "foreignKeys": { 2572 + "playlists_created_by_users_xata_id_fk": { 2573 + "name": "playlists_created_by_users_xata_id_fk", 2574 + "tableFrom": "playlists", 2575 + "tableTo": "users", 2576 + "columnsFrom": [ 2577 + "created_by" 2578 + ], 2579 + "columnsTo": [ 2580 + "xata_id" 2581 + ], 2582 + "onDelete": "no action", 2583 + "onUpdate": "no action" 2584 + } 2585 + }, 2586 + "compositePrimaryKeys": {}, 2587 + "uniqueConstraints": { 2588 + "playlists_uri_unique": { 2589 + "name": "playlists_uri_unique", 2590 + "nullsNotDistinct": false, 2591 + "columns": [ 2592 + "uri" 2593 + ] 2594 + } 2595 + }, 2596 + "policies": {}, 2597 + "checkConstraints": {}, 2598 + "isRLSEnabled": false 2599 + }, 2600 + "public.profile_shouts": { 2601 + "name": "profile_shouts", 2602 + "schema": "", 2603 + "columns": { 2604 + "xata_id": { 2605 + "name": "xata_id", 2606 + "type": "text", 2607 + "primaryKey": true, 2608 + "notNull": true, 2609 + "default": "xata_id()" 2610 + }, 2611 + "user_id": { 2612 + "name": "user_id", 2613 + "type": "text", 2614 + "primaryKey": false, 2615 + "notNull": true 2616 + }, 2617 + "shout_id": { 2618 + "name": "shout_id", 2619 + "type": "text", 2620 + "primaryKey": false, 2621 + "notNull": true 2622 + }, 2623 + "xata_createdat": { 2624 + "name": "xata_createdat", 2625 + "type": "timestamp", 2626 + "primaryKey": false, 2627 + "notNull": true, 2628 + "default": "now()" 2629 + } 2630 + }, 2631 + "indexes": {}, 2632 + "foreignKeys": { 2633 + "profile_shouts_user_id_users_xata_id_fk": { 2634 + "name": "profile_shouts_user_id_users_xata_id_fk", 2635 + "tableFrom": "profile_shouts", 2636 + "tableTo": "users", 2637 + "columnsFrom": [ 2638 + "user_id" 2639 + ], 2640 + "columnsTo": [ 2641 + "xata_id" 2642 + ], 2643 + "onDelete": "no action", 2644 + "onUpdate": "no action" 2645 + }, 2646 + "profile_shouts_shout_id_shouts_xata_id_fk": { 2647 + "name": "profile_shouts_shout_id_shouts_xata_id_fk", 2648 + "tableFrom": "profile_shouts", 2649 + "tableTo": "shouts", 2650 + "columnsFrom": [ 2651 + "shout_id" 2652 + ], 2653 + "columnsTo": [ 2654 + "xata_id" 2655 + ], 2656 + "onDelete": "no action", 2657 + "onUpdate": "no action" 2658 + } 2659 + }, 2660 + "compositePrimaryKeys": {}, 2661 + "uniqueConstraints": {}, 2662 + "policies": {}, 2663 + "checkConstraints": {}, 2664 + "isRLSEnabled": false 2665 + }, 2666 + "public.queue_tracks": { 2667 + "name": "queue_tracks", 2668 + "schema": "", 2669 + "columns": { 2670 + "xata_id": { 2671 + "name": "xata_id", 2672 + "type": "text", 2673 + "primaryKey": true, 2674 + "notNull": true, 2675 + "default": "xata_id()" 2676 + }, 2677 + "user_id": { 2678 + "name": "user_id", 2679 + "type": "text", 2680 + "primaryKey": false, 2681 + "notNull": true 2682 + }, 2683 + "track_id": { 2684 + "name": "track_id", 2685 + "type": "text", 2686 + "primaryKey": false, 2687 + "notNull": true 2688 + }, 2689 + "position": { 2690 + "name": "position", 2691 + "type": "integer", 2692 + "primaryKey": false, 2693 + "notNull": true 2694 + }, 2695 + "file_uri": { 2696 + "name": "file_uri", 2697 + "type": "text", 2698 + "primaryKey": false, 2699 + "notNull": true 2700 + }, 2701 + "xata_version": { 2702 + "name": "xata_version", 2703 + "type": "integer", 2704 + "primaryKey": false, 2705 + "notNull": true, 2706 + "default": 0 2707 + }, 2708 + "xata_createdat": { 2709 + "name": "xata_createdat", 2710 + "type": "timestamp", 2711 + "primaryKey": false, 2712 + "notNull": true, 2713 + "default": "now()" 2714 + }, 2715 + "xata_updatedat": { 2716 + "name": "xata_updatedat", 2717 + "type": "timestamp", 2718 + "primaryKey": false, 2719 + "notNull": true, 2720 + "default": "now()" 2721 + } 2722 + }, 2723 + "indexes": {}, 2724 + "foreignKeys": { 2725 + "queue_tracks_user_id_users_xata_id_fk": { 2726 + "name": "queue_tracks_user_id_users_xata_id_fk", 2727 + "tableFrom": "queue_tracks", 2728 + "tableTo": "users", 2729 + "columnsFrom": [ 2730 + "user_id" 2731 + ], 2732 + "columnsTo": [ 2733 + "xata_id" 2734 + ], 2735 + "onDelete": "no action", 2736 + "onUpdate": "no action" 2737 + }, 2738 + "queue_tracks_track_id_tracks_xata_id_fk": { 2739 + "name": "queue_tracks_track_id_tracks_xata_id_fk", 2740 + "tableFrom": "queue_tracks", 2741 + "tableTo": "tracks", 2742 + "columnsFrom": [ 2743 + "track_id" 2744 + ], 2745 + "columnsTo": [ 2746 + "xata_id" 2747 + ], 2748 + "onDelete": "no action", 2749 + "onUpdate": "no action" 2750 + } 2751 + }, 2752 + "compositePrimaryKeys": {}, 2753 + "uniqueConstraints": {}, 2754 + "policies": {}, 2755 + "checkConstraints": {}, 2756 + "isRLSEnabled": false 2757 + }, 2758 + "public.scrobbles": { 2759 + "name": "scrobbles", 2760 + "schema": "", 2761 + "columns": { 2762 + "xata_id": { 2763 + "name": "xata_id", 2764 + "type": "text", 2765 + "primaryKey": true, 2766 + "notNull": true, 2767 + "default": "xata_id()" 2768 + }, 2769 + "user_id": { 2770 + "name": "user_id", 2771 + "type": "text", 2772 + "primaryKey": false, 2773 + "notNull": false 2774 + }, 2775 + "track_id": { 2776 + "name": "track_id", 2777 + "type": "text", 2778 + "primaryKey": false, 2779 + "notNull": false 2780 + }, 2781 + "album_id": { 2782 + "name": "album_id", 2783 + "type": "text", 2784 + "primaryKey": false, 2785 + "notNull": false 2786 + }, 2787 + "artist_id": { 2788 + "name": "artist_id", 2789 + "type": "text", 2790 + "primaryKey": false, 2791 + "notNull": false 2792 + }, 2793 + "uri": { 2794 + "name": "uri", 2795 + "type": "text", 2796 + "primaryKey": false, 2797 + "notNull": false 2798 + }, 2799 + "xata_createdat": { 2800 + "name": "xata_createdat", 2801 + "type": "timestamp", 2802 + "primaryKey": false, 2803 + "notNull": true, 2804 + "default": "now()" 2805 + }, 2806 + "xata_updatedat": { 2807 + "name": "xata_updatedat", 2808 + "type": "timestamp", 2809 + "primaryKey": false, 2810 + "notNull": true, 2811 + "default": "now()" 2812 + }, 2813 + "xata_version": { 2814 + "name": "xata_version", 2815 + "type": "integer", 2816 + "primaryKey": false, 2817 + "notNull": false 2818 + }, 2819 + "timestamp": { 2820 + "name": "timestamp", 2821 + "type": "timestamp", 2822 + "primaryKey": false, 2823 + "notNull": true, 2824 + "default": "now()" 2825 + } 2826 + }, 2827 + "indexes": { 2828 + "scrobbles_user_id_timestamp_idx": { 2829 + "name": "scrobbles_user_id_timestamp_idx", 2830 + "columns": [ 2831 + { 2832 + "expression": "user_id", 2833 + "isExpression": false, 2834 + "asc": true, 2835 + "nulls": "last" 2836 + }, 2837 + { 2838 + "expression": "timestamp", 2839 + "isExpression": false, 2840 + "asc": true, 2841 + "nulls": "last" 2842 + } 2843 + ], 2844 + "isUnique": false, 2845 + "concurrently": false, 2846 + "method": "btree", 2847 + "with": {} 2848 + }, 2849 + "scrobbles_artist_id_idx": { 2850 + "name": "scrobbles_artist_id_idx", 2851 + "columns": [ 2852 + { 2853 + "expression": "artist_id", 2854 + "isExpression": false, 2855 + "asc": true, 2856 + "nulls": "last" 2857 + } 2858 + ], 2859 + "isUnique": false, 2860 + "concurrently": false, 2861 + "method": "btree", 2862 + "with": {} 2863 + }, 2864 + "scrobbles_album_id_idx": { 2865 + "name": "scrobbles_album_id_idx", 2866 + "columns": [ 2867 + { 2868 + "expression": "album_id", 2869 + "isExpression": false, 2870 + "asc": true, 2871 + "nulls": "last" 2872 + } 2873 + ], 2874 + "isUnique": false, 2875 + "concurrently": false, 2876 + "method": "btree", 2877 + "with": {} 2878 + }, 2879 + "scrobbles_track_id_idx": { 2880 + "name": "scrobbles_track_id_idx", 2881 + "columns": [ 2882 + { 2883 + "expression": "track_id", 2884 + "isExpression": false, 2885 + "asc": true, 2886 + "nulls": "last" 2887 + } 2888 + ], 2889 + "isUnique": false, 2890 + "concurrently": false, 2891 + "method": "btree", 2892 + "with": {} 2893 + }, 2894 + "scrobbles_timestamp_idx": { 2895 + "name": "scrobbles_timestamp_idx", 2896 + "columns": [ 2897 + { 2898 + "expression": "timestamp", 2899 + "isExpression": false, 2900 + "asc": true, 2901 + "nulls": "last" 2902 + } 2903 + ], 2904 + "isUnique": false, 2905 + "concurrently": false, 2906 + "method": "btree", 2907 + "with": {} 2908 + } 2909 + }, 2910 + "foreignKeys": { 2911 + "scrobbles_user_id_users_xata_id_fk": { 2912 + "name": "scrobbles_user_id_users_xata_id_fk", 2913 + "tableFrom": "scrobbles", 2914 + "tableTo": "users", 2915 + "columnsFrom": [ 2916 + "user_id" 2917 + ], 2918 + "columnsTo": [ 2919 + "xata_id" 2920 + ], 2921 + "onDelete": "no action", 2922 + "onUpdate": "no action" 2923 + }, 2924 + "scrobbles_track_id_tracks_xata_id_fk": { 2925 + "name": "scrobbles_track_id_tracks_xata_id_fk", 2926 + "tableFrom": "scrobbles", 2927 + "tableTo": "tracks", 2928 + "columnsFrom": [ 2929 + "track_id" 2930 + ], 2931 + "columnsTo": [ 2932 + "xata_id" 2933 + ], 2934 + "onDelete": "no action", 2935 + "onUpdate": "no action" 2936 + }, 2937 + "scrobbles_album_id_albums_xata_id_fk": { 2938 + "name": "scrobbles_album_id_albums_xata_id_fk", 2939 + "tableFrom": "scrobbles", 2940 + "tableTo": "albums", 2941 + "columnsFrom": [ 2942 + "album_id" 2943 + ], 2944 + "columnsTo": [ 2945 + "xata_id" 2946 + ], 2947 + "onDelete": "no action", 2948 + "onUpdate": "no action" 2949 + }, 2950 + "scrobbles_artist_id_artists_xata_id_fk": { 2951 + "name": "scrobbles_artist_id_artists_xata_id_fk", 2952 + "tableFrom": "scrobbles", 2953 + "tableTo": "artists", 2954 + "columnsFrom": [ 2955 + "artist_id" 2956 + ], 2957 + "columnsTo": [ 2958 + "xata_id" 2959 + ], 2960 + "onDelete": "no action", 2961 + "onUpdate": "no action" 2962 + } 2963 + }, 2964 + "compositePrimaryKeys": {}, 2965 + "uniqueConstraints": { 2966 + "scrobbles_uri_unique": { 2967 + "name": "scrobbles_uri_unique", 2968 + "nullsNotDistinct": false, 2969 + "columns": [ 2970 + "uri" 2971 + ] 2972 + }, 2973 + "scrobbles_user_track_timestamp_unique": { 2974 + "name": "scrobbles_user_track_timestamp_unique", 2975 + "nullsNotDistinct": false, 2976 + "columns": [ 2977 + "user_id", 2978 + "track_id", 2979 + "timestamp" 2980 + ] 2981 + } 2982 + }, 2983 + "policies": {}, 2984 + "checkConstraints": {}, 2985 + "isRLSEnabled": false 2986 + }, 2987 + "public.shout_likes": { 2988 + "name": "shout_likes", 2989 + "schema": "", 2990 + "columns": { 2991 + "xata_id": { 2992 + "name": "xata_id", 2993 + "type": "text", 2994 + "primaryKey": true, 2995 + "notNull": true, 2996 + "default": "xata_id()" 2997 + }, 2998 + "user_id": { 2999 + "name": "user_id", 3000 + "type": "text", 3001 + "primaryKey": false, 3002 + "notNull": true 3003 + }, 3004 + "shout_id": { 3005 + "name": "shout_id", 3006 + "type": "text", 3007 + "primaryKey": false, 3008 + "notNull": true 3009 + }, 3010 + "xata_createdat": { 3011 + "name": "xata_createdat", 3012 + "type": "timestamp", 3013 + "primaryKey": false, 3014 + "notNull": true, 3015 + "default": "now()" 3016 + }, 3017 + "uri": { 3018 + "name": "uri", 3019 + "type": "text", 3020 + "primaryKey": false, 3021 + "notNull": true 3022 + } 3023 + }, 3024 + "indexes": {}, 3025 + "foreignKeys": { 3026 + "shout_likes_user_id_users_xata_id_fk": { 3027 + "name": "shout_likes_user_id_users_xata_id_fk", 3028 + "tableFrom": "shout_likes", 3029 + "tableTo": "users", 3030 + "columnsFrom": [ 3031 + "user_id" 3032 + ], 3033 + "columnsTo": [ 3034 + "xata_id" 3035 + ], 3036 + "onDelete": "no action", 3037 + "onUpdate": "no action" 3038 + }, 3039 + "shout_likes_shout_id_shouts_xata_id_fk": { 3040 + "name": "shout_likes_shout_id_shouts_xata_id_fk", 3041 + "tableFrom": "shout_likes", 3042 + "tableTo": "shouts", 3043 + "columnsFrom": [ 3044 + "shout_id" 3045 + ], 3046 + "columnsTo": [ 3047 + "xata_id" 3048 + ], 3049 + "onDelete": "no action", 3050 + "onUpdate": "no action" 3051 + } 3052 + }, 3053 + "compositePrimaryKeys": {}, 3054 + "uniqueConstraints": { 3055 + "shout_likes_uri_unique": { 3056 + "name": "shout_likes_uri_unique", 3057 + "nullsNotDistinct": false, 3058 + "columns": [ 3059 + "uri" 3060 + ] 3061 + } 3062 + }, 3063 + "policies": {}, 3064 + "checkConstraints": {}, 3065 + "isRLSEnabled": false 3066 + }, 3067 + "public.shout_reports": { 3068 + "name": "shout_reports", 3069 + "schema": "", 3070 + "columns": { 3071 + "xata_id": { 3072 + "name": "xata_id", 3073 + "type": "text", 3074 + "primaryKey": true, 3075 + "notNull": true, 3076 + "default": "xata_id()" 3077 + }, 3078 + "user_id": { 3079 + "name": "user_id", 3080 + "type": "text", 3081 + "primaryKey": false, 3082 + "notNull": true 3083 + }, 3084 + "shout_id": { 3085 + "name": "shout_id", 3086 + "type": "text", 3087 + "primaryKey": false, 3088 + "notNull": true 3089 + }, 3090 + "xata_createdat": { 3091 + "name": "xata_createdat", 3092 + "type": "timestamp", 3093 + "primaryKey": false, 3094 + "notNull": true, 3095 + "default": "now()" 3096 + } 3097 + }, 3098 + "indexes": {}, 3099 + "foreignKeys": { 3100 + "shout_reports_user_id_users_xata_id_fk": { 3101 + "name": "shout_reports_user_id_users_xata_id_fk", 3102 + "tableFrom": "shout_reports", 3103 + "tableTo": "users", 3104 + "columnsFrom": [ 3105 + "user_id" 3106 + ], 3107 + "columnsTo": [ 3108 + "xata_id" 3109 + ], 3110 + "onDelete": "no action", 3111 + "onUpdate": "no action" 3112 + }, 3113 + "shout_reports_shout_id_shouts_xata_id_fk": { 3114 + "name": "shout_reports_shout_id_shouts_xata_id_fk", 3115 + "tableFrom": "shout_reports", 3116 + "tableTo": "shouts", 3117 + "columnsFrom": [ 3118 + "shout_id" 3119 + ], 3120 + "columnsTo": [ 3121 + "xata_id" 3122 + ], 3123 + "onDelete": "no action", 3124 + "onUpdate": "no action" 3125 + } 3126 + }, 3127 + "compositePrimaryKeys": {}, 3128 + "uniqueConstraints": {}, 3129 + "policies": {}, 3130 + "checkConstraints": {}, 3131 + "isRLSEnabled": false 3132 + }, 3133 + "public.shouts": { 3134 + "name": "shouts", 3135 + "schema": "", 3136 + "columns": { 3137 + "xata_id": { 3138 + "name": "xata_id", 3139 + "type": "text", 3140 + "primaryKey": true, 3141 + "notNull": true, 3142 + "default": "xata_id()" 3143 + }, 3144 + "content": { 3145 + "name": "content", 3146 + "type": "text", 3147 + "primaryKey": false, 3148 + "notNull": true 3149 + }, 3150 + "track_id": { 3151 + "name": "track_id", 3152 + "type": "text", 3153 + "primaryKey": false, 3154 + "notNull": false 3155 + }, 3156 + "artist_id": { 3157 + "name": "artist_id", 3158 + "type": "text", 3159 + "primaryKey": false, 3160 + "notNull": false 3161 + }, 3162 + "album_id": { 3163 + "name": "album_id", 3164 + "type": "text", 3165 + "primaryKey": false, 3166 + "notNull": false 3167 + }, 3168 + "scrobble_id": { 3169 + "name": "scrobble_id", 3170 + "type": "text", 3171 + "primaryKey": false, 3172 + "notNull": false 3173 + }, 3174 + "uri": { 3175 + "name": "uri", 3176 + "type": "text", 3177 + "primaryKey": false, 3178 + "notNull": true 3179 + }, 3180 + "author_id": { 3181 + "name": "author_id", 3182 + "type": "text", 3183 + "primaryKey": false, 3184 + "notNull": true 3185 + }, 3186 + "parent_id": { 3187 + "name": "parent_id", 3188 + "type": "text", 3189 + "primaryKey": false, 3190 + "notNull": false 3191 + }, 3192 + "gif_url": { 3193 + "name": "gif_url", 3194 + "type": "text", 3195 + "primaryKey": false, 3196 + "notNull": false 3197 + }, 3198 + "gif_preview_url": { 3199 + "name": "gif_preview_url", 3200 + "type": "text", 3201 + "primaryKey": false, 3202 + "notNull": false 3203 + }, 3204 + "gif_alt": { 3205 + "name": "gif_alt", 3206 + "type": "text", 3207 + "primaryKey": false, 3208 + "notNull": false 3209 + }, 3210 + "gif_width": { 3211 + "name": "gif_width", 3212 + "type": "integer", 3213 + "primaryKey": false, 3214 + "notNull": false 3215 + }, 3216 + "gif_height": { 3217 + "name": "gif_height", 3218 + "type": "integer", 3219 + "primaryKey": false, 3220 + "notNull": false 3221 + }, 3222 + "facets": { 3223 + "name": "facets", 3224 + "type": "jsonb", 3225 + "primaryKey": false, 3226 + "notNull": false 3227 + }, 3228 + "xata_createdat": { 3229 + "name": "xata_createdat", 3230 + "type": "timestamp", 3231 + "primaryKey": false, 3232 + "notNull": true, 3233 + "default": "now()" 3234 + }, 3235 + "xata_updatedat": { 3236 + "name": "xata_updatedat", 3237 + "type": "timestamp", 3238 + "primaryKey": false, 3239 + "notNull": true, 3240 + "default": "now()" 3241 + } 3242 + }, 3243 + "indexes": {}, 3244 + "foreignKeys": { 3245 + "shouts_track_id_tracks_xata_id_fk": { 3246 + "name": "shouts_track_id_tracks_xata_id_fk", 3247 + "tableFrom": "shouts", 3248 + "tableTo": "tracks", 3249 + "columnsFrom": [ 3250 + "track_id" 3251 + ], 3252 + "columnsTo": [ 3253 + "xata_id" 3254 + ], 3255 + "onDelete": "no action", 3256 + "onUpdate": "no action" 3257 + }, 3258 + "shouts_artist_id_users_xata_id_fk": { 3259 + "name": "shouts_artist_id_users_xata_id_fk", 3260 + "tableFrom": "shouts", 3261 + "tableTo": "users", 3262 + "columnsFrom": [ 3263 + "artist_id" 3264 + ], 3265 + "columnsTo": [ 3266 + "xata_id" 3267 + ], 3268 + "onDelete": "no action", 3269 + "onUpdate": "no action" 3270 + }, 3271 + "shouts_album_id_albums_xata_id_fk": { 3272 + "name": "shouts_album_id_albums_xata_id_fk", 3273 + "tableFrom": "shouts", 3274 + "tableTo": "albums", 3275 + "columnsFrom": [ 3276 + "album_id" 3277 + ], 3278 + "columnsTo": [ 3279 + "xata_id" 3280 + ], 3281 + "onDelete": "no action", 3282 + "onUpdate": "no action" 3283 + }, 3284 + "shouts_scrobble_id_scrobbles_xata_id_fk": { 3285 + "name": "shouts_scrobble_id_scrobbles_xata_id_fk", 3286 + "tableFrom": "shouts", 3287 + "tableTo": "scrobbles", 3288 + "columnsFrom": [ 3289 + "scrobble_id" 3290 + ], 3291 + "columnsTo": [ 3292 + "xata_id" 3293 + ], 3294 + "onDelete": "no action", 3295 + "onUpdate": "no action" 3296 + }, 3297 + "shouts_author_id_users_xata_id_fk": { 3298 + "name": "shouts_author_id_users_xata_id_fk", 3299 + "tableFrom": "shouts", 3300 + "tableTo": "users", 3301 + "columnsFrom": [ 3302 + "author_id" 3303 + ], 3304 + "columnsTo": [ 3305 + "xata_id" 3306 + ], 3307 + "onDelete": "no action", 3308 + "onUpdate": "no action" 3309 + }, 3310 + "shouts_parent_id_shouts_xata_id_fk": { 3311 + "name": "shouts_parent_id_shouts_xata_id_fk", 3312 + "tableFrom": "shouts", 3313 + "tableTo": "shouts", 3314 + "columnsFrom": [ 3315 + "parent_id" 3316 + ], 3317 + "columnsTo": [ 3318 + "xata_id" 3319 + ], 3320 + "onDelete": "no action", 3321 + "onUpdate": "no action" 3322 + } 3323 + }, 3324 + "compositePrimaryKeys": {}, 3325 + "uniqueConstraints": { 3326 + "shouts_uri_unique": { 3327 + "name": "shouts_uri_unique", 3328 + "nullsNotDistinct": false, 3329 + "columns": [ 3330 + "uri" 3331 + ] 3332 + } 3333 + }, 3334 + "policies": {}, 3335 + "checkConstraints": {}, 3336 + "isRLSEnabled": false 3337 + }, 3338 + "public.spotify_accounts": { 3339 + "name": "spotify_accounts", 3340 + "schema": "", 3341 + "columns": { 3342 + "xata_id": { 3343 + "name": "xata_id", 3344 + "type": "text", 3345 + "primaryKey": true, 3346 + "notNull": true, 3347 + "default": "xata_id()" 3348 + }, 3349 + "xata_version": { 3350 + "name": "xata_version", 3351 + "type": "integer", 3352 + "primaryKey": false, 3353 + "notNull": false 3354 + }, 3355 + "email": { 3356 + "name": "email", 3357 + "type": "text", 3358 + "primaryKey": false, 3359 + "notNull": true 3360 + }, 3361 + "user_id": { 3362 + "name": "user_id", 3363 + "type": "text", 3364 + "primaryKey": false, 3365 + "notNull": true 3366 + }, 3367 + "is_beta_user": { 3368 + "name": "is_beta_user", 3369 + "type": "boolean", 3370 + "primaryKey": false, 3371 + "notNull": true, 3372 + "default": false 3373 + }, 3374 + "spotify_app_id": { 3375 + "name": "spotify_app_id", 3376 + "type": "text", 3377 + "primaryKey": false, 3378 + "notNull": false 3379 + }, 3380 + "xata_createdat": { 3381 + "name": "xata_createdat", 3382 + "type": "timestamp", 3383 + "primaryKey": false, 3384 + "notNull": true, 3385 + "default": "now()" 3386 + }, 3387 + "xata_updatedat": { 3388 + "name": "xata_updatedat", 3389 + "type": "timestamp", 3390 + "primaryKey": false, 3391 + "notNull": true, 3392 + "default": "now()" 3393 + } 3394 + }, 3395 + "indexes": {}, 3396 + "foreignKeys": { 3397 + "spotify_accounts_user_id_users_xata_id_fk": { 3398 + "name": "spotify_accounts_user_id_users_xata_id_fk", 3399 + "tableFrom": "spotify_accounts", 3400 + "tableTo": "users", 3401 + "columnsFrom": [ 3402 + "user_id" 3403 + ], 3404 + "columnsTo": [ 3405 + "xata_id" 3406 + ], 3407 + "onDelete": "no action", 3408 + "onUpdate": "no action" 3409 + } 3410 + }, 3411 + "compositePrimaryKeys": {}, 3412 + "uniqueConstraints": {}, 3413 + "policies": {}, 3414 + "checkConstraints": {}, 3415 + "isRLSEnabled": false 3416 + }, 3417 + "public.spotify_apps": { 3418 + "name": "spotify_apps", 3419 + "schema": "", 3420 + "columns": { 3421 + "xata_id": { 3422 + "name": "xata_id", 3423 + "type": "text", 3424 + "primaryKey": true, 3425 + "notNull": true, 3426 + "default": "xata_id()" 3427 + }, 3428 + "xata_version": { 3429 + "name": "xata_version", 3430 + "type": "integer", 3431 + "primaryKey": false, 3432 + "notNull": false 3433 + }, 3434 + "spotify_app_id": { 3435 + "name": "spotify_app_id", 3436 + "type": "text", 3437 + "primaryKey": false, 3438 + "notNull": true 3439 + }, 3440 + "spotify_secret": { 3441 + "name": "spotify_secret", 3442 + "type": "text", 3443 + "primaryKey": false, 3444 + "notNull": true 3445 + }, 3446 + "xata_createdat": { 3447 + "name": "xata_createdat", 3448 + "type": "timestamp", 3449 + "primaryKey": false, 3450 + "notNull": true, 3451 + "default": "now()" 3452 + }, 3453 + "xata_updatedat": { 3454 + "name": "xata_updatedat", 3455 + "type": "timestamp", 3456 + "primaryKey": false, 3457 + "notNull": true, 3458 + "default": "now()" 3459 + } 3460 + }, 3461 + "indexes": {}, 3462 + "foreignKeys": {}, 3463 + "compositePrimaryKeys": {}, 3464 + "uniqueConstraints": { 3465 + "spotify_apps_spotify_app_id_unique": { 3466 + "name": "spotify_apps_spotify_app_id_unique", 3467 + "nullsNotDistinct": false, 3468 + "columns": [ 3469 + "spotify_app_id" 3470 + ] 3471 + } 3472 + }, 3473 + "policies": {}, 3474 + "checkConstraints": {}, 3475 + "isRLSEnabled": false 3476 + }, 3477 + "public.spotify_tokens": { 3478 + "name": "spotify_tokens", 3479 + "schema": "", 3480 + "columns": { 3481 + "xata_id": { 3482 + "name": "xata_id", 3483 + "type": "text", 3484 + "primaryKey": true, 3485 + "notNull": true, 3486 + "default": "xata_id()" 3487 + }, 3488 + "xata_version": { 3489 + "name": "xata_version", 3490 + "type": "integer", 3491 + "primaryKey": false, 3492 + "notNull": false 3493 + }, 3494 + "access_token": { 3495 + "name": "access_token", 3496 + "type": "text", 3497 + "primaryKey": false, 3498 + "notNull": true 3499 + }, 3500 + "refresh_token": { 3501 + "name": "refresh_token", 3502 + "type": "text", 3503 + "primaryKey": false, 3504 + "notNull": true 3505 + }, 3506 + "user_id": { 3507 + "name": "user_id", 3508 + "type": "text", 3509 + "primaryKey": false, 3510 + "notNull": true 3511 + }, 3512 + "spotify_app_id": { 3513 + "name": "spotify_app_id", 3514 + "type": "text", 3515 + "primaryKey": false, 3516 + "notNull": true 3517 + }, 3518 + "xata_createdat": { 3519 + "name": "xata_createdat", 3520 + "type": "timestamp", 3521 + "primaryKey": false, 3522 + "notNull": true, 3523 + "default": "now()" 3524 + }, 3525 + "xata_updatedat": { 3526 + "name": "xata_updatedat", 3527 + "type": "timestamp", 3528 + "primaryKey": false, 3529 + "notNull": true, 3530 + "default": "now()" 3531 + } 3532 + }, 3533 + "indexes": {}, 3534 + "foreignKeys": { 3535 + "spotify_tokens_user_id_users_xata_id_fk": { 3536 + "name": "spotify_tokens_user_id_users_xata_id_fk", 3537 + "tableFrom": "spotify_tokens", 3538 + "tableTo": "users", 3539 + "columnsFrom": [ 3540 + "user_id" 3541 + ], 3542 + "columnsTo": [ 3543 + "xata_id" 3544 + ], 3545 + "onDelete": "no action", 3546 + "onUpdate": "no action" 3547 + } 3548 + }, 3549 + "compositePrimaryKeys": {}, 3550 + "uniqueConstraints": {}, 3551 + "policies": {}, 3552 + "checkConstraints": {}, 3553 + "isRLSEnabled": false 3554 + }, 3555 + "public.tracks": { 3556 + "name": "tracks", 3557 + "schema": "", 3558 + "columns": { 3559 + "xata_id": { 3560 + "name": "xata_id", 3561 + "type": "text", 3562 + "primaryKey": true, 3563 + "notNull": true, 3564 + "default": "xata_id()" 3565 + }, 3566 + "title": { 3567 + "name": "title", 3568 + "type": "text", 3569 + "primaryKey": false, 3570 + "notNull": true 3571 + }, 3572 + "artist": { 3573 + "name": "artist", 3574 + "type": "text", 3575 + "primaryKey": false, 3576 + "notNull": true 3577 + }, 3578 + "album_artist": { 3579 + "name": "album_artist", 3580 + "type": "text", 3581 + "primaryKey": false, 3582 + "notNull": true 3583 + }, 3584 + "album_art": { 3585 + "name": "album_art", 3586 + "type": "text", 3587 + "primaryKey": false, 3588 + "notNull": false 3589 + }, 3590 + "album": { 3591 + "name": "album", 3592 + "type": "text", 3593 + "primaryKey": false, 3594 + "notNull": true 3595 + }, 3596 + "track_number": { 3597 + "name": "track_number", 3598 + "type": "integer", 3599 + "primaryKey": false, 3600 + "notNull": false 3601 + }, 3602 + "duration": { 3603 + "name": "duration", 3604 + "type": "integer", 3605 + "primaryKey": false, 3606 + "notNull": true 3607 + }, 3608 + "mb_id": { 3609 + "name": "mb_id", 3610 + "type": "text", 3611 + "primaryKey": false, 3612 + "notNull": false 3613 + }, 3614 + "isrc": { 3615 + "name": "isrc", 3616 + "type": "text", 3617 + "primaryKey": false, 3618 + "notNull": false 3619 + }, 3620 + "youtube_link": { 3621 + "name": "youtube_link", 3622 + "type": "text", 3623 + "primaryKey": false, 3624 + "notNull": false 3625 + }, 3626 + "spotify_link": { 3627 + "name": "spotify_link", 3628 + "type": "text", 3629 + "primaryKey": false, 3630 + "notNull": false 3631 + }, 3632 + "apple_music_link": { 3633 + "name": "apple_music_link", 3634 + "type": "text", 3635 + "primaryKey": false, 3636 + "notNull": false 3637 + }, 3638 + "tidal_link": { 3639 + "name": "tidal_link", 3640 + "type": "text", 3641 + "primaryKey": false, 3642 + "notNull": false 3643 + }, 3644 + "sha256": { 3645 + "name": "sha256", 3646 + "type": "text", 3647 + "primaryKey": false, 3648 + "notNull": true 3649 + }, 3650 + "disc_number": { 3651 + "name": "disc_number", 3652 + "type": "integer", 3653 + "primaryKey": false, 3654 + "notNull": false 3655 + }, 3656 + "lyrics": { 3657 + "name": "lyrics", 3658 + "type": "text", 3659 + "primaryKey": false, 3660 + "notNull": false 3661 + }, 3662 + "composer": { 3663 + "name": "composer", 3664 + "type": "text", 3665 + "primaryKey": false, 3666 + "notNull": false 3667 + }, 3668 + "genre": { 3669 + "name": "genre", 3670 + "type": "text", 3671 + "primaryKey": false, 3672 + "notNull": false 3673 + }, 3674 + "label": { 3675 + "name": "label", 3676 + "type": "text", 3677 + "primaryKey": false, 3678 + "notNull": false 3679 + }, 3680 + "copyright_message": { 3681 + "name": "copyright_message", 3682 + "type": "text", 3683 + "primaryKey": false, 3684 + "notNull": false 3685 + }, 3686 + "uri": { 3687 + "name": "uri", 3688 + "type": "text", 3689 + "primaryKey": false, 3690 + "notNull": false 3691 + }, 3692 + "album_uri": { 3693 + "name": "album_uri", 3694 + "type": "text", 3695 + "primaryKey": false, 3696 + "notNull": false 3697 + }, 3698 + "artist_uri": { 3699 + "name": "artist_uri", 3700 + "type": "text", 3701 + "primaryKey": false, 3702 + "notNull": false 3703 + }, 3704 + "xata_createdat": { 3705 + "name": "xata_createdat", 3706 + "type": "timestamp", 3707 + "primaryKey": false, 3708 + "notNull": true, 3709 + "default": "now()" 3710 + }, 3711 + "xata_updatedat": { 3712 + "name": "xata_updatedat", 3713 + "type": "timestamp", 3714 + "primaryKey": false, 3715 + "notNull": true, 3716 + "default": "now()" 3717 + }, 3718 + "xata_version": { 3719 + "name": "xata_version", 3720 + "type": "integer", 3721 + "primaryKey": false, 3722 + "notNull": false 3723 + } 3724 + }, 3725 + "indexes": { 3726 + "tracks_genre_idx": { 3727 + "name": "tracks_genre_idx", 3728 + "columns": [ 3729 + { 3730 + "expression": "genre", 3731 + "isExpression": false, 3732 + "asc": true, 3733 + "nulls": "last" 3734 + } 3735 + ], 3736 + "isUnique": false, 3737 + "concurrently": false, 3738 + "method": "btree", 3739 + "with": {} 3740 + }, 3741 + "tracks_artist_uri_idx": { 3742 + "name": "tracks_artist_uri_idx", 3743 + "columns": [ 3744 + { 3745 + "expression": "artist_uri", 3746 + "isExpression": false, 3747 + "asc": true, 3748 + "nulls": "last" 3749 + } 3750 + ], 3751 + "isUnique": false, 3752 + "concurrently": false, 3753 + "method": "btree", 3754 + "with": {} 3755 + }, 3756 + "tracks_album_idx": { 3757 + "name": "tracks_album_idx", 3758 + "columns": [ 3759 + { 3760 + "expression": "album", 3761 + "isExpression": false, 3762 + "asc": true, 3763 + "nulls": "last" 3764 + } 3765 + ], 3766 + "isUnique": false, 3767 + "concurrently": false, 3768 + "method": "btree", 3769 + "with": {} 3770 + }, 3771 + "tracks_album_artist_idx": { 3772 + "name": "tracks_album_artist_idx", 3773 + "columns": [ 3774 + { 3775 + "expression": "album_artist", 3776 + "isExpression": false, 3777 + "asc": true, 3778 + "nulls": "last" 3779 + } 3780 + ], 3781 + "isUnique": false, 3782 + "concurrently": false, 3783 + "method": "btree", 3784 + "with": {} 3785 + }, 3786 + "tracks_isrc_idx": { 3787 + "name": "tracks_isrc_idx", 3788 + "columns": [ 3789 + { 3790 + "expression": "isrc", 3791 + "isExpression": false, 3792 + "asc": true, 3793 + "nulls": "last" 3794 + } 3795 + ], 3796 + "isUnique": false, 3797 + "concurrently": false, 3798 + "method": "btree", 3799 + "with": {} 3800 + } 3801 + }, 3802 + "foreignKeys": {}, 3803 + "compositePrimaryKeys": {}, 3804 + "uniqueConstraints": { 3805 + "tracks_youtube_link_unique": { 3806 + "name": "tracks_youtube_link_unique", 3807 + "nullsNotDistinct": false, 3808 + "columns": [ 3809 + "youtube_link" 3810 + ] 3811 + }, 3812 + "tracks_spotify_link_unique": { 3813 + "name": "tracks_spotify_link_unique", 3814 + "nullsNotDistinct": false, 3815 + "columns": [ 3816 + "spotify_link" 3817 + ] 3818 + }, 3819 + "tracks_apple_music_link_unique": { 3820 + "name": "tracks_apple_music_link_unique", 3821 + "nullsNotDistinct": false, 3822 + "columns": [ 3823 + "apple_music_link" 3824 + ] 3825 + }, 3826 + "tracks_tidal_link_unique": { 3827 + "name": "tracks_tidal_link_unique", 3828 + "nullsNotDistinct": false, 3829 + "columns": [ 3830 + "tidal_link" 3831 + ] 3832 + }, 3833 + "tracks_sha256_unique": { 3834 + "name": "tracks_sha256_unique", 3835 + "nullsNotDistinct": false, 3836 + "columns": [ 3837 + "sha256" 3838 + ] 3839 + }, 3840 + "tracks_uri_unique": { 3841 + "name": "tracks_uri_unique", 3842 + "nullsNotDistinct": false, 3843 + "columns": [ 3844 + "uri" 3845 + ] 3846 + } 3847 + }, 3848 + "policies": {}, 3849 + "checkConstraints": {}, 3850 + "isRLSEnabled": false 3851 + }, 3852 + "public.upload_queue_state": { 3853 + "name": "upload_queue_state", 3854 + "schema": "", 3855 + "columns": { 3856 + "xata_id": { 3857 + "name": "xata_id", 3858 + "type": "text", 3859 + "primaryKey": true, 3860 + "notNull": true, 3861 + "default": "xata_id()" 3862 + }, 3863 + "user_id": { 3864 + "name": "user_id", 3865 + "type": "text", 3866 + "primaryKey": false, 3867 + "notNull": true 3868 + }, 3869 + "upload_ids": { 3870 + "name": "upload_ids", 3871 + "type": "text", 3872 + "primaryKey": false, 3873 + "notNull": true, 3874 + "default": "'[]'" 3875 + }, 3876 + "current_index": { 3877 + "name": "current_index", 3878 + "type": "integer", 3879 + "primaryKey": false, 3880 + "notNull": true, 3881 + "default": 0 3882 + }, 3883 + "xata_createdat": { 3884 + "name": "xata_createdat", 3885 + "type": "timestamp", 3886 + "primaryKey": false, 3887 + "notNull": true, 3888 + "default": "now()" 3889 + }, 3890 + "xata_updatedat": { 3891 + "name": "xata_updatedat", 3892 + "type": "timestamp", 3893 + "primaryKey": false, 3894 + "notNull": true, 3895 + "default": "now()" 3896 + }, 3897 + "xata_version": { 3898 + "name": "xata_version", 3899 + "type": "integer", 3900 + "primaryKey": false, 3901 + "notNull": false 3902 + } 3903 + }, 3904 + "indexes": {}, 3905 + "foreignKeys": { 3906 + "upload_queue_state_user_id_users_xata_id_fk": { 3907 + "name": "upload_queue_state_user_id_users_xata_id_fk", 3908 + "tableFrom": "upload_queue_state", 3909 + "tableTo": "users", 3910 + "columnsFrom": [ 3911 + "user_id" 3912 + ], 3913 + "columnsTo": [ 3914 + "xata_id" 3915 + ], 3916 + "onDelete": "no action", 3917 + "onUpdate": "no action" 3918 + } 3919 + }, 3920 + "compositePrimaryKeys": {}, 3921 + "uniqueConstraints": { 3922 + "upload_queue_state_user_id_unique": { 3923 + "name": "upload_queue_state_user_id_unique", 3924 + "nullsNotDistinct": false, 3925 + "columns": [ 3926 + "user_id" 3927 + ] 3928 + } 3929 + }, 3930 + "policies": {}, 3931 + "checkConstraints": {}, 3932 + "isRLSEnabled": false 3933 + }, 3934 + "public.user_albums": { 3935 + "name": "user_albums", 3936 + "schema": "", 3937 + "columns": { 3938 + "xata_id": { 3939 + "name": "xata_id", 3940 + "type": "text", 3941 + "primaryKey": true, 3942 + "notNull": true, 3943 + "default": "xata_id()" 3944 + }, 3945 + "user_id": { 3946 + "name": "user_id", 3947 + "type": "text", 3948 + "primaryKey": false, 3949 + "notNull": true 3950 + }, 3951 + "album_id": { 3952 + "name": "album_id", 3953 + "type": "text", 3954 + "primaryKey": false, 3955 + "notNull": true 3956 + }, 3957 + "xata_createdat": { 3958 + "name": "xata_createdat", 3959 + "type": "timestamp", 3960 + "primaryKey": false, 3961 + "notNull": true, 3962 + "default": "now()" 3963 + }, 3964 + "xata_updatedat": { 3965 + "name": "xata_updatedat", 3966 + "type": "timestamp", 3967 + "primaryKey": false, 3968 + "notNull": true, 3969 + "default": "now()" 3970 + }, 3971 + "xata_version": { 3972 + "name": "xata_version", 3973 + "type": "integer", 3974 + "primaryKey": false, 3975 + "notNull": false 3976 + }, 3977 + "scrobbles": { 3978 + "name": "scrobbles", 3979 + "type": "integer", 3980 + "primaryKey": false, 3981 + "notNull": false 3982 + }, 3983 + "uri": { 3984 + "name": "uri", 3985 + "type": "text", 3986 + "primaryKey": false, 3987 + "notNull": true 3988 + } 3989 + }, 3990 + "indexes": {}, 3991 + "foreignKeys": { 3992 + "user_albums_user_id_users_xata_id_fk": { 3993 + "name": "user_albums_user_id_users_xata_id_fk", 3994 + "tableFrom": "user_albums", 3995 + "tableTo": "users", 3996 + "columnsFrom": [ 3997 + "user_id" 3998 + ], 3999 + "columnsTo": [ 4000 + "xata_id" 4001 + ], 4002 + "onDelete": "no action", 4003 + "onUpdate": "no action" 4004 + }, 4005 + "user_albums_album_id_albums_xata_id_fk": { 4006 + "name": "user_albums_album_id_albums_xata_id_fk", 4007 + "tableFrom": "user_albums", 4008 + "tableTo": "albums", 4009 + "columnsFrom": [ 4010 + "album_id" 4011 + ], 4012 + "columnsTo": [ 4013 + "xata_id" 4014 + ], 4015 + "onDelete": "no action", 4016 + "onUpdate": "no action" 4017 + } 4018 + }, 4019 + "compositePrimaryKeys": {}, 4020 + "uniqueConstraints": { 4021 + "user_albums_uri_unique": { 4022 + "name": "user_albums_uri_unique", 4023 + "nullsNotDistinct": false, 4024 + "columns": [ 4025 + "uri" 4026 + ] 4027 + } 4028 + }, 4029 + "policies": {}, 4030 + "checkConstraints": {}, 4031 + "isRLSEnabled": false 4032 + }, 4033 + "public.user_artists": { 4034 + "name": "user_artists", 4035 + "schema": "", 4036 + "columns": { 4037 + "xata_id": { 4038 + "name": "xata_id", 4039 + "type": "text", 4040 + "primaryKey": true, 4041 + "notNull": true, 4042 + "default": "xata_id()" 4043 + }, 4044 + "user_id": { 4045 + "name": "user_id", 4046 + "type": "text", 4047 + "primaryKey": false, 4048 + "notNull": true 4049 + }, 4050 + "artist_id": { 4051 + "name": "artist_id", 4052 + "type": "text", 4053 + "primaryKey": false, 4054 + "notNull": true 4055 + }, 4056 + "xata_createdat": { 4057 + "name": "xata_createdat", 4058 + "type": "timestamp", 4059 + "primaryKey": false, 4060 + "notNull": true, 4061 + "default": "now()" 4062 + }, 4063 + "xata_updatedat": { 4064 + "name": "xata_updatedat", 4065 + "type": "timestamp", 4066 + "primaryKey": false, 4067 + "notNull": true, 4068 + "default": "now()" 4069 + }, 4070 + "xata_version": { 4071 + "name": "xata_version", 4072 + "type": "integer", 4073 + "primaryKey": false, 4074 + "notNull": false 4075 + }, 4076 + "scrobbles": { 4077 + "name": "scrobbles", 4078 + "type": "integer", 4079 + "primaryKey": false, 4080 + "notNull": false 4081 + }, 4082 + "uri": { 4083 + "name": "uri", 4084 + "type": "text", 4085 + "primaryKey": false, 4086 + "notNull": true 4087 + } 4088 + }, 4089 + "indexes": { 4090 + "user_artists_user_id_idx": { 4091 + "name": "user_artists_user_id_idx", 4092 + "columns": [ 4093 + { 4094 + "expression": "user_id", 4095 + "isExpression": false, 4096 + "asc": true, 4097 + "nulls": "last" 4098 + } 4099 + ], 4100 + "isUnique": false, 4101 + "concurrently": false, 4102 + "method": "btree", 4103 + "with": {} 4104 + }, 4105 + "user_artists_artist_id_idx": { 4106 + "name": "user_artists_artist_id_idx", 4107 + "columns": [ 4108 + { 4109 + "expression": "artist_id", 4110 + "isExpression": false, 4111 + "asc": true, 4112 + "nulls": "last" 4113 + } 4114 + ], 4115 + "isUnique": false, 4116 + "concurrently": false, 4117 + "method": "btree", 4118 + "with": {} 4119 + } 4120 + }, 4121 + "foreignKeys": { 4122 + "user_artists_user_id_users_xata_id_fk": { 4123 + "name": "user_artists_user_id_users_xata_id_fk", 4124 + "tableFrom": "user_artists", 4125 + "tableTo": "users", 4126 + "columnsFrom": [ 4127 + "user_id" 4128 + ], 4129 + "columnsTo": [ 4130 + "xata_id" 4131 + ], 4132 + "onDelete": "no action", 4133 + "onUpdate": "no action" 4134 + }, 4135 + "user_artists_artist_id_artists_xata_id_fk": { 4136 + "name": "user_artists_artist_id_artists_xata_id_fk", 4137 + "tableFrom": "user_artists", 4138 + "tableTo": "artists", 4139 + "columnsFrom": [ 4140 + "artist_id" 4141 + ], 4142 + "columnsTo": [ 4143 + "xata_id" 4144 + ], 4145 + "onDelete": "no action", 4146 + "onUpdate": "no action" 4147 + } 4148 + }, 4149 + "compositePrimaryKeys": {}, 4150 + "uniqueConstraints": { 4151 + "user_artists_uri_unique": { 4152 + "name": "user_artists_uri_unique", 4153 + "nullsNotDistinct": false, 4154 + "columns": [ 4155 + "uri" 4156 + ] 4157 + } 4158 + }, 4159 + "policies": {}, 4160 + "checkConstraints": {}, 4161 + "isRLSEnabled": false 4162 + }, 4163 + "public.user_playlists": { 4164 + "name": "user_playlists", 4165 + "schema": "", 4166 + "columns": { 4167 + "xata_id": { 4168 + "name": "xata_id", 4169 + "type": "text", 4170 + "primaryKey": true, 4171 + "notNull": true, 4172 + "default": "xata_id()" 4173 + }, 4174 + "user_id": { 4175 + "name": "user_id", 4176 + "type": "text", 4177 + "primaryKey": false, 4178 + "notNull": true 4179 + }, 4180 + "playlist_id": { 4181 + "name": "playlist_id", 4182 + "type": "text", 4183 + "primaryKey": false, 4184 + "notNull": true 4185 + }, 4186 + "xata_createdat": { 4187 + "name": "xata_createdat", 4188 + "type": "timestamp", 4189 + "primaryKey": false, 4190 + "notNull": true, 4191 + "default": "now()" 4192 + }, 4193 + "uri": { 4194 + "name": "uri", 4195 + "type": "text", 4196 + "primaryKey": false, 4197 + "notNull": false 4198 + } 4199 + }, 4200 + "indexes": {}, 4201 + "foreignKeys": { 4202 + "user_playlists_user_id_users_xata_id_fk": { 4203 + "name": "user_playlists_user_id_users_xata_id_fk", 4204 + "tableFrom": "user_playlists", 4205 + "tableTo": "users", 4206 + "columnsFrom": [ 4207 + "user_id" 4208 + ], 4209 + "columnsTo": [ 4210 + "xata_id" 4211 + ], 4212 + "onDelete": "no action", 4213 + "onUpdate": "no action" 4214 + }, 4215 + "user_playlists_playlist_id_playlists_xata_id_fk": { 4216 + "name": "user_playlists_playlist_id_playlists_xata_id_fk", 4217 + "tableFrom": "user_playlists", 4218 + "tableTo": "playlists", 4219 + "columnsFrom": [ 4220 + "playlist_id" 4221 + ], 4222 + "columnsTo": [ 4223 + "xata_id" 4224 + ], 4225 + "onDelete": "no action", 4226 + "onUpdate": "no action" 4227 + } 4228 + }, 4229 + "compositePrimaryKeys": {}, 4230 + "uniqueConstraints": { 4231 + "user_playlists_uri_unique": { 4232 + "name": "user_playlists_uri_unique", 4233 + "nullsNotDistinct": false, 4234 + "columns": [ 4235 + "uri" 4236 + ] 4237 + } 4238 + }, 4239 + "policies": {}, 4240 + "checkConstraints": {}, 4241 + "isRLSEnabled": false 4242 + }, 4243 + "public.user_storage_providers": { 4244 + "name": "user_storage_providers", 4245 + "schema": "", 4246 + "columns": { 4247 + "xata_id": { 4248 + "name": "xata_id", 4249 + "type": "text", 4250 + "primaryKey": true, 4251 + "notNull": true, 4252 + "default": "xata_id()" 4253 + }, 4254 + "user_id": { 4255 + "name": "user_id", 4256 + "type": "text", 4257 + "primaryKey": false, 4258 + "notNull": true 4259 + }, 4260 + "label": { 4261 + "name": "label", 4262 + "type": "text", 4263 + "primaryKey": false, 4264 + "notNull": true 4265 + }, 4266 + "endpoint": { 4267 + "name": "endpoint", 4268 + "type": "text", 4269 + "primaryKey": false, 4270 + "notNull": true 4271 + }, 4272 + "region": { 4273 + "name": "region", 4274 + "type": "text", 4275 + "primaryKey": false, 4276 + "notNull": true, 4277 + "default": "'auto'" 4278 + }, 4279 + "bucket": { 4280 + "name": "bucket", 4281 + "type": "text", 4282 + "primaryKey": false, 4283 + "notNull": true 4284 + }, 4285 + "access_key": { 4286 + "name": "access_key", 4287 + "type": "text", 4288 + "primaryKey": false, 4289 + "notNull": true 4290 + }, 4291 + "secret_key": { 4292 + "name": "secret_key", 4293 + "type": "text", 4294 + "primaryKey": false, 4295 + "notNull": true 4296 + }, 4297 + "public_url": { 4298 + "name": "public_url", 4299 + "type": "text", 4300 + "primaryKey": false, 4301 + "notNull": false 4302 + }, 4303 + "verified_at": { 4304 + "name": "verified_at", 4305 + "type": "timestamp", 4306 + "primaryKey": false, 4307 + "notNull": false 4308 + }, 4309 + "xata_createdat": { 4310 + "name": "xata_createdat", 4311 + "type": "timestamp", 4312 + "primaryKey": false, 4313 + "notNull": true, 4314 + "default": "now()" 4315 + }, 4316 + "xata_updatedat": { 4317 + "name": "xata_updatedat", 4318 + "type": "timestamp", 4319 + "primaryKey": false, 4320 + "notNull": true, 4321 + "default": "now()" 4322 + }, 4323 + "xata_version": { 4324 + "name": "xata_version", 4325 + "type": "integer", 4326 + "primaryKey": false, 4327 + "notNull": false 4328 + } 4329 + }, 4330 + "indexes": { 4331 + "user_storage_providers_user_id_idx": { 4332 + "name": "user_storage_providers_user_id_idx", 4333 + "columns": [ 4334 + { 4335 + "expression": "user_id", 4336 + "isExpression": false, 4337 + "asc": true, 4338 + "nulls": "last" 4339 + } 4340 + ], 4341 + "isUnique": false, 4342 + "concurrently": false, 4343 + "method": "btree", 4344 + "with": {} 4345 + } 4346 + }, 4347 + "foreignKeys": { 4348 + "user_storage_providers_user_id_users_xata_id_fk": { 4349 + "name": "user_storage_providers_user_id_users_xata_id_fk", 4350 + "tableFrom": "user_storage_providers", 4351 + "tableTo": "users", 4352 + "columnsFrom": [ 4353 + "user_id" 4354 + ], 4355 + "columnsTo": [ 4356 + "xata_id" 4357 + ], 4358 + "onDelete": "no action", 4359 + "onUpdate": "no action" 4360 + } 4361 + }, 4362 + "compositePrimaryKeys": {}, 4363 + "uniqueConstraints": {}, 4364 + "policies": {}, 4365 + "checkConstraints": {}, 4366 + "isRLSEnabled": false 4367 + }, 4368 + "public.user_tracks": { 4369 + "name": "user_tracks", 4370 + "schema": "", 4371 + "columns": { 4372 + "xata_id": { 4373 + "name": "xata_id", 4374 + "type": "text", 4375 + "primaryKey": true, 4376 + "notNull": true, 4377 + "default": "xata_id()" 4378 + }, 4379 + "user_id": { 4380 + "name": "user_id", 4381 + "type": "text", 4382 + "primaryKey": false, 4383 + "notNull": true 4384 + }, 4385 + "track_id": { 4386 + "name": "track_id", 4387 + "type": "text", 4388 + "primaryKey": false, 4389 + "notNull": true 4390 + }, 4391 + "xata_createdat": { 4392 + "name": "xata_createdat", 4393 + "type": "timestamp", 4394 + "primaryKey": false, 4395 + "notNull": true, 4396 + "default": "now()" 4397 + }, 4398 + "xata_updatedat": { 4399 + "name": "xata_updatedat", 4400 + "type": "timestamp", 4401 + "primaryKey": false, 4402 + "notNull": true, 4403 + "default": "now()" 4404 + }, 4405 + "xata_version": { 4406 + "name": "xata_version", 4407 + "type": "integer", 4408 + "primaryKey": false, 4409 + "notNull": false 4410 + }, 4411 + "uri": { 4412 + "name": "uri", 4413 + "type": "text", 4414 + "primaryKey": false, 4415 + "notNull": true 4416 + }, 4417 + "scrobbles": { 4418 + "name": "scrobbles", 4419 + "type": "integer", 4420 + "primaryKey": false, 4421 + "notNull": false 4422 + } 4423 + }, 4424 + "indexes": {}, 4425 + "foreignKeys": { 4426 + "user_tracks_user_id_users_xata_id_fk": { 4427 + "name": "user_tracks_user_id_users_xata_id_fk", 4428 + "tableFrom": "user_tracks", 4429 + "tableTo": "users", 4430 + "columnsFrom": [ 4431 + "user_id" 4432 + ], 4433 + "columnsTo": [ 4434 + "xata_id" 4435 + ], 4436 + "onDelete": "no action", 4437 + "onUpdate": "no action" 4438 + }, 4439 + "user_tracks_track_id_tracks_xata_id_fk": { 4440 + "name": "user_tracks_track_id_tracks_xata_id_fk", 4441 + "tableFrom": "user_tracks", 4442 + "tableTo": "tracks", 4443 + "columnsFrom": [ 4444 + "track_id" 4445 + ], 4446 + "columnsTo": [ 4447 + "xata_id" 4448 + ], 4449 + "onDelete": "no action", 4450 + "onUpdate": "no action" 4451 + } 4452 + }, 4453 + "compositePrimaryKeys": {}, 4454 + "uniqueConstraints": { 4455 + "user_tracks_uri_unique": { 4456 + "name": "user_tracks_uri_unique", 4457 + "nullsNotDistinct": false, 4458 + "columns": [ 4459 + "uri" 4460 + ] 4461 + } 4462 + }, 4463 + "policies": {}, 4464 + "checkConstraints": {}, 4465 + "isRLSEnabled": false 4466 + }, 4467 + "public.user_uploads": { 4468 + "name": "user_uploads", 4469 + "schema": "", 4470 + "columns": { 4471 + "xata_id": { 4472 + "name": "xata_id", 4473 + "type": "text", 4474 + "primaryKey": true, 4475 + "notNull": true, 4476 + "default": "xata_id()" 4477 + }, 4478 + "user_id": { 4479 + "name": "user_id", 4480 + "type": "text", 4481 + "primaryKey": false, 4482 + "notNull": true 4483 + }, 4484 + "track_id": { 4485 + "name": "track_id", 4486 + "type": "text", 4487 + "primaryKey": false, 4488 + "notNull": true 4489 + }, 4490 + "r2_key": { 4491 + "name": "r2_key", 4492 + "type": "text", 4493 + "primaryKey": false, 4494 + "notNull": true 4495 + }, 4496 + "mime_type": { 4497 + "name": "mime_type", 4498 + "type": "text", 4499 + "primaryKey": false, 4500 + "notNull": true 4501 + }, 4502 + "file_size": { 4503 + "name": "file_size", 4504 + "type": "integer", 4505 + "primaryKey": false, 4506 + "notNull": true 4507 + }, 4508 + "original_filename": { 4509 + "name": "original_filename", 4510 + "type": "text", 4511 + "primaryKey": false, 4512 + "notNull": true 4513 + }, 4514 + "storage_provider_id": { 4515 + "name": "storage_provider_id", 4516 + "type": "text", 4517 + "primaryKey": false, 4518 + "notNull": false 4519 + }, 4520 + "uploaded_at": { 4521 + "name": "uploaded_at", 4522 + "type": "timestamp", 4523 + "primaryKey": false, 4524 + "notNull": true, 4525 + "default": "now()" 4526 + }, 4527 + "xata_createdat": { 4528 + "name": "xata_createdat", 4529 + "type": "timestamp", 4530 + "primaryKey": false, 4531 + "notNull": true, 4532 + "default": "now()" 4533 + }, 4534 + "xata_updatedat": { 4535 + "name": "xata_updatedat", 4536 + "type": "timestamp", 4537 + "primaryKey": false, 4538 + "notNull": true, 4539 + "default": "now()" 4540 + }, 4541 + "xata_version": { 4542 + "name": "xata_version", 4543 + "type": "integer", 4544 + "primaryKey": false, 4545 + "notNull": false 4546 + } 4547 + }, 4548 + "indexes": { 4549 + "user_uploads_user_id_idx": { 4550 + "name": "user_uploads_user_id_idx", 4551 + "columns": [ 4552 + { 4553 + "expression": "user_id", 4554 + "isExpression": false, 4555 + "asc": true, 4556 + "nulls": "last" 4557 + } 4558 + ], 4559 + "isUnique": false, 4560 + "concurrently": false, 4561 + "method": "btree", 4562 + "with": {} 4563 + }, 4564 + "user_uploads_track_id_idx": { 4565 + "name": "user_uploads_track_id_idx", 4566 + "columns": [ 4567 + { 4568 + "expression": "track_id", 4569 + "isExpression": false, 4570 + "asc": true, 4571 + "nulls": "last" 4572 + } 4573 + ], 4574 + "isUnique": false, 4575 + "concurrently": false, 4576 + "method": "btree", 4577 + "with": {} 4578 + }, 4579 + "user_uploads_user_id_track_id_idx": { 4580 + "name": "user_uploads_user_id_track_id_idx", 4581 + "columns": [ 4582 + { 4583 + "expression": "user_id", 4584 + "isExpression": false, 4585 + "asc": true, 4586 + "nulls": "last" 4587 + }, 4588 + { 4589 + "expression": "track_id", 4590 + "isExpression": false, 4591 + "asc": true, 4592 + "nulls": "last" 4593 + } 4594 + ], 4595 + "isUnique": false, 4596 + "concurrently": false, 4597 + "method": "btree", 4598 + "with": {} 4599 + } 4600 + }, 4601 + "foreignKeys": { 4602 + "user_uploads_user_id_users_xata_id_fk": { 4603 + "name": "user_uploads_user_id_users_xata_id_fk", 4604 + "tableFrom": "user_uploads", 4605 + "tableTo": "users", 4606 + "columnsFrom": [ 4607 + "user_id" 4608 + ], 4609 + "columnsTo": [ 4610 + "xata_id" 4611 + ], 4612 + "onDelete": "no action", 4613 + "onUpdate": "no action" 4614 + }, 4615 + "user_uploads_track_id_tracks_xata_id_fk": { 4616 + "name": "user_uploads_track_id_tracks_xata_id_fk", 4617 + "tableFrom": "user_uploads", 4618 + "tableTo": "tracks", 4619 + "columnsFrom": [ 4620 + "track_id" 4621 + ], 4622 + "columnsTo": [ 4623 + "xata_id" 4624 + ], 4625 + "onDelete": "no action", 4626 + "onUpdate": "no action" 4627 + }, 4628 + "user_uploads_storage_provider_id_user_storage_providers_xata_id_fk": { 4629 + "name": "user_uploads_storage_provider_id_user_storage_providers_xata_id_fk", 4630 + "tableFrom": "user_uploads", 4631 + "tableTo": "user_storage_providers", 4632 + "columnsFrom": [ 4633 + "storage_provider_id" 4634 + ], 4635 + "columnsTo": [ 4636 + "xata_id" 4637 + ], 4638 + "onDelete": "no action", 4639 + "onUpdate": "no action" 4640 + } 4641 + }, 4642 + "compositePrimaryKeys": {}, 4643 + "uniqueConstraints": {}, 4644 + "policies": {}, 4645 + "checkConstraints": {}, 4646 + "isRLSEnabled": false 4647 + }, 4648 + "public.users": { 4649 + "name": "users", 4650 + "schema": "", 4651 + "columns": { 4652 + "xata_id": { 4653 + "name": "xata_id", 4654 + "type": "text", 4655 + "primaryKey": true, 4656 + "notNull": true, 4657 + "default": "xata_id()" 4658 + }, 4659 + "did": { 4660 + "name": "did", 4661 + "type": "text", 4662 + "primaryKey": false, 4663 + "notNull": true 4664 + }, 4665 + "display_name": { 4666 + "name": "display_name", 4667 + "type": "text", 4668 + "primaryKey": false, 4669 + "notNull": false 4670 + }, 4671 + "handle": { 4672 + "name": "handle", 4673 + "type": "text", 4674 + "primaryKey": false, 4675 + "notNull": true 4676 + }, 4677 + "avatar": { 4678 + "name": "avatar", 4679 + "type": "text", 4680 + "primaryKey": false, 4681 + "notNull": true 4682 + }, 4683 + "is_bot": { 4684 + "name": "is_bot", 4685 + "type": "boolean", 4686 + "primaryKey": false, 4687 + "notNull": true, 4688 + "default": false 4689 + }, 4690 + "bot_flagged_at": { 4691 + "name": "bot_flagged_at", 4692 + "type": "timestamp with time zone", 4693 + "primaryKey": false, 4694 + "notNull": false 4695 + }, 4696 + "bot_reason": { 4697 + "name": "bot_reason", 4698 + "type": "text", 4699 + "primaryKey": false, 4700 + "notNull": false 4701 + }, 4702 + "xata_createdat": { 4703 + "name": "xata_createdat", 4704 + "type": "timestamp", 4705 + "primaryKey": false, 4706 + "notNull": true, 4707 + "default": "now()" 4708 + }, 4709 + "xata_updatedat": { 4710 + "name": "xata_updatedat", 4711 + "type": "timestamp", 4712 + "primaryKey": false, 4713 + "notNull": true, 4714 + "default": "now()" 4715 + }, 4716 + "xata_version": { 4717 + "name": "xata_version", 4718 + "type": "integer", 4719 + "primaryKey": false, 4720 + "notNull": false 4721 + } 4722 + }, 4723 + "indexes": {}, 4724 + "foreignKeys": {}, 4725 + "compositePrimaryKeys": {}, 4726 + "uniqueConstraints": { 4727 + "users_did_unique": { 4728 + "name": "users_did_unique", 4729 + "nullsNotDistinct": false, 4730 + "columns": [ 4731 + "did" 4732 + ] 4733 + }, 4734 + "users_handle_unique": { 4735 + "name": "users_handle_unique", 4736 + "nullsNotDistinct": false, 4737 + "columns": [ 4738 + "handle" 4739 + ] 4740 + } 4741 + }, 4742 + "policies": {}, 4743 + "checkConstraints": {}, 4744 + "isRLSEnabled": false 4745 + }, 4746 + "public.webscrobblers": { 4747 + "name": "webscrobblers", 4748 + "schema": "", 4749 + "columns": { 4750 + "xata_id": { 4751 + "name": "xata_id", 4752 + "type": "text", 4753 + "primaryKey": true, 4754 + "notNull": true, 4755 + "default": "xata_id()" 4756 + }, 4757 + "name": { 4758 + "name": "name", 4759 + "type": "text", 4760 + "primaryKey": false, 4761 + "notNull": true 4762 + }, 4763 + "uuid": { 4764 + "name": "uuid", 4765 + "type": "text", 4766 + "primaryKey": false, 4767 + "notNull": true 4768 + }, 4769 + "description": { 4770 + "name": "description", 4771 + "type": "text", 4772 + "primaryKey": false, 4773 + "notNull": false 4774 + }, 4775 + "enabled": { 4776 + "name": "enabled", 4777 + "type": "boolean", 4778 + "primaryKey": false, 4779 + "notNull": true, 4780 + "default": true 4781 + }, 4782 + "user_id": { 4783 + "name": "user_id", 4784 + "type": "text", 4785 + "primaryKey": false, 4786 + "notNull": true 4787 + }, 4788 + "xata_createdat": { 4789 + "name": "xata_createdat", 4790 + "type": "timestamp", 4791 + "primaryKey": false, 4792 + "notNull": true, 4793 + "default": "now()" 4794 + }, 4795 + "xata_updatedat": { 4796 + "name": "xata_updatedat", 4797 + "type": "timestamp", 4798 + "primaryKey": false, 4799 + "notNull": true, 4800 + "default": "now()" 4801 + } 4802 + }, 4803 + "indexes": {}, 4804 + "foreignKeys": { 4805 + "webscrobblers_user_id_users_xata_id_fk": { 4806 + "name": "webscrobblers_user_id_users_xata_id_fk", 4807 + "tableFrom": "webscrobblers", 4808 + "tableTo": "users", 4809 + "columnsFrom": [ 4810 + "user_id" 4811 + ], 4812 + "columnsTo": [ 4813 + "xata_id" 4814 + ], 4815 + "onDelete": "no action", 4816 + "onUpdate": "no action" 4817 + } 4818 + }, 4819 + "compositePrimaryKeys": {}, 4820 + "uniqueConstraints": {}, 4821 + "policies": {}, 4822 + "checkConstraints": {}, 4823 + "isRLSEnabled": false 4824 + } 4825 + }, 4826 + "enums": {}, 4827 + "schemas": {}, 4828 + "sequences": {}, 4829 + "roles": {}, 4830 + "policies": {}, 4831 + "views": {}, 4832 + "_meta": { 4833 + "columns": {}, 4834 + "schemas": {}, 4835 + "tables": {} 4836 + } 4837 + }
+7
apps/api/drizzle/meta/_journal.json
··· 113 113 "when": 1780800100000, 114 114 "tag": "0017_shouts_facets", 115 115 "breakpoints": true 116 + }, 117 + { 118 + "idx": 18, 119 + "version": "7", 120 + "when": 1780800200000, 121 + "tag": "0018_notifications", 122 + "breakpoints": true 116 123 } 117 124 ] 118 125 }
+82
apps/api/lexicons/notification/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.notification.defs", 4 + "defs": { 5 + "notificationActor": { 6 + "type": "object", 7 + "description": "The user who triggered a notification.", 8 + "properties": { 9 + "id": { 10 + "type": "string", 11 + "description": "The unique identifier of the actor." 12 + }, 13 + "did": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "The decentralized identifier of the actor." 17 + }, 18 + "handle": { 19 + "type": "string", 20 + "format": "at-identifier", 21 + "description": "The handle of the actor." 22 + }, 23 + "displayName": { 24 + "type": "string", 25 + "description": "The display name of the actor." 26 + }, 27 + "avatar": { 28 + "type": "string", 29 + "format": "uri", 30 + "description": "The URL of the actor's avatar image." 31 + } 32 + } 33 + }, 34 + "notificationView": { 35 + "type": "object", 36 + "required": ["id", "type", "read", "createdAt"], 37 + "properties": { 38 + "id": { 39 + "type": "string", 40 + "description": "The unique identifier of the notification." 41 + }, 42 + "type": { 43 + "type": "string", 44 + "description": "The notification type: like_scrobble, follow, comment_scrobble, comment_profile, reply, or react_comment.", 45 + "knownValues": [ 46 + "like_scrobble", 47 + "follow", 48 + "comment_scrobble", 49 + "comment_profile", 50 + "reply", 51 + "react_comment" 52 + ] 53 + }, 54 + "read": { 55 + "type": "boolean", 56 + "description": "Whether the notification has been viewed." 57 + }, 58 + "createdAt": { 59 + "type": "string", 60 + "format": "datetime", 61 + "description": "When the notification was created." 62 + }, 63 + "subjectUri": { 64 + "type": "string", 65 + "description": "The at-uri of the subject the notification relates to." 66 + }, 67 + "shoutId": { 68 + "type": "string", 69 + "description": "The id of the related shout, if any." 70 + }, 71 + "shoutContent": { 72 + "type": "string", 73 + "description": "The content of the related shout, if any." 74 + }, 75 + "actor": { 76 + "type": "ref", 77 + "ref": "app.rocksky.notification.defs#notificationActor" 78 + } 79 + } 80 + } 81 + } 82 + }
+23
apps/api/lexicons/notification/getUnreadCount.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.notification.getUnreadCount", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the number of unread notifications for the authenticated user.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["count"], 13 + "properties": { 14 + "count": { 15 + "type": "integer", 16 + "description": "The number of unread notifications." 17 + } 18 + } 19 + } 20 + } 21 + } 22 + } 23 + }
+48
apps/api/lexicons/notification/listNotifications.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.notification.listNotifications", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List notifications for the authenticated user, most recent first.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "maximum": 100, 14 + "minimum": 1, 15 + "default": 30 16 + }, 17 + "cursor": { 18 + "type": "string" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["notifications", "unreadCount"], 27 + "properties": { 28 + "notifications": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "app.rocksky.notification.defs#notificationView" 33 + } 34 + }, 35 + "unreadCount": { 36 + "type": "integer", 37 + "description": "The number of unread notifications." 38 + }, 39 + "cursor": { 40 + "type": "string", 41 + "description": "A cursor value to pass to subsequent calls to get the next page of results." 42 + } 43 + } 44 + } 45 + } 46 + } 47 + } 48 + }
+38
apps/api/lexicons/notification/updateSeen.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.notification.updateSeen", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Mark notifications as viewed. When no ids are provided, marks all of the authenticated user's notifications as viewed.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "ids": { 14 + "type": "array", 15 + "description": "The ids of the notifications to mark as viewed. Omit to mark all.", 16 + "items": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["unreadCount"], 28 + "properties": { 29 + "unreadCount": { 30 + "type": "integer", 31 + "description": "The number of unread notifications remaining." 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+3
apps/api/src/index.ts
··· 34 34 import googledrive from "./googledrive/app"; 35 35 import importApp from "./import/app"; 36 36 import { requestCounter, requestDuration } from "./metrics"; 37 + import notificationsSse from "./notifications/sse"; 37 38 import storageApp from "./storage/app"; 38 39 import uploadsApp from "./uploads/app"; 39 40 import "./profiling"; ··· 98 99 app.route("/access-tokens", accessTokensApp); 99 100 100 101 app.route("/import", importApp); 102 + 103 + app.route("/notifications", notificationsSse); 101 104 102 105 app.route("/public/og", opengraph); 103 106
+46
apps/api/src/lexicon/index.ts
··· 61 61 import type * as AppRockskyGraphGetFollows from "./types/app/rocksky/graph/getFollows"; 62 62 import type * as AppRockskyGraphGetKnownFollowers from "./types/app/rocksky/graph/getKnownFollowers"; 63 63 import type * as AppRockskyGraphUnfollowAccount from "./types/app/rocksky/graph/unfollowAccount"; 64 + import type * as AppRockskyNotificationGetUnreadCount from "./types/app/rocksky/notification/getUnreadCount"; 65 + import type * as AppRockskyNotificationListNotifications from "./types/app/rocksky/notification/listNotifications"; 66 + import type * as AppRockskyNotificationUpdateSeen from "./types/app/rocksky/notification/updateSeen"; 64 67 import type * as AppRockskyLibraryCreatePlaylist from "./types/app/rocksky/library/createPlaylist"; 65 68 import type * as AppRockskyLibraryDeleteAlbum from "./types/app/rocksky/library/deleteAlbum"; 66 69 import type * as AppRockskyLibraryDeletePlaylist from "./types/app/rocksky/library/deletePlaylist"; ··· 302 305 graph: AppRockskyGraphNS; 303 306 library: AppRockskyLibraryNS; 304 307 like: AppRockskyLikeNS; 308 + notification: AppRockskyNotificationNS; 305 309 mirror: AppRockskyMirrorNS; 306 310 player: AppRockskyPlayerNS; 307 311 playlist: AppRockskyPlaylistNS; ··· 325 329 this.graph = new AppRockskyGraphNS(server); 326 330 this.library = new AppRockskyLibraryNS(server); 327 331 this.like = new AppRockskyLikeNS(server); 332 + this.notification = new AppRockskyNotificationNS(server); 328 333 this.mirror = new AppRockskyMirrorNS(server); 329 334 this.player = new AppRockskyPlayerNS(server); 330 335 this.playlist = new AppRockskyPlaylistNS(server); ··· 1433 1438 >, 1434 1439 ) { 1435 1440 const nsid = "app.rocksky.like.likeSong"; // @ts-ignore 1441 + return this._server.xrpc.method(nsid, cfg); 1442 + } 1443 + } 1444 + 1445 + export class AppRockskyNotificationNS { 1446 + _server: Server; 1447 + 1448 + constructor(server: Server) { 1449 + this._server = server; 1450 + } 1451 + 1452 + getUnreadCount<AV extends AuthVerifier>( 1453 + cfg: ConfigOf< 1454 + AV, 1455 + AppRockskyNotificationGetUnreadCount.Handler<ExtractAuth<AV>>, 1456 + AppRockskyNotificationGetUnreadCount.HandlerReqCtx<ExtractAuth<AV>> 1457 + >, 1458 + ) { 1459 + const nsid = "app.rocksky.notification.getUnreadCount"; // @ts-ignore 1460 + return this._server.xrpc.method(nsid, cfg); 1461 + } 1462 + 1463 + listNotifications<AV extends AuthVerifier>( 1464 + cfg: ConfigOf< 1465 + AV, 1466 + AppRockskyNotificationListNotifications.Handler<ExtractAuth<AV>>, 1467 + AppRockskyNotificationListNotifications.HandlerReqCtx<ExtractAuth<AV>> 1468 + >, 1469 + ) { 1470 + const nsid = "app.rocksky.notification.listNotifications"; // @ts-ignore 1471 + return this._server.xrpc.method(nsid, cfg); 1472 + } 1473 + 1474 + updateSeen<AV extends AuthVerifier>( 1475 + cfg: ConfigOf< 1476 + AV, 1477 + AppRockskyNotificationUpdateSeen.Handler<ExtractAuth<AV>>, 1478 + AppRockskyNotificationUpdateSeen.HandlerReqCtx<ExtractAuth<AV>> 1479 + >, 1480 + ) { 1481 + const nsid = "app.rocksky.notification.updateSeen"; // @ts-ignore 1436 1482 return this._server.xrpc.method(nsid, cfg); 1437 1483 } 1438 1484 }
+204
apps/api/src/lexicon/lexicons.ts
··· 8933 8933 }, 8934 8934 }, 8935 8935 }, 8936 + AppRockskyNotificationDefs: { 8937 + lexicon: 1, 8938 + id: "app.rocksky.notification.defs", 8939 + defs: { 8940 + notificationActor: { 8941 + type: "object", 8942 + description: "The user who triggered a notification.", 8943 + properties: { 8944 + id: { 8945 + type: "string", 8946 + description: "The unique identifier of the actor.", 8947 + }, 8948 + did: { 8949 + type: "string", 8950 + format: "did", 8951 + description: "The decentralized identifier of the actor.", 8952 + }, 8953 + handle: { 8954 + type: "string", 8955 + format: "at-identifier", 8956 + description: "The handle of the actor.", 8957 + }, 8958 + displayName: { 8959 + type: "string", 8960 + description: "The display name of the actor.", 8961 + }, 8962 + avatar: { 8963 + type: "string", 8964 + format: "uri", 8965 + description: "The URL of the actor's avatar image.", 8966 + }, 8967 + }, 8968 + }, 8969 + notificationView: { 8970 + type: "object", 8971 + required: ["id", "type", "read", "createdAt"], 8972 + properties: { 8973 + id: { 8974 + type: "string", 8975 + description: "The unique identifier of the notification.", 8976 + }, 8977 + type: { 8978 + type: "string", 8979 + description: 8980 + "The notification type: like_scrobble, follow, comment_scrobble, comment_profile, reply, or react_comment.", 8981 + knownValues: [ 8982 + "like_scrobble", 8983 + "follow", 8984 + "comment_scrobble", 8985 + "comment_profile", 8986 + "reply", 8987 + "react_comment", 8988 + ], 8989 + }, 8990 + read: { 8991 + type: "boolean", 8992 + description: "Whether the notification has been viewed.", 8993 + }, 8994 + createdAt: { 8995 + type: "string", 8996 + format: "datetime", 8997 + description: "When the notification was created.", 8998 + }, 8999 + subjectUri: { 9000 + type: "string", 9001 + description: 9002 + "The at-uri of the subject the notification relates to.", 9003 + }, 9004 + shoutId: { 9005 + type: "string", 9006 + description: "The id of the related shout, if any.", 9007 + }, 9008 + shoutContent: { 9009 + type: "string", 9010 + description: "The content of the related shout, if any.", 9011 + }, 9012 + actor: { 9013 + type: "ref", 9014 + ref: "lex:app.rocksky.notification.defs#notificationActor", 9015 + }, 9016 + }, 9017 + }, 9018 + }, 9019 + }, 9020 + AppRockskyNotificationGetUnreadCount: { 9021 + lexicon: 1, 9022 + id: "app.rocksky.notification.getUnreadCount", 9023 + defs: { 9024 + main: { 9025 + type: "query", 9026 + description: 9027 + "Get the number of unread notifications for the authenticated user.", 9028 + output: { 9029 + encoding: "application/json", 9030 + schema: { 9031 + type: "object", 9032 + required: ["count"], 9033 + properties: { 9034 + count: { 9035 + type: "integer", 9036 + description: "The number of unread notifications.", 9037 + }, 9038 + }, 9039 + }, 9040 + }, 9041 + }, 9042 + }, 9043 + }, 9044 + AppRockskyNotificationListNotifications: { 9045 + lexicon: 1, 9046 + id: "app.rocksky.notification.listNotifications", 9047 + defs: { 9048 + main: { 9049 + type: "query", 9050 + description: 9051 + "List notifications for the authenticated user, most recent first.", 9052 + parameters: { 9053 + type: "params", 9054 + properties: { 9055 + limit: { 9056 + type: "integer", 9057 + maximum: 100, 9058 + minimum: 1, 9059 + default: 30, 9060 + }, 9061 + cursor: { 9062 + type: "string", 9063 + }, 9064 + }, 9065 + }, 9066 + output: { 9067 + encoding: "application/json", 9068 + schema: { 9069 + type: "object", 9070 + required: ["notifications", "unreadCount"], 9071 + properties: { 9072 + notifications: { 9073 + type: "array", 9074 + items: { 9075 + type: "ref", 9076 + ref: "lex:app.rocksky.notification.defs#notificationView", 9077 + }, 9078 + }, 9079 + unreadCount: { 9080 + type: "integer", 9081 + description: "The number of unread notifications.", 9082 + }, 9083 + cursor: { 9084 + type: "string", 9085 + description: 9086 + "A cursor value to pass to subsequent calls to get the next page of results.", 9087 + }, 9088 + }, 9089 + }, 9090 + }, 9091 + }, 9092 + }, 9093 + }, 9094 + AppRockskyNotificationUpdateSeen: { 9095 + lexicon: 1, 9096 + id: "app.rocksky.notification.updateSeen", 9097 + defs: { 9098 + main: { 9099 + type: "procedure", 9100 + description: 9101 + "Mark notifications as viewed. When no ids are provided, marks all of the authenticated user's notifications as viewed.", 9102 + input: { 9103 + encoding: "application/json", 9104 + schema: { 9105 + type: "object", 9106 + properties: { 9107 + ids: { 9108 + type: "array", 9109 + description: 9110 + "The ids of the notifications to mark as viewed. Omit to mark all.", 9111 + items: { 9112 + type: "string", 9113 + }, 9114 + }, 9115 + }, 9116 + }, 9117 + }, 9118 + output: { 9119 + encoding: "application/json", 9120 + schema: { 9121 + type: "object", 9122 + required: ["unreadCount"], 9123 + properties: { 9124 + unreadCount: { 9125 + type: "integer", 9126 + description: "The number of unread notifications remaining.", 9127 + }, 9128 + }, 9129 + }, 9130 + }, 9131 + }, 9132 + }, 9133 + }, 8936 9134 } as const satisfies Record<string, LexiconDoc>; 8937 9135 8938 9136 export const schemas = Object.values(schemaDict); ··· 9131 9329 AppRockskyLibraryUpdatePlaylist: "app.rocksky.library.updatePlaylist", 9132 9330 AppRockskyLibraryDeleteAlbum: "app.rocksky.library.deleteAlbum", 9133 9331 AppRockskyLibraryDeleteSong: "app.rocksky.library.deleteSong", 9332 + AppRockskyNotificationDefs: "app.rocksky.notification.defs", 9333 + AppRockskyNotificationGetUnreadCount: 9334 + "app.rocksky.notification.getUnreadCount", 9335 + AppRockskyNotificationListNotifications: 9336 + "app.rocksky.notification.listNotifications", 9337 + AppRockskyNotificationUpdateSeen: "app.rocksky.notification.updateSeen", 9134 9338 };
+72
apps/api/src/lexicon/types/app/rocksky/notification/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { lexicons } from '../../../../lexicons' 6 + import { isObj, hasProp } from '../../../../util' 7 + import { CID } from 'multiformats/cid' 8 + 9 + /** The user who triggered a notification. */ 10 + export interface NotificationActor { 11 + /** The unique identifier of the actor. */ 12 + id?: string 13 + /** The decentralized identifier of the actor. */ 14 + did?: string 15 + /** The handle of the actor. */ 16 + handle?: string 17 + /** The display name of the actor. */ 18 + displayName?: string 19 + /** The URL of the actor's avatar image. */ 20 + avatar?: string 21 + [k: string]: unknown 22 + } 23 + 24 + export function isNotificationActor(v: unknown): v is NotificationActor { 25 + return ( 26 + isObj(v) && 27 + hasProp(v, '$type') && 28 + v.$type === 'app.rocksky.notification.defs#notificationActor' 29 + ) 30 + } 31 + 32 + export function validateNotificationActor(v: unknown): ValidationResult { 33 + return lexicons.validate('app.rocksky.notification.defs#notificationActor', v) 34 + } 35 + 36 + export interface NotificationView { 37 + /** The unique identifier of the notification. */ 38 + id: string 39 + /** The notification type: like_scrobble, follow, comment_scrobble, comment_profile, reply, or react_comment. */ 40 + type: 41 + | 'like_scrobble' 42 + | 'follow' 43 + | 'comment_scrobble' 44 + | 'comment_profile' 45 + | 'reply' 46 + | 'react_comment' 47 + | (string & {}) 48 + /** Whether the notification has been viewed. */ 49 + read: boolean 50 + /** When the notification was created. */ 51 + createdAt: string 52 + /** The at-uri of the subject the notification relates to. */ 53 + subjectUri?: string 54 + /** The id of the related shout, if any. */ 55 + shoutId?: string 56 + /** The content of the related shout, if any. */ 57 + shoutContent?: string 58 + actor?: NotificationActor 59 + [k: string]: unknown 60 + } 61 + 62 + export function isNotificationView(v: unknown): v is NotificationView { 63 + return ( 64 + isObj(v) && 65 + hasProp(v, '$type') && 66 + v.$type === 'app.rocksky.notification.defs#notificationView' 67 + ) 68 + } 69 + 70 + export function validateNotificationView(v: unknown): ValidationResult { 71 + return lexicons.validate('app.rocksky.notification.defs#notificationView', v) 72 + }
+45
apps/api/src/lexicon/types/app/rocksky/notification/getUnreadCount.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + 11 + export interface QueryParams {} 12 + 13 + export type InputSchema = undefined 14 + 15 + export interface OutputSchema { 16 + /** The number of unread notifications. */ 17 + count: number 18 + [k: string]: unknown 19 + } 20 + 21 + export type HandlerInput = undefined 22 + 23 + export interface HandlerSuccess { 24 + encoding: 'application/json' 25 + body: OutputSchema 26 + headers?: { [key: string]: string } 27 + } 28 + 29 + export interface HandlerError { 30 + status: number 31 + message?: string 32 + } 33 + 34 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 35 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 36 + auth: HA 37 + params: QueryParams 38 + input: HandlerInput 39 + req: express.Request 40 + res: express.Response 41 + resetRouteRateLimits: () => Promise<void> 42 + } 43 + export type Handler<HA extends HandlerAuth = never> = ( 44 + ctx: HandlerReqCtx<HA>, 45 + ) => Promise<HandlerOutput> | HandlerOutput
+52
apps/api/src/lexicon/types/app/rocksky/notification/listNotifications.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + import * as AppRockskyNotificationDefs from './defs' 11 + 12 + export interface QueryParams { 13 + limit: number 14 + cursor?: string 15 + } 16 + 17 + export type InputSchema = undefined 18 + 19 + export interface OutputSchema { 20 + notifications: AppRockskyNotificationDefs.NotificationView[] 21 + /** The number of unread notifications. */ 22 + unreadCount: number 23 + /** A cursor value to pass to subsequent calls to get the next page of results. */ 24 + cursor?: string 25 + [k: string]: unknown 26 + } 27 + 28 + export type HandlerInput = undefined 29 + 30 + export interface HandlerSuccess { 31 + encoding: 'application/json' 32 + body: OutputSchema 33 + headers?: { [key: string]: string } 34 + } 35 + 36 + export interface HandlerError { 37 + status: number 38 + message?: string 39 + } 40 + 41 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 42 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 43 + auth: HA 44 + params: QueryParams 45 + input: HandlerInput 46 + req: express.Request 47 + res: express.Response 48 + resetRouteRateLimits: () => Promise<void> 49 + } 50 + export type Handler<HA extends HandlerAuth = never> = ( 51 + ctx: HandlerReqCtx<HA>, 52 + ) => Promise<HandlerOutput> | HandlerOutput
+52
apps/api/src/lexicon/types/app/rocksky/notification/updateSeen.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + 11 + export interface QueryParams {} 12 + 13 + export interface InputSchema { 14 + /** The ids of the notifications to mark as viewed. Omit to mark all. */ 15 + ids?: string[] 16 + [k: string]: unknown 17 + } 18 + 19 + export interface OutputSchema { 20 + /** The number of unread notifications remaining. */ 21 + unreadCount: number 22 + [k: string]: unknown 23 + } 24 + 25 + export interface HandlerInput { 26 + encoding: 'application/json' 27 + body: InputSchema 28 + } 29 + 30 + export interface HandlerSuccess { 31 + encoding: 'application/json' 32 + body: OutputSchema 33 + headers?: { [key: string]: string } 34 + } 35 + 36 + export interface HandlerError { 37 + status: number 38 + message?: string 39 + } 40 + 41 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 42 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 43 + auth: HA 44 + params: QueryParams 45 + input: HandlerInput 46 + req: express.Request 47 + res: express.Response 48 + resetRouteRateLimits: () => Promise<void> 49 + } 50 + export type Handler<HA extends HandlerAuth = never> = ( 51 + ctx: HandlerReqCtx<HA>, 52 + ) => Promise<HandlerOutput> | HandlerOutput
+21
apps/api/src/lovedtracks/lovedtracks.service.ts
··· 15 15 import artists from "../schema/artists"; 16 16 import lovedTracks from "../schema/loved-tracks"; 17 17 import tracks from "../schema/tracks"; 18 + import users from "../schema/users"; 18 19 import extractPdsFromDid from "lib/extractPdsFromDid"; 20 + import { createNotification } from "notifications/notifications.service"; 19 21 20 22 export async function likeTrack( 21 23 ctx: Context, ··· 325 327 xata_version: 0, 326 328 }); 327 329 ctx.nc.publish("rocksky.like", Buffer.from(message)); 330 + 331 + // Notify the owner of the liked song record (the repo the at-uri belongs to). 332 + if (trackWithUri?.uri) { 333 + const ownerDid = trackWithUri.uri.replace("at://", "").split("/")[0]; 334 + const owner = await ctx.db 335 + .select({ id: users.id }) 336 + .from(users) 337 + .where(eq(users.did, ownerDid)) 338 + .limit(1) 339 + .then((rows) => rows[0]); 340 + if (owner) { 341 + await createNotification(ctx, { 342 + userId: owner.id, 343 + actorId: user.id, 344 + type: "like_scrobble", 345 + subjectUri: trackWithUri.uri, 346 + }); 347 + } 348 + } 328 349 329 350 return created; 330 351 }
+232
apps/api/src/notifications/notifications.service.ts
··· 1 + import { consola } from "consola"; 2 + import type { Context } from "context"; 3 + import { and, desc, eq, inArray, lt, sql } from "drizzle-orm"; 4 + import notifications, { 5 + type SelectNotification, 6 + } from "../schema/notifications"; 7 + import shouts from "../schema/shouts"; 8 + import users from "../schema/users"; 9 + 10 + export type NotificationType = 11 + | "like_scrobble" 12 + | "follow" 13 + | "comment_scrobble" 14 + | "comment_profile" 15 + | "reply" 16 + | "react_comment"; 17 + 18 + export interface CreateNotificationParams { 19 + /** Recipient user id (users.xata_id). */ 20 + userId?: string | null; 21 + /** User id of whoever triggered the event (users.xata_id). */ 22 + actorId?: string | null; 23 + type: NotificationType; 24 + /** The shout involved, for comment/reply/react notifications. */ 25 + shoutId?: string | null; 26 + /** at-uri of the subject (scrobble/song/profile), for deep-linking. */ 27 + subjectUri?: string | null; 28 + } 29 + 30 + export interface NotificationActor { 31 + id: string; 32 + did: string; 33 + handle: string; 34 + displayName?: string; 35 + avatar: string; 36 + } 37 + 38 + export interface NotificationView { 39 + id: string; 40 + type: NotificationType; 41 + read: boolean; 42 + createdAt: string; 43 + subjectUri?: string; 44 + shoutId?: string; 45 + shoutContent?: string; 46 + actor?: NotificationActor; 47 + } 48 + 49 + /** 50 + * NATS subject a given user's realtime notification stream is published on. 51 + * Every SSE connection subscribes to exactly this subject, so a message is 52 + * only delivered to the API instances holding that user's live connections — 53 + * this is what makes the fan-out horizontally scalable without websockets. 54 + */ 55 + export const notificationSubject = (userId: string) => 56 + `rocksky.notification.${userId.replace(/[^A-Za-z0-9_-]/g, "_")}`; 57 + 58 + /** 59 + * Persist a notification and push it to the recipient's realtime stream. 60 + * No-ops when the actor is the recipient (you don't get notified about your 61 + * own actions) or when either party is missing. Never throws: notification 62 + * delivery must not break the underlying mutation (like/follow/shout). 63 + */ 64 + export async function createNotification( 65 + ctx: Context, 66 + params: CreateNotificationParams, 67 + ): Promise<SelectNotification | undefined> { 68 + try { 69 + const { userId, actorId, type, shoutId, subjectUri } = params; 70 + 71 + if (!userId || !actorId || userId === actorId) { 72 + return undefined; 73 + } 74 + 75 + const created = await ctx.db 76 + .insert(notifications) 77 + .values({ 78 + userId, 79 + actorId, 80 + type, 81 + shoutId: shoutId ?? null, 82 + subjectUri: subjectUri ?? null, 83 + }) 84 + .returning() 85 + .then((rows) => rows[0]); 86 + 87 + const unreadCount = await getUnreadCount(ctx, userId); 88 + const [view] = await hydrate(ctx, [created]); 89 + 90 + publish(ctx, userId, { 91 + type: "notification", 92 + notification: view, 93 + unreadCount, 94 + }); 95 + 96 + return created; 97 + } catch (err) { 98 + consola.error("Failed to create notification", err); 99 + return undefined; 100 + } 101 + } 102 + 103 + export async function getUnreadCount( 104 + ctx: Context, 105 + userId: string, 106 + ): Promise<number> { 107 + const [row] = await ctx.db 108 + .select({ count: sql<number>`count(*)::int` }) 109 + .from(notifications) 110 + .where( 111 + and(eq(notifications.userId, userId), eq(notifications.read, false)), 112 + ); 113 + return row?.count ?? 0; 114 + } 115 + 116 + export async function listNotifications( 117 + ctx: Context, 118 + userId: string, 119 + opts: { limit?: number; cursor?: string } = {}, 120 + ): Promise<{ notifications: NotificationView[]; cursor?: string }> { 121 + const limit = Math.min(Math.max(opts.limit ?? 30, 1), 100); 122 + 123 + const rows = await ctx.db 124 + .select() 125 + .from(notifications) 126 + .where( 127 + opts.cursor 128 + ? and( 129 + eq(notifications.userId, userId), 130 + lt(notifications.createdAt, new Date(opts.cursor)), 131 + ) 132 + : eq(notifications.userId, userId), 133 + ) 134 + .orderBy(desc(notifications.createdAt)) 135 + .limit(limit + 1); 136 + 137 + const hasMore = rows.length > limit; 138 + const page = hasMore ? rows.slice(0, limit) : rows; 139 + const views = await hydrate(ctx, page); 140 + 141 + return { 142 + notifications: views, 143 + cursor: hasMore ? page[page.length - 1].createdAt.toISOString() : undefined, 144 + }; 145 + } 146 + 147 + /** 148 + * Mark notifications as read/viewed. With no ids, marks the whole inbox. 149 + * Returns the recipient's new unread count and pushes it to their stream so 150 + * every open tab clears its badge. 151 + */ 152 + export async function markSeen( 153 + ctx: Context, 154 + userId: string, 155 + ids?: string[], 156 + ): Promise<number> { 157 + const base = and( 158 + eq(notifications.userId, userId), 159 + eq(notifications.read, false), 160 + ); 161 + 162 + await ctx.db 163 + .update(notifications) 164 + .set({ read: true, readAt: new Date() }) 165 + .where(ids?.length ? and(base, inArray(notifications.id, ids)) : base); 166 + 167 + const unreadCount = await getUnreadCount(ctx, userId); 168 + publish(ctx, userId, { type: "seen", unreadCount }); 169 + return unreadCount; 170 + } 171 + 172 + async function hydrate( 173 + ctx: Context, 174 + rows: SelectNotification[], 175 + ): Promise<NotificationView[]> { 176 + if (!rows.length) return []; 177 + 178 + const actorIds = [...new Set(rows.map((r) => r.actorId))]; 179 + const shoutIds = [ 180 + ...new Set(rows.map((r) => r.shoutId).filter((v): v is string => !!v)), 181 + ]; 182 + 183 + const actors = await ctx.db 184 + .select() 185 + .from(users) 186 + .where(inArray(users.id, actorIds)); 187 + const actorById = new Map(actors.map((a) => [a.id, a])); 188 + 189 + const shoutById = new Map<string, string>(); 190 + if (shoutIds.length) { 191 + const shoutRows = await ctx.db 192 + .select({ id: shouts.id, content: shouts.content }) 193 + .from(shouts) 194 + .where(inArray(shouts.id, shoutIds)); 195 + for (const s of shoutRows) shoutById.set(s.id, s.content); 196 + } 197 + 198 + return rows.map((r) => { 199 + const actor = actorById.get(r.actorId); 200 + return { 201 + id: r.id, 202 + type: r.type as NotificationType, 203 + read: r.read, 204 + createdAt: r.createdAt.toISOString(), 205 + subjectUri: r.subjectUri ?? undefined, 206 + shoutId: r.shoutId ?? undefined, 207 + shoutContent: r.shoutId 208 + ? (shoutById.get(r.shoutId) ?? undefined) 209 + : undefined, 210 + actor: actor 211 + ? { 212 + id: actor.id, 213 + did: actor.did, 214 + handle: actor.handle, 215 + displayName: actor.displayName ?? undefined, 216 + avatar: actor.avatar, 217 + } 218 + : undefined, 219 + }; 220 + }); 221 + } 222 + 223 + function publish(ctx: Context, userId: string, payload: unknown): void { 224 + try { 225 + ctx.nc.publish( 226 + notificationSubject(userId), 227 + Buffer.from(JSON.stringify(payload)), 228 + ); 229 + } catch (err) { 230 + consola.error("Failed to publish notification event", err); 231 + } 232 + }
+112
apps/api/src/notifications/sse.ts
··· 1 + import { consola } from "consola"; 2 + import { ctx } from "context"; 3 + import { eq } from "drizzle-orm"; 4 + import { Hono } from "hono"; 5 + import { streamSSE } from "hono/streaming"; 6 + import { verifyToken } from "lib/verifyToken"; 7 + import { StringCodec } from "nats"; 8 + import { 9 + getUnreadCount, 10 + notificationSubject, 11 + } from "notifications/notifications.service"; 12 + import users from "schema/users"; 13 + 14 + const app = new Hono(); 15 + const sc = StringCodec(); 16 + 17 + /** 18 + * Server-Sent Events stream of a user's live notifications. 19 + * 20 + * EventSource cannot set headers, so the JWT is passed as a `token` query 21 + * param. Each connection subscribes to the user's dedicated NATS subject, so 22 + * NATS routes a published event only to the API instances actually holding 23 + * that user's live connections — the fan-out scales horizontally without any 24 + * websocket or per-instance broadcast filtering. 25 + */ 26 + app.get("/stream", async (c) => { 27 + const token = c.req.query("token"); 28 + if (!token) { 29 + c.status(401); 30 + return c.text("Unauthorized"); 31 + } 32 + 33 + let did: string | undefined; 34 + try { 35 + did = (await verifyToken(token)).did; 36 + } catch { 37 + c.status(401); 38 + return c.text("Unauthorized"); 39 + } 40 + if (!did) { 41 + c.status(401); 42 + return c.text("Unauthorized"); 43 + } 44 + 45 + const user = await ctx.db 46 + .select({ id: users.id }) 47 + .from(users) 48 + .where(eq(users.did, did)) 49 + .limit(1) 50 + .then((rows) => rows[0]); 51 + if (!user) { 52 + c.status(401); 53 + return c.text("Unauthorized"); 54 + } 55 + 56 + return streamSSE(c, async (stream) => { 57 + const sub = ctx.nc.subscribe(notificationSubject(user.id)); 58 + let closed = false; 59 + const close = () => { 60 + if (closed) return; 61 + closed = true; 62 + try { 63 + sub.unsubscribe(); 64 + } catch { 65 + // already torn down 66 + } 67 + }; 68 + stream.onAbort(close); 69 + 70 + // Prime the client with the current unread count on connect. 71 + try { 72 + const unreadCount = await getUnreadCount(ctx, user.id); 73 + await stream.writeSSE({ 74 + event: "unread", 75 + data: JSON.stringify({ unreadCount }), 76 + }); 77 + } catch (err) { 78 + consola.error(err); 79 + } 80 + 81 + // Heartbeat so intermediary proxies don't drop an idle connection. 82 + const heartbeat = (async () => { 83 + while (!closed) { 84 + await stream.sleep(25_000); 85 + if (closed) break; 86 + try { 87 + await stream.writeSSE({ event: "ping", data: "" }); 88 + } catch { 89 + break; 90 + } 91 + } 92 + })(); 93 + 94 + try { 95 + for await (const msg of sub) { 96 + if (closed) break; 97 + await stream.writeSSE({ 98 + event: "notification", 99 + data: sc.decode(msg.data), 100 + }); 101 + } 102 + } catch (err) { 103 + consola.error(err); 104 + } finally { 105 + close(); 106 + } 107 + 108 + await heartbeat; 109 + }); 110 + }); 111 + 112 + export default app;
+2
apps/api/src/schema/index.ts
··· 21 21 import mirrorSources from "./mirror-sources"; 22 22 import navidromePlaylistTracks from "./navidrome-playlist-tracks"; 23 23 import navidromePlaylists from "./navidrome-playlists"; 24 + import notifications from "./notifications"; 24 25 import playlistTracks from "./playlist-tracks"; 25 26 import playlists from "./playlists"; 26 27 import profileShouts from "./profile-shouts"; ··· 45 46 46 47 export default { 47 48 importJobs, 49 + notifications, 48 50 profileShouts, 49 51 shoutLikes, 50 52 shouts,
+44
apps/api/src/schema/notifications.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + import shouts from "./shouts"; 4 + import users from "./users"; 5 + 6 + /** 7 + * A single notification row per event. `type` is one of: 8 + * - "like_scrobble" — someone liked the recipient's song/scrobble 9 + * - "follow" — someone followed the recipient 10 + * - "comment_scrobble" — someone commented on the recipient's scrobble 11 + * - "comment_profile" — someone commented on the recipient's profile 12 + * - "reply" — someone replied to the recipient's comment 13 + * - "react_comment" — someone reacted to (liked) the recipient's comment 14 + * 15 + * `userId` is the recipient; `actorId` is the user who triggered the event. 16 + * `shoutId` / `subjectUri` point at the relevant record so the UI can deep-link. 17 + */ 18 + const notifications = pgTable( 19 + "notifications", 20 + { 21 + id: text("xata_id").primaryKey().default(sql`xata_id()`), 22 + userId: text("user_id") 23 + .notNull() 24 + .references(() => users.id), 25 + actorId: text("actor_id") 26 + .notNull() 27 + .references(() => users.id), 28 + type: text("type").notNull(), 29 + shoutId: text("shout_id").references(() => shouts.id), 30 + subjectUri: text("subject_uri"), 31 + read: boolean("read").notNull().default(false), 32 + readAt: timestamp("read_at", { withTimezone: true }), 33 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 34 + }, 35 + (t) => [ 36 + index("notifications_user_id_read_idx").on(t.userId, t.read), 37 + index("notifications_user_id_createdat_idx").on(t.userId, t.createdAt), 38 + ], 39 + ); 40 + 41 + export type SelectNotification = InferSelectModel<typeof notifications>; 42 + export type InsertNotification = InferInsertModel<typeof notifications>; 43 + 44 + export default notifications;
+40
apps/api/src/shouts/shouts.service.ts
··· 7 7 import * as ShoutLexicon from "lexicon/types/app/rocksky/shout"; 8 8 import { validateMain } from "lexicon/types/com/atproto/repo/strongRef"; 9 9 import _ from "lodash"; 10 + import { createNotification } from "notifications/notifications.service"; 10 11 import type { Shout } from "types/shout"; 11 12 import albums, { type SelectAlbum } from "../schema/albums"; 12 13 import artists, { type SelectArtist } from "../schema/artists"; ··· 156 157 userId: profile.id, 157 158 }); 158 159 } 160 + 161 + // Notify the owner of the commented-on subject. Only scrobbles and 162 + // profiles have a single owner to notify (tracks/albums/artists are 163 + // shared entities), matching the notification spec. 164 + if (scrobble?.scrobble) { 165 + await createNotification(ctx, { 166 + userId: scrobble.scrobble.userId, 167 + actorId: user.id, 168 + type: "comment_scrobble", 169 + shoutId: createdShout.id, 170 + subjectUri: scrobble.scrobble.uri, 171 + }); 172 + } else if (profile) { 173 + await createNotification(ctx, { 174 + userId: profile.id, 175 + actorId: user.id, 176 + type: "comment_profile", 177 + shoutId: createdShout.id, 178 + subjectUri: `at://${profile.did}`, 179 + }); 180 + } 159 181 } catch (e) { 160 182 consola.error(`Error creating shout record: ${e.message}`); 161 183 } ··· 330 352 userId: profileShout.userId, 331 353 }); 332 354 } 355 + 356 + // Notify the author of the parent comment that someone replied. 357 + await createNotification(ctx, { 358 + userId: shout.shout.authorId, 359 + actorId: user.id, 360 + type: "reply", 361 + shoutId: createdShout.id, 362 + subjectUri: shout.shout.uri, 363 + }); 333 364 } catch (e) { 334 365 consola.error(`Error creating reply record: ${e.message}`); 335 366 } ··· 417 448 shoutId: shout.id, 418 449 userId: user.id, 419 450 uri, 451 + }); 452 + 453 + // Notify the shout's author that someone reacted to their comment. 454 + await createNotification(ctx, { 455 + userId: shout.authorId, 456 + actorId: user.id, 457 + type: "react_comment", 458 + shoutId: shout.id, 459 + subjectUri: shout.uri, 420 460 }); 421 461 } catch (e) { 422 462 consola.error(`Error creating like record: ${e.message}`);
+18 -1
apps/api/src/xrpc/app/rocksky/graph/followAccount.ts
··· 8 8 import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 9 9 import type { QueryParams } from "lexicon/types/app/rocksky/graph/followAccount"; 10 10 import { createAgent } from "lib/agent"; 11 + import { createNotification } from "notifications/notifications.service"; 11 12 import tables from "schema"; 12 13 import type { SelectUser } from "schema/users"; 13 14 import * as FollowLexicon from "lexicon/types/app/rocksky/graph/follow"; ··· 100 101 .onConflictDoNothing() 101 102 .execute(); 102 103 103 - return Promise.all([ 104 + const [subject, followers] = await Promise.all([ 104 105 ctx.db 105 106 .select() 106 107 .from(tables.users) ··· 120 121 .execute() 121 122 .then((rows) => rows.map(({ users }) => users)), 122 123 ]); 124 + 125 + // Notify the followed user. `did` is the follower; look up their row id. 126 + const follower = await ctx.db 127 + .select({ id: tables.users.id }) 128 + .from(tables.users) 129 + .where(eq(tables.users.did, did)) 130 + .limit(1) 131 + .then((rows) => rows[0]); 132 + await createNotification(ctx, { 133 + userId: subject?.id, 134 + actorId: follower?.id, 135 + type: "follow", 136 + subjectUri: `at://${did}`, 137 + }); 138 + 139 + return [subject, followers] as [SelectUser | undefined, SelectUser[]]; 123 140 }, 124 141 catch: (error) => new Error(`Failed to retrieve follow: ${error}`), 125 142 });
+46 -25
apps/api/src/xrpc/app/rocksky/like/dislikeShout.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 2 3 import type { Context } from "context"; 3 4 import { eq } from "drizzle-orm"; 4 5 import { Effect, pipe } from "effect"; 5 6 import type { Server } from "lexicon"; 6 - import type { QueryParams } from "lexicon/types/app/rocksky/like/dislikeShout"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/like/dislikeShout"; 8 + import { createAgent } from "lib/agent"; 7 9 import tables from "schema"; 10 + import { unlikeShout as unlikeShoutService } from "shouts/shouts.service"; 8 11 9 12 export default function (server: Server, ctx: Context) { 10 - const dislikeShout = (params, auth: HandlerAuth) => 11 - pipe(params, dislike, presentation); 13 + const dislikeShout = (input: InputSchema, auth: HandlerAuth) => 14 + pipe( 15 + dislike({ input, ctx, did: auth.credentials?.did }), 16 + Effect.flatMap(presentation), 17 + Effect.timeout("10 seconds"), 18 + Effect.catchAll((err) => { 19 + consola.error(err); 20 + return Effect.succeed({}); 21 + }), 22 + ); 12 23 server.app.rocksky.like.dislikeShout({ 13 24 auth: ctx.authVerifier, 14 - handler: async ({ params, auth }) => { 15 - const result = dislikeShout(params, auth); 25 + handler: async ({ input, auth }) => { 26 + const result = await Effect.runPromise(dislikeShout(input.body, auth)); 16 27 return { 17 28 encoding: "application/json", 18 29 body: result, ··· 21 32 }); 22 33 } 23 34 24 - const getCurrentUser = ({ 25 - params, 35 + const dislike = ({ 36 + input, 26 37 ctx, 27 38 did, 28 39 }: { 29 - params: QueryParams; 40 + input: InputSchema; 30 41 ctx: Context; 31 42 did?: string; 32 - }) => { 33 - return Effect.tryPromise({ 34 - try: async () => 35 - ctx.db 43 + }) => 44 + Effect.tryPromise({ 45 + try: async () => { 46 + if (!did) { 47 + throw new Error("User is not authenticated"); 48 + } 49 + if (!input.uri) { 50 + throw new Error("Missing shout uri"); 51 + } 52 + 53 + const user = await ctx.db 36 54 .select() 37 55 .from(tables.users) 38 56 .where(eq(tables.users.did, did)) 39 - .execute() 40 - .then((users) => ({ user: users[0], ctx, params })), 41 - catch: (error) => new Error(`Failed to retrieve current user: ${error}`), 57 + .limit(1) 58 + .then((rows) => rows[0]); 59 + if (!user) { 60 + throw new Error("User not found"); 61 + } 62 + 63 + const agent = await createAgent(ctx.oauthClient, did); 64 + if (!agent) { 65 + throw new Error("Unauthorized"); 66 + } 67 + 68 + await unlikeShoutService(ctx, input.uri, user, agent); 69 + return {}; 70 + }, 71 + catch: (error) => new Error(`Failed to dislike shout: ${error}`), 42 72 }); 43 - }; 44 73 45 - const dislike = () => { 46 - // Logic to dislike a shout 47 - return {}; 48 - }; 49 - 50 - const presentation = () => { 51 - // Logic to format the response for presentation 52 - return {}; 53 - }; 74 + const presentation = () => Effect.sync(() => ({}));
+56 -25
apps/api/src/xrpc/app/rocksky/like/dislikeSong.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 2 3 import type { Context } from "context"; 3 4 import { eq } from "drizzle-orm"; 4 5 import { Effect, pipe } from "effect"; 5 6 import type { Server } from "lexicon"; 6 - import type { QueryParams } from "lexicon/types/app/rocksky/like/dislikeSong"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/like/dislikeSong"; 8 + import { createAgent } from "lib/agent"; 9 + import { unLikeTrack } from "lovedtracks/lovedtracks.service"; 7 10 import tables from "schema"; 8 11 9 12 export default function (server: Server, ctx: Context) { 10 - const dislikeSong = (params, auth: HandlerAuth) => 11 - pipe(params, dislike, presentation); 13 + const dislikeSong = (input: InputSchema, auth: HandlerAuth) => 14 + pipe( 15 + dislike({ input, ctx, did: auth.credentials?.did }), 16 + Effect.flatMap(presentation), 17 + Effect.timeout("10 seconds"), 18 + Effect.catchAll((err) => { 19 + consola.error(err); 20 + return Effect.succeed({}); 21 + }), 22 + ); 12 23 server.app.rocksky.like.dislikeSong({ 13 24 auth: ctx.authVerifier, 14 - handler: async ({ params, auth }) => { 15 - const result = dislikeSong(params, auth); 25 + handler: async ({ input, auth }) => { 26 + const result = await Effect.runPromise(dislikeSong(input.body, auth)); 16 27 return { 17 28 encoding: "application/json", 18 29 body: result, ··· 21 32 }); 22 33 } 23 34 24 - const getCurrentUser = ({ 25 - params, 35 + const dislike = ({ 36 + input, 26 37 ctx, 27 38 did, 28 39 }: { 29 - params: QueryParams; 40 + input: InputSchema; 30 41 ctx: Context; 31 42 did?: string; 32 - }) => { 33 - return Effect.tryPromise({ 34 - try: async () => 35 - ctx.db 43 + }) => 44 + Effect.tryPromise({ 45 + try: async () => { 46 + if (!did) { 47 + throw new Error("User is not authenticated"); 48 + } 49 + if (!input.uri) { 50 + throw new Error("Missing song uri"); 51 + } 52 + 53 + const user = await ctx.db 36 54 .select() 37 55 .from(tables.users) 38 56 .where(eq(tables.users.did, did)) 39 - .execute() 40 - .then((users) => ({ user: users[0], ctx, params })), 41 - catch: (error) => new Error(`Failed to retrieve current user: ${error}`), 42 - }); 43 - }; 57 + .limit(1) 58 + .then((rows) => rows[0]); 59 + if (!user) { 60 + throw new Error("User not found"); 61 + } 44 62 45 - const dislike = () => { 46 - // Logic to dislike a song 47 - return {}; 48 - }; 63 + const agent = await createAgent(ctx.oauthClient, did); 64 + if (!agent) { 65 + throw new Error("Unauthorized"); 66 + } 49 67 50 - const presentation = () => { 51 - // Logic to format the response for presentation 52 - return {}; 53 - }; 68 + const track = await ctx.db 69 + .select() 70 + .from(tables.tracks) 71 + .where(eq(tables.tracks.uri, input.uri)) 72 + .limit(1) 73 + .then((rows) => rows[0]); 74 + if (!track) { 75 + throw new Error("Track not found"); 76 + } 77 + 78 + await unLikeTrack(ctx, track.sha256, user, agent); 79 + return {}; 80 + }, 81 + catch: (error) => new Error(`Failed to dislike song: ${error}`), 82 + }); 83 + 84 + const presentation = () => Effect.sync(() => ({}));
+46 -25
apps/api/src/xrpc/app/rocksky/like/likeShout.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 2 3 import type { Context } from "context"; 3 4 import { eq } from "drizzle-orm"; 4 5 import { Effect, pipe } from "effect"; 5 6 import type { Server } from "lexicon"; 6 - import type { QueryParams } from "lexicon/types/app/rocksky/like/likeShout"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/like/likeShout"; 8 + import { createAgent } from "lib/agent"; 7 9 import tables from "schema"; 10 + import { likeShout as likeShoutService } from "shouts/shouts.service"; 8 11 9 12 export default function (server: Server, ctx: Context) { 10 - const likeShout = (params, auth: HandlerAuth) => 11 - pipe(params, like, presentation); 13 + const likeShout = (input: InputSchema, auth: HandlerAuth) => 14 + pipe( 15 + like({ input, ctx, did: auth.credentials?.did }), 16 + Effect.flatMap(presentation), 17 + Effect.timeout("10 seconds"), 18 + Effect.catchAll((err) => { 19 + consola.error(err); 20 + return Effect.succeed({}); 21 + }), 22 + ); 12 23 server.app.rocksky.like.likeShout({ 13 24 auth: ctx.authVerifier, 14 - handler: async ({ params, auth }) => { 15 - const result = likeShout(params, auth); 25 + handler: async ({ input, auth }) => { 26 + const result = await Effect.runPromise(likeShout(input.body, auth)); 16 27 return { 17 28 encoding: "application/json", 18 29 body: result, ··· 21 32 }); 22 33 } 23 34 24 - const getCurrentUser = ({ 25 - params, 35 + const like = ({ 36 + input, 26 37 ctx, 27 38 did, 28 39 }: { 29 - params: QueryParams; 40 + input: InputSchema; 30 41 ctx: Context; 31 42 did?: string; 32 - }) => { 33 - return Effect.tryPromise({ 34 - try: async () => 35 - ctx.db 43 + }) => 44 + Effect.tryPromise({ 45 + try: async () => { 46 + if (!did) { 47 + throw new Error("User is not authenticated"); 48 + } 49 + if (!input.uri) { 50 + throw new Error("Missing shout uri"); 51 + } 52 + 53 + const user = await ctx.db 36 54 .select() 37 55 .from(tables.users) 38 56 .where(eq(tables.users.did, did)) 39 - .execute() 40 - .then((users) => ({ user: users[0], ctx, params })), 41 - catch: (error) => new Error(`Failed to retrieve current user: ${error}`), 57 + .limit(1) 58 + .then((rows) => rows[0]); 59 + if (!user) { 60 + throw new Error("User not found"); 61 + } 62 + 63 + const agent = await createAgent(ctx.oauthClient, did); 64 + if (!agent) { 65 + throw new Error("Unauthorized"); 66 + } 67 + 68 + await likeShoutService(ctx, input.uri, user, agent); 69 + return {}; 70 + }, 71 + catch: (error) => new Error(`Failed to like shout: ${error}`), 42 72 }); 43 - }; 44 73 45 - const like = () => { 46 - // Logic to like a shout 47 - return {}; 48 - }; 49 - 50 - const presentation = () => { 51 - // Logic to format the response for presentation 52 - return {}; 53 - }; 74 + const presentation = () => Effect.sync(() => ({}));
+69 -25
apps/api/src/xrpc/app/rocksky/like/likeSong.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 2 3 import type { Context } from "context"; 3 4 import { eq } from "drizzle-orm"; 4 5 import { Effect, pipe } from "effect"; 5 6 import type { Server } from "lexicon"; 6 - import type { QueryParams } from "lexicon/types/app/rocksky/like/likeSong"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/like/likeSong"; 8 + import { createAgent } from "lib/agent"; 9 + import { likeTrack } from "lovedtracks/lovedtracks.service"; 7 10 import tables from "schema"; 11 + import type { Track } from "types/track"; 8 12 9 13 export default function (server: Server, ctx: Context) { 10 - const likeSong = (params, auth: HandlerAuth) => 11 - pipe(params, like, presentation); 14 + const likeSong = (input: InputSchema, auth: HandlerAuth) => 15 + pipe( 16 + like({ input, ctx, did: auth.credentials?.did }), 17 + Effect.flatMap(presentation), 18 + Effect.timeout("10 seconds"), 19 + Effect.catchAll((err) => { 20 + consola.error(err); 21 + return Effect.succeed({}); 22 + }), 23 + ); 12 24 server.app.rocksky.like.likeSong({ 13 25 auth: ctx.authVerifier, 14 - handler: async ({ params, auth }) => { 15 - const result = likeSong(params, auth); 26 + handler: async ({ input, auth }) => { 27 + const result = await Effect.runPromise(likeSong(input.body, auth)); 16 28 return { 17 29 encoding: "application/json", 18 30 body: result, ··· 21 33 }); 22 34 } 23 35 24 - const getCurrentUser = ({ 25 - params, 36 + const like = ({ 37 + input, 26 38 ctx, 27 39 did, 28 40 }: { 29 - params: QueryParams; 41 + input: InputSchema; 30 42 ctx: Context; 31 43 did?: string; 32 - }) => { 33 - return Effect.tryPromise({ 34 - try: async () => 35 - ctx.db 44 + }) => 45 + Effect.tryPromise({ 46 + try: async () => { 47 + if (!did) { 48 + throw new Error("User is not authenticated"); 49 + } 50 + if (!input.uri) { 51 + throw new Error("Missing song uri"); 52 + } 53 + 54 + const user = await ctx.db 36 55 .select() 37 56 .from(tables.users) 38 57 .where(eq(tables.users.did, did)) 39 - .execute() 40 - .then((users) => ({ user: users[0], ctx, params })), 41 - catch: (error) => new Error(`Failed to retrieve current user: ${error}`), 42 - }); 43 - }; 58 + .limit(1) 59 + .then((rows) => rows[0]); 60 + if (!user) { 61 + throw new Error("User not found"); 62 + } 44 63 45 - const like = () => { 46 - // Logic to like a song 47 - return {}; 48 - }; 64 + const agent = await createAgent(ctx.oauthClient, did); 65 + if (!agent) { 66 + throw new Error("Unauthorized"); 67 + } 68 + 69 + const result = await ctx.db 70 + .select() 71 + .from(tables.tracks) 72 + .where(eq(tables.tracks.uri, input.uri)) 73 + .limit(1) 74 + .then((rows) => rows[0]); 75 + if (!result) { 76 + throw new Error("Track not found"); 77 + } 78 + 79 + const track: Track = { 80 + title: result.title, 81 + artist: result.artist, 82 + album: result.album, 83 + albumArt: result.albumArt, 84 + albumArtist: result.albumArtist, 85 + trackNumber: result.trackNumber, 86 + duration: result.duration, 87 + composer: result.composer, 88 + lyrics: result.lyrics, 89 + discNumber: result.discNumber, 90 + }; 91 + await likeTrack(ctx, track, user, agent); 92 + return {}; 93 + }, 94 + catch: (error) => new Error(`Failed to like song: ${error}`), 95 + }); 49 96 50 - const presentation = () => { 51 - // Logic to format the response for presentation 52 - return {}; 53 - }; 97 + const presentation = () => Effect.sync(() => ({}));
+36
apps/api/src/xrpc/app/rocksky/notification/getUnreadCount.ts
··· 1 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 3 + import type { Context } from "context"; 4 + import { eq } from "drizzle-orm"; 5 + import type { Server } from "lexicon"; 6 + import { getUnreadCount } from "notifications/notifications.service"; 7 + import tables from "schema"; 8 + 9 + export default function (server: Server, ctx: Context) { 10 + server.app.rocksky.notification.getUnreadCount({ 11 + auth: ctx.authVerifier, 12 + handler: async ({ auth }) => { 13 + let count = 0; 14 + try { 15 + const did = (auth as HandlerAuth).credentials?.did; 16 + if (did) { 17 + const user = await ctx.db 18 + .select({ id: tables.users.id }) 19 + .from(tables.users) 20 + .where(eq(tables.users.did, did)) 21 + .limit(1) 22 + .then((rows) => rows[0]); 23 + if (user) { 24 + count = await getUnreadCount(ctx, user.id); 25 + } 26 + } 27 + } catch (err) { 28 + consola.error(err); 29 + } 30 + return { 31 + encoding: "application/json", 32 + body: { count }, 33 + }; 34 + }, 35 + }); 36 + }
+64
apps/api/src/xrpc/app/rocksky/notification/listNotifications.ts
··· 1 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 3 + import type { Context } from "context"; 4 + import { eq } from "drizzle-orm"; 5 + import type { Server } from "lexicon"; 6 + import type { NotificationView } from "lexicon/types/app/rocksky/notification/defs"; 7 + import { 8 + getUnreadCount, 9 + listNotifications, 10 + } from "notifications/notifications.service"; 11 + import tables from "schema"; 12 + 13 + export default function (server: Server, ctx: Context) { 14 + server.app.rocksky.notification.listNotifications({ 15 + auth: ctx.authVerifier, 16 + handler: async ({ params, auth }) => { 17 + try { 18 + const did = (auth as HandlerAuth).credentials?.did; 19 + if (!did) { 20 + return { 21 + encoding: "application/json", 22 + body: { notifications: [], unreadCount: 0 }, 23 + }; 24 + } 25 + 26 + const user = await ctx.db 27 + .select({ id: tables.users.id }) 28 + .from(tables.users) 29 + .where(eq(tables.users.did, did)) 30 + .limit(1) 31 + .then((rows) => rows[0]); 32 + 33 + if (!user) { 34 + return { 35 + encoding: "application/json", 36 + body: { notifications: [], unreadCount: 0 }, 37 + }; 38 + } 39 + 40 + const { notifications, cursor } = await listNotifications( 41 + ctx, 42 + user.id, 43 + { limit: params.limit, cursor: params.cursor }, 44 + ); 45 + const unreadCount = await getUnreadCount(ctx, user.id); 46 + 47 + return { 48 + encoding: "application/json", 49 + body: { 50 + notifications: notifications as NotificationView[], 51 + unreadCount, 52 + cursor, 53 + }, 54 + }; 55 + } catch (err) { 56 + consola.error(err); 57 + return { 58 + encoding: "application/json", 59 + body: { notifications: [], unreadCount: 0 }, 60 + }; 61 + } 62 + }, 63 + }); 64 + }
+36
apps/api/src/xrpc/app/rocksky/notification/updateSeen.ts
··· 1 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 + import { consola } from "consola"; 3 + import type { Context } from "context"; 4 + import { eq } from "drizzle-orm"; 5 + import type { Server } from "lexicon"; 6 + import { markSeen } from "notifications/notifications.service"; 7 + import tables from "schema"; 8 + 9 + export default function (server: Server, ctx: Context) { 10 + server.app.rocksky.notification.updateSeen({ 11 + auth: ctx.authVerifier, 12 + handler: async ({ input, auth }) => { 13 + let unreadCount = 0; 14 + try { 15 + const did = (auth as HandlerAuth).credentials?.did; 16 + if (did) { 17 + const user = await ctx.db 18 + .select({ id: tables.users.id }) 19 + .from(tables.users) 20 + .where(eq(tables.users.did, did)) 21 + .limit(1) 22 + .then((rows) => rows[0]); 23 + if (user) { 24 + unreadCount = await markSeen(ctx, user.id, input.body?.ids); 25 + } 26 + } 27 + } catch (err) { 28 + consola.error(err); 29 + } 30 + return { 31 + encoding: "application/json", 32 + body: { unreadCount }, 33 + }; 34 + }, 35 + }); 36 + }
+66 -82
apps/api/src/xrpc/app/rocksky/shout/createShout.ts
··· 1 - import type { Agent } from "@atproto/api"; 1 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 2 import { consola } from "consola"; 3 - import type { HandlerAuth } from "@atproto/xrpc-server"; 4 3 import type { Context } from "context"; 5 - import { eq } from "drizzle-orm"; 4 + import { eq, or } from "drizzle-orm"; 6 5 import { Effect, pipe } from "effect"; 7 6 import type { Server } from "lexicon"; 8 7 import type { InputSchema } from "lexicon/types/app/rocksky/shout/createShout"; 9 8 import { createAgent } from "lib/agent"; 10 9 import tables from "schema"; 11 - import type { SelectUser } from "schema/users"; 10 + import { createShout as createShoutService } from "shouts/shouts.service"; 11 + import { shoutSchema } from "types/shout"; 12 + 13 + /** 14 + * The subject of a shout is carried in the request body alongside `message`. 15 + * The lexicon only formally declares `message`, but the input schema is open 16 + * (`[k: string]: unknown`), so the subject `uri` (a scrobble/song/album/artist 17 + * at-uri, or a bare user DID for a profile shout) travels through untouched. 18 + */ 19 + type CreateShoutInput = InputSchema & { uri?: string }; 12 20 13 21 export default function (server: Server, ctx: Context) { 14 - const createShout = (input, auth: HandlerAuth) => 22 + const createShout = (input: CreateShoutInput, auth: HandlerAuth) => 15 23 pipe( 16 - { input, ctx, did: auth.credentials?.did }, 17 - withAgent, 18 - Effect.flatMap(withUser), 19 - Effect.flatMap(putRecord), 20 - Effect.flatMap(saveIntoDatabase), 24 + handleCreate({ input, ctx, did: auth.credentials?.did }), 21 25 Effect.flatMap(presentation), 22 - Effect.retry({ times: 3 }), 23 - Effect.timeout("10 seconds"), 26 + Effect.timeout("15 seconds"), 24 27 Effect.catchAll((err) => { 25 28 consola.error(err); 26 29 return Effect.succeed({}); ··· 29 32 server.app.rocksky.shout.createShout({ 30 33 auth: ctx.authVerifier, 31 34 handler: async ({ input, auth }) => { 32 - const result = await Effect.runPromise(createShout(input.body, auth)); 35 + const result = await Effect.runPromise( 36 + createShout(input.body as CreateShoutInput, auth), 37 + ); 33 38 return { 34 39 encoding: "application/json", 35 40 body: result, ··· 38 43 }); 39 44 } 40 45 41 - const withAgent = ({ 42 - input, 43 - ctx, 44 - did, 45 - }: { 46 - input: InputSchema; 47 - ctx: Context; 48 - did: string; 49 - }): Effect.Effect< 50 - { 51 - agent: Agent; 52 - ctx: Context; 53 - did: string; 54 - input: InputSchema; 55 - }, 56 - Error 57 - > => { 58 - return Effect.tryPromise({ 59 - try: async () => 60 - createAgent(ctx.oauthClient, did).then((agent) => ({ 61 - agent, 62 - ctx, 63 - did, 64 - input, 65 - })), 66 - catch: (error) => new Error(`Failed to create agent: ${error}`), 67 - }); 68 - }; 69 - 70 - const withUser = ({ 46 + const handleCreate = ({ 71 47 input, 72 48 ctx, 73 49 did, 74 - agent, 75 50 }: { 76 - input: InputSchema; 51 + input: CreateShoutInput; 77 52 ctx: Context; 78 53 did?: string; 79 - agent: Agent; 80 - }) => { 81 - return Effect.tryPromise({ 82 - try: async () => 83 - ctx.db 54 + }) => 55 + Effect.tryPromise({ 56 + try: async () => { 57 + if (!did) { 58 + throw new Error("User is not authenticated"); 59 + } 60 + if (!input.uri) { 61 + throw new Error("Missing shout subject uri"); 62 + } 63 + 64 + const parsed = shoutSchema.safeParse(input); 65 + if (!parsed.success) { 66 + throw new Error(`Invalid shout data: ${parsed.error.message}`); 67 + } 68 + 69 + const user = await ctx.db 84 70 .select() 85 71 .from(tables.users) 86 72 .where(eq(tables.users.did, did)) 87 - .execute() 88 - .then((users) => ({ user: users[0], ctx, input, agent })), 89 - catch: (error) => new Error(`Failed to retrieve current user: ${error}`), 90 - }); 91 - }; 73 + .limit(1) 74 + .then((rows) => rows[0]); 75 + if (!user) { 76 + throw new Error("User not found"); 77 + } 92 78 93 - const putRecord = ({ 94 - input, 95 - ctx, 96 - did, 97 - user, 98 - agent, 99 - }: { 100 - input: InputSchema; 101 - ctx: Context; 102 - did?: string; 103 - user: SelectUser; 104 - agent: Agent; 105 - }) => { 106 - return Effect.tryPromise({ 107 - try: async () => {}, 108 - catch: (error) => new Error(`Failed to put shout record: ${error}`), 109 - }); 110 - }; 79 + const agent = await createAgent(ctx.oauthClient, did); 80 + if (!agent) { 81 + throw new Error("Unauthorized"); 82 + } 83 + 84 + // A bare `at://<did>` (or handle) targets a user profile; the shout 85 + // service resolves it to the profile owner and writes a profile_shout. 86 + let subjectUri = input.uri; 87 + if (!subjectUri.includes("/app.rocksky.")) { 88 + const target = subjectUri.replace("at://", ""); 89 + const profile = await ctx.db 90 + .select() 91 + .from(tables.users) 92 + .where(or(eq(tables.users.did, target), eq(tables.users.handle, target))) 93 + .limit(1) 94 + .then((rows) => rows[0]); 95 + if (!profile) { 96 + throw new Error("Profile not found"); 97 + } 98 + subjectUri = `at://${profile.did}`; 99 + } 111 100 112 - const saveIntoDatabase = () => { 113 - return Effect.tryPromise({ 114 - try: async () => {}, 101 + await createShoutService(ctx, parsed.data, subjectUri, user, agent); 102 + return {}; 103 + }, 115 104 catch: (error) => new Error(`Failed to create shout: ${error}`), 116 105 }); 117 - }; 118 106 119 - const presentation = () => { 120 - return Effect.sync(() => ({ 121 - shouts: [], 122 - })); 123 - }; 107 + const presentation = () => Effect.sync(() => ({}));
+97 -38
apps/api/src/xrpc/app/rocksky/shout/removeShout.ts
··· 1 - import type { Agent } from "@atproto/api"; 1 + import type { HandlerAuth } from "@atproto/xrpc-server"; 2 2 import { consola } from "consola"; 3 - import type { HandlerAuth } from "@atproto/xrpc-server"; 4 3 import type { Context } from "context"; 4 + import { eq, inArray } from "drizzle-orm"; 5 5 import { Effect, pipe } from "effect"; 6 6 import type { Server } from "lexicon"; 7 7 import type { QueryParams } from "lexicon/types/app/rocksky/shout/removeShout"; 8 8 import { createAgent } from "lib/agent"; 9 + import tables from "schema"; 9 10 10 11 export default function (server: Server, ctx: Context) { 11 - const removeShout = (params, auth: HandlerAuth) => 12 + const removeShout = (params: QueryParams, auth: HandlerAuth) => 12 13 pipe( 13 - { params, ctx }, 14 - remove, 14 + handleRemove({ params, ctx, did: auth.credentials?.did }), 15 15 Effect.flatMap(presentation), 16 - Effect.retry({ times: 3 }), 17 - Effect.timeout("10 seconds"), 16 + Effect.timeout("15 seconds"), 18 17 Effect.catchAll((err) => { 19 18 consola.error(err); 20 19 return Effect.succeed({}); ··· 32 31 }); 33 32 } 34 33 35 - const withAgent = ({ 34 + const handleRemove = ({ 36 35 params, 37 36 ctx, 38 37 did, 39 38 }: { 40 39 params: QueryParams; 41 40 ctx: Context; 42 - did: string; 43 - }): Effect.Effect< 44 - { 45 - agent: Agent; 46 - ctx: Context; 47 - did: string; 48 - params: QueryParams; 49 - }, 50 - Error 51 - > => { 52 - return Effect.tryPromise({ 53 - try: async () => 54 - createAgent(ctx.oauthClient, did).then((agent) => ({ 55 - agent, 56 - ctx, 57 - did, 58 - params, 59 - })), 60 - catch: (error) => new Error(`Failed to create agent: ${error}`), 61 - }); 62 - }; 41 + did?: string; 42 + }) => 43 + Effect.tryPromise({ 44 + try: async () => { 45 + if (!did) { 46 + throw new Error("User is not authenticated"); 47 + } 48 + if (!params.id) { 49 + throw new Error("Missing shout id"); 50 + } 51 + 52 + const user = await ctx.db 53 + .select() 54 + .from(tables.users) 55 + .where(eq(tables.users.did, did)) 56 + .limit(1) 57 + .then((rows) => rows[0]); 58 + if (!user) { 59 + throw new Error("User not found"); 60 + } 61 + 62 + const agent = await createAgent(ctx.oauthClient, did); 63 + if (!agent) { 64 + throw new Error("Unauthorized"); 65 + } 66 + 67 + // `id` may be the shout's at-uri or its database id. 68 + const column = params.id.startsWith("at://") 69 + ? tables.shouts.uri 70 + : tables.shouts.id; 71 + const shout = await ctx.db 72 + .select() 73 + .from(tables.shouts) 74 + .where(eq(column, params.id)) 75 + .limit(1) 76 + .then((rows) => rows[0]); 77 + if (!shout) { 78 + throw new Error("Shout not found"); 79 + } 80 + 81 + if (shout.authorId !== user.id) { 82 + throw new Error("Forbidden"); 83 + } 84 + 85 + const replies = await ctx.db 86 + .select({ id: tables.shouts.id }) 87 + .from(tables.shouts) 88 + .where(eq(tables.shouts.parentId, shout.id)); 89 + const replyIds = replies.map((r) => r.id); 90 + 91 + // Delete related records in the correct order (children first). 92 + if (replyIds.length) { 93 + await ctx.db 94 + .delete(tables.shoutLikes) 95 + .where(inArray(tables.shoutLikes.shoutId, replyIds)); 96 + await ctx.db 97 + .delete(tables.shoutReports) 98 + .where(inArray(tables.shoutReports.shoutId, replyIds)); 99 + await ctx.db 100 + .delete(tables.profileShouts) 101 + .where(inArray(tables.profileShouts.shoutId, replyIds)); 102 + } 103 + 104 + await ctx.db 105 + .delete(tables.profileShouts) 106 + .where(eq(tables.profileShouts.shoutId, shout.id)); 107 + await ctx.db 108 + .delete(tables.shoutLikes) 109 + .where(eq(tables.shoutLikes.shoutId, shout.id)); 110 + await ctx.db 111 + .delete(tables.shoutReports) 112 + .where(eq(tables.shoutReports.shoutId, shout.id)); 113 + 114 + if (replyIds.length) { 115 + await ctx.db 116 + .delete(tables.shouts) 117 + .where(inArray(tables.shouts.id, replyIds)); 118 + } 119 + await ctx.db.delete(tables.shouts).where(eq(tables.shouts.id, shout.id)); 120 + 121 + await agent.com.atproto.repo.deleteRecord({ 122 + repo: agent.assertDid, 123 + collection: "app.rocksky.shout", 124 + rkey: shout.uri.split("/").pop(), 125 + }); 63 126 64 - const remove = () => { 65 - return Effect.tryPromise({ 66 - try: async () => {}, 127 + return shout; 128 + }, 67 129 catch: (error) => new Error(`Failed to remove shout: ${error}`), 68 130 }); 69 - }; 70 131 71 - const presentation = () => { 72 - return Effect.sync(() => ({ 73 - shouts: [], 74 - })); 75 - }; 132 + // The lexicon output is a ShoutView; this mutation only confirms success, so 133 + // (like the sibling shout handlers) it returns an empty object. 134 + const presentation = () => Effect.sync(() => ({}));
+70 -42
apps/api/src/xrpc/app/rocksky/shout/replyShout.ts
··· 1 - import type { Agent } from "@atproto/api"; 2 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 3 2 import { consola } from "consola"; 4 3 import type { Context } from "context"; 4 + import { eq } from "drizzle-orm"; 5 5 import { Effect, pipe } from "effect"; 6 6 import type { Server } from "lexicon"; 7 - import type { QueryParams } from "lexicon/types/app/rocksky/shout/replyShout"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/shout/replyShout"; 8 8 import { createAgent } from "lib/agent"; 9 + import tables from "schema"; 10 + import { replyShout as replyShoutService } from "shouts/shouts.service"; 11 + import { shoutSchema } from "types/shout"; 9 12 10 13 export default function (server: Server, ctx: Context) { 11 - const replyShout = (params, auth: HandlerAuth) => 14 + const replyShout = (input: InputSchema, auth: HandlerAuth) => 12 15 pipe( 13 - { params, ctx }, 14 - reply, 16 + handleReply({ input, ctx, did: auth.credentials?.did }), 15 17 Effect.flatMap(presentation), 16 - Effect.retry({ times: 3 }), 17 - Effect.timeout("10 seconds"), 18 + Effect.timeout("15 seconds"), 18 19 Effect.catchAll((err) => { 19 20 consola.error(err); 20 - return Effect.succeed({ albums: [] }); 21 + return Effect.succeed({}); 21 22 }), 22 23 ); 23 24 server.app.rocksky.shout.replyShout({ 24 25 auth: ctx.authVerifier, 25 - handler: async ({ params, auth }) => { 26 - const result = await Effect.runPromise(replyShout(params, auth)); 26 + handler: async ({ input, auth }) => { 27 + const result = await Effect.runPromise(replyShout(input.body, auth)); 27 28 return { 28 29 encoding: "application/json", 29 30 body: result, ··· 32 33 }); 33 34 } 34 35 35 - const withAgent = ({ 36 - params, 36 + const handleReply = ({ 37 + input, 37 38 ctx, 38 39 did, 39 40 }: { 40 - params: QueryParams; 41 + input: InputSchema; 41 42 ctx: Context; 42 - did: string; 43 - }): Effect.Effect< 44 - { 45 - agent: Agent; 46 - ctx: Context; 47 - did: string; 48 - params: QueryParams; 49 - }, 50 - Error 51 - > => { 52 - return Effect.tryPromise({ 53 - try: async () => 54 - createAgent(ctx.oauthClient, did).then((agent) => ({ 55 - agent, 56 - ctx, 57 - did, 58 - params, 59 - })), 60 - catch: (error) => new Error(`Failed to create agent: ${error}`), 61 - }); 62 - }; 43 + did?: string; 44 + }) => 45 + Effect.tryPromise({ 46 + try: async () => { 47 + if (!did) { 48 + throw new Error("User is not authenticated"); 49 + } 50 + if (!input.shoutId) { 51 + throw new Error("Missing parent shout id"); 52 + } 63 53 64 - const reply = () => { 65 - return Effect.tryPromise({ 66 - try: async () => {}, 54 + const parsed = shoutSchema.safeParse(input); 55 + if (!parsed.success) { 56 + throw new Error(`Invalid reply data: ${parsed.error.message}`); 57 + } 58 + 59 + const user = await ctx.db 60 + .select() 61 + .from(tables.users) 62 + .where(eq(tables.users.did, did)) 63 + .limit(1) 64 + .then((rows) => rows[0]); 65 + if (!user) { 66 + throw new Error("User not found"); 67 + } 68 + 69 + const agent = await createAgent(ctx.oauthClient, did); 70 + if (!agent) { 71 + throw new Error("Unauthorized"); 72 + } 73 + 74 + // `shoutId` may be the shout's at-uri or its database id; resolve to the 75 + // at-uri that the reply service expects. 76 + const parentUri = await resolveShoutUri(ctx, input.shoutId); 77 + if (!parentUri) { 78 + throw new Error("Parent shout not found"); 79 + } 80 + 81 + await replyShoutService(ctx, parsed.data, parentUri, user, agent); 82 + return {}; 83 + }, 67 84 catch: (error) => new Error(`Failed to reply to shout: ${error}`), 68 85 }); 69 - }; 70 86 71 - const presentation = () => { 72 - return Effect.sync(() => ({ 73 - shouts: [], 74 - })); 87 + const resolveShoutUri = async ( 88 + ctx: Context, 89 + idOrUri: string, 90 + ): Promise<string | undefined> => { 91 + if (idOrUri.startsWith("at://")) { 92 + return idOrUri; 93 + } 94 + const shout = await ctx.db 95 + .select({ uri: tables.shouts.uri }) 96 + .from(tables.shouts) 97 + .where(eq(tables.shouts.id, idOrUri)) 98 + .limit(1) 99 + .then((rows) => rows[0]); 100 + return shout?.uri; 75 101 }; 102 + 103 + const presentation = () => Effect.sync(() => ({}));
+80 -14
apps/api/src/xrpc/app/rocksky/shout/reportShout.ts
··· 1 1 import type { HandlerAuth } from "@atproto/xrpc-server"; 2 2 import { consola } from "consola"; 3 3 import type { Context } from "context"; 4 + import { and, eq } from "drizzle-orm"; 4 5 import { Effect, pipe } from "effect"; 5 6 import type { Server } from "lexicon"; 7 + import type { InputSchema } from "lexicon/types/app/rocksky/shout/reportShout"; 8 + import tables from "schema"; 6 9 7 10 export default function (server: Server, ctx: Context) { 8 - const reportShout = (params, auth: HandlerAuth) => 11 + const reportShout = (input: InputSchema, auth: HandlerAuth) => 9 12 pipe( 10 - { params, ctx }, 11 - report, 13 + handleReport({ input, ctx, did: auth.credentials?.did }), 12 14 Effect.flatMap(presentation), 13 - Effect.retry({ times: 3 }), 14 15 Effect.timeout("10 seconds"), 15 16 Effect.catchAll((err) => { 16 17 consola.error(err); ··· 19 20 ); 20 21 server.app.rocksky.shout.reportShout({ 21 22 auth: ctx.authVerifier, 22 - handler: async ({ params, auth }) => { 23 - const result = await Effect.runPromise(reportShout(params, auth)); 23 + handler: async ({ input, auth }) => { 24 + const result = await Effect.runPromise(reportShout(input.body, auth)); 24 25 return { 25 26 encoding: "application/json", 26 27 body: result, ··· 29 30 }); 30 31 } 31 32 32 - const report = () => { 33 - return Effect.tryPromise({ 34 - try: async () => {}, 33 + const handleReport = ({ 34 + input, 35 + ctx, 36 + did, 37 + }: { 38 + input: InputSchema; 39 + ctx: Context; 40 + did?: string; 41 + }) => 42 + Effect.tryPromise({ 43 + try: async () => { 44 + if (!did) { 45 + throw new Error("User is not authenticated"); 46 + } 47 + if (!input.shoutId) { 48 + throw new Error("Missing shout id"); 49 + } 50 + 51 + const user = await ctx.db 52 + .select() 53 + .from(tables.users) 54 + .where(eq(tables.users.did, did)) 55 + .limit(1) 56 + .then((rows) => rows[0]); 57 + if (!user) { 58 + throw new Error("User not found"); 59 + } 60 + 61 + // `shoutId` may be the shout's at-uri or its database id. 62 + const shout = await resolveShout(ctx, input.shoutId); 63 + if (!shout) { 64 + throw new Error("Shout not found"); 65 + } 66 + 67 + const existingReport = await ctx.db 68 + .select() 69 + .from(tables.shoutReports) 70 + .where( 71 + and( 72 + eq(tables.shoutReports.userId, user.id), 73 + eq(tables.shoutReports.shoutId, shout.id), 74 + ), 75 + ) 76 + .limit(1) 77 + .then((rows) => rows[0]); 78 + 79 + if (existingReport) { 80 + return existingReport; 81 + } 82 + 83 + return await ctx.db 84 + .insert(tables.shoutReports) 85 + .values({ 86 + userId: user.id, 87 + shoutId: shout.id, 88 + }) 89 + .returning() 90 + .then((rows) => rows[0]); 91 + }, 35 92 catch: (error) => new Error(`Failed to report shout: ${error}`), 36 93 }); 94 + 95 + const resolveShout = async (ctx: Context, idOrUri: string) => { 96 + const column = idOrUri.startsWith("at://") 97 + ? tables.shouts.uri 98 + : tables.shouts.id; 99 + return ctx.db 100 + .select() 101 + .from(tables.shouts) 102 + .where(eq(column, idOrUri)) 103 + .limit(1) 104 + .then((rows) => rows[0]); 37 105 }; 38 106 39 - const presentation = () => { 40 - return Effect.sync(() => ({ 41 - shouts: [], 42 - })); 43 - }; 107 + // The lexicon output is a ShoutView; this mutation only confirms success, so 108 + // (like the sibling like/dislike handlers) it returns an empty object. 109 + const presentation = () => Effect.sync(() => ({}));
+6
apps/api/src/xrpc/index.ts
··· 51 51 import likeSong from "./app/rocksky/like/likeSong"; 52 52 import getMirrorSources from "./app/rocksky/mirror/getMirrorSources"; 53 53 import putMirrorSource from "./app/rocksky/mirror/putMirrorSource"; 54 + import getUnreadCount from "./app/rocksky/notification/getUnreadCount"; 55 + import listNotifications from "./app/rocksky/notification/listNotifications"; 56 + import updateSeen from "./app/rocksky/notification/updateSeen"; 54 57 import registerLibrary from "./app/rocksky/library"; 55 58 import getAudioSettings from "./app/rocksky/rockbox/getAudioSettings"; 56 59 import putAudioSettings from "./app/rocksky/rockbox/putAudioSettings"; ··· 194 197 getAlbumRecommendations(server, ctx); 195 198 getMirrorSources(server, ctx); 196 199 putMirrorSource(server, ctx); 200 + listNotifications(server, ctx); 201 + getUnreadCount(server, ctx); 202 + updateSeen(server, ctx); 197 203 getAudioSettings(server, ctx); 198 204 putAudioSettings(server, ctx); 199 205
+1
apps/app-proxy/src/index.ts
··· 141 141 url.pathname.startsWith('/googledrive/join') || 142 142 url.pathname.startsWith('/search') || 143 143 url.pathname.startsWith('/public/scrobbles') || 144 + url.pathname.startsWith('/notifications') || 144 145 url.pathname.startsWith('/uploads') 145 146 ) { 146 147 redirectToApi = true;
+2
apps/web-mobile/src/App.tsx
··· 14 14 import LibraryPlaylistPage from "./pages/library/playlist"; 15 15 import UploadPage from "./pages/library/upload"; 16 16 import Me from "./pages/me"; 17 + import NotificationsPage from "./pages/notifications"; 17 18 import ProfilePage from "./pages/profile"; 18 19 import Recommendations from "./pages/recommendations"; 19 20 import Search from "./pages/search"; ··· 28 29 <Route path="/" element={<HomePage />} /> 29 30 <Route path="/charts" element={<Charts />} /> 30 31 <Route path="/recommendations" element={<Recommendations />} /> 32 + <Route path="/notifications" element={<NotificationsPage />} /> 31 33 <Route path="/wrapped" element={<WrappedPage />} /> 32 34 <Route path="/search" element={<Search />} /> 33 35 <Route path="/me" element={<Me />} />
+71
apps/web-mobile/src/api/notifications.ts
··· 1 + import { client } from "."; 2 + 3 + export interface NotificationActor { 4 + id?: string; 5 + did?: string; 6 + handle?: string; 7 + displayName?: string; 8 + avatar?: string; 9 + } 10 + 11 + export type NotificationType = 12 + | "like_scrobble" 13 + | "follow" 14 + | "comment_scrobble" 15 + | "comment_profile" 16 + | "reply" 17 + | "react_comment"; 18 + 19 + export interface NotificationView { 20 + id: string; 21 + type: NotificationType; 22 + read: boolean; 23 + createdAt: string; 24 + subjectUri?: string; 25 + shoutId?: string; 26 + shoutContent?: string; 27 + actor?: NotificationActor; 28 + } 29 + 30 + export interface NotificationList { 31 + notifications: NotificationView[]; 32 + unreadCount: number; 33 + cursor?: string; 34 + } 35 + 36 + const authHeaders = () => ({ 37 + Authorization: `Bearer ${localStorage.getItem("token")}`, 38 + }); 39 + 40 + export const getUnreadCount = async (): Promise<number> => { 41 + const response = await client.get( 42 + "/xrpc/app.rocksky.notification.getUnreadCount", 43 + { headers: authHeaders() }, 44 + ); 45 + return response.data?.count ?? 0; 46 + }; 47 + 48 + export const listNotifications = async ( 49 + cursor?: string, 50 + ): Promise<NotificationList> => { 51 + const response = await client.get( 52 + "/xrpc/app.rocksky.notification.listNotifications", 53 + { 54 + params: { limit: 30, ...(cursor ? { cursor } : {}) }, 55 + headers: authHeaders(), 56 + }, 57 + ); 58 + return response.data; 59 + }; 60 + 61 + /** Mark notifications as viewed. Omit `ids` to mark the whole inbox. */ 62 + export const markNotificationsSeen = async ( 63 + ids?: string[], 64 + ): Promise<number> => { 65 + const response = await client.post( 66 + "/xrpc/app.rocksky.notification.updateSeen", 67 + ids?.length ? { ids } : {}, 68 + { headers: authHeaders() }, 69 + ); 70 + return response.data?.unreadCount ?? 0; 71 + };
+45 -5
apps/web-mobile/src/components/BottomNav/index.tsx
··· 1 1 import { 2 + IconBell, 2 3 IconChartBar, 3 4 IconHome, 4 5 IconSearch, 5 - IconSparkles, 6 6 IconUser, 7 7 IconVinyl, 8 8 } from "@tabler/icons-react"; 9 9 import { useAtomValue } from "jotai"; 10 10 import { Link, useLocation } from "react-router-dom"; 11 11 import { profileAtom } from "../../atoms/profile"; 12 + import { 13 + useNotificationStream, 14 + useUnreadCountQuery, 15 + } from "../../hooks/useNotifications"; 16 + 17 + type NavTab = { 18 + to: string; 19 + icon: typeof IconHome; 20 + label: string; 21 + badge?: number; 22 + }; 12 23 13 24 export default function BottomNav() { 14 25 const location = useLocation(); 15 26 const profile = useAtomValue(profileAtom); 16 27 const jwt = localStorage.getItem("token"); 17 28 18 - const baseTabs = [ 29 + // BottomNav is mounted on every screen, so it's the natural home for the 30 + // live notification subscription that keeps the badge up to date app-wide. 31 + useNotificationStream(); 32 + const { data: unreadCount = 0 } = useUnreadCountQuery(); 33 + 34 + const baseTabs: NavTab[] = [ 19 35 { to: "/", icon: IconHome, label: "Home" }, 20 36 { to: "/charts", icon: IconChartBar, label: "Charts" }, 21 37 { to: "/search", icon: IconSearch, label: "Search" }, ··· 27 43 profile && jwt 28 44 ? [ 29 45 baseTabs[0], 30 - { to: "/recommendations", icon: IconSparkles, label: "For You" }, 46 + { 47 + to: "/notifications", 48 + icon: IconBell, 49 + label: "Alerts", 50 + badge: unreadCount, 51 + }, 31 52 baseTabs[1], 32 53 baseTabs[2], 33 54 baseTabs[3], ··· 45 66 }} 46 67 > 47 68 <div className="flex h-14"> 48 - {tabs.map(({ to, icon: Icon, label }) => { 69 + {tabs.map(({ to, icon: Icon, label, badge }) => { 49 70 const active = 50 71 to === "/" 51 72 ? location.pathname === "/" ··· 57 78 className="flex flex-1 flex-col items-center justify-center gap-0.5 no-underline" 58 79 style={{ color: active ? "var(--color-primary)" : "var(--color-text-muted)" }} 59 80 > 60 - <Icon size={22} strokeWidth={active ? 2.2 : 1.8} /> 81 + <span className="relative flex items-center justify-center"> 82 + <Icon size={22} strokeWidth={active ? 2.2 : 1.8} /> 83 + {typeof badge === "number" && badge > 0 && ( 84 + <span 85 + className="absolute flex items-center justify-center rounded-full font-bold text-white" 86 + style={{ 87 + top: "-6px", 88 + left: "12px", 89 + minWidth: "16px", 90 + height: "16px", 91 + padding: "0 4px", 92 + fontSize: "10px", 93 + lineHeight: "16px", 94 + backgroundColor: "#e0245e", 95 + }} 96 + > 97 + {badge > 99 ? "99+" : badge} 98 + </span> 99 + )} 100 + </span> 61 101 <span className="text-[10px] font-medium">{label}</span> 62 102 </Link> 63 103 );
+80
apps/web-mobile/src/hooks/useNotifications.tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { useEffect } from "react"; 3 + import { 4 + getUnreadCount, 5 + listNotifications, 6 + markNotificationsSeen, 7 + } from "../api/notifications"; 8 + import { API_URL } from "../consts"; 9 + 10 + const UNREAD_KEY = ["notifications", "unreadCount"]; 11 + const LIST_KEY = ["notifications", "list"]; 12 + 13 + export const useUnreadCountQuery = () => 14 + useQuery({ 15 + queryKey: UNREAD_KEY, 16 + queryFn: getUnreadCount, 17 + enabled: !!localStorage.getItem("token"), 18 + }); 19 + 20 + export const useNotificationListQuery = (enabled: boolean) => 21 + useQuery({ 22 + queryKey: LIST_KEY, 23 + queryFn: () => listNotifications(), 24 + enabled, 25 + }); 26 + 27 + export const useMarkSeenMutation = () => { 28 + const queryClient = useQueryClient(); 29 + return useMutation({ 30 + mutationFn: (ids?: string[]) => markNotificationsSeen(ids), 31 + onSuccess: (unreadCount) => { 32 + queryClient.setQueryData(UNREAD_KEY, unreadCount); 33 + queryClient.invalidateQueries({ queryKey: LIST_KEY }); 34 + }, 35 + }); 36 + }; 37 + 38 + /** 39 + * Subscribes to the notifications SSE stream and keeps the react-query caches 40 + * in sync. The browser's EventSource auto-reconnects on transient errors. 41 + */ 42 + export const useNotificationStream = () => { 43 + const queryClient = useQueryClient(); 44 + 45 + useEffect(() => { 46 + const token = localStorage.getItem("token"); 47 + if (!token) return; 48 + 49 + const setCount = (n: number) => 50 + queryClient.setQueryData(UNREAD_KEY, n); 51 + 52 + const es = new EventSource( 53 + `${API_URL}/notifications/stream?token=${encodeURIComponent(token)}`, 54 + ); 55 + 56 + const onCount = (e: MessageEvent) => { 57 + try { 58 + setCount(JSON.parse(e.data).unreadCount); 59 + } catch { 60 + // ignore malformed payloads 61 + } 62 + }; 63 + 64 + es.addEventListener("unread", onCount as EventListener); 65 + es.addEventListener("seen", onCount as EventListener); 66 + es.addEventListener("notification", ((e: MessageEvent) => { 67 + try { 68 + const payload = JSON.parse(e.data); 69 + if (typeof payload.unreadCount === "number") { 70 + setCount(payload.unreadCount); 71 + } 72 + } catch { 73 + // ignore 74 + } 75 + queryClient.invalidateQueries({ queryKey: LIST_KEY }); 76 + }) as EventListener); 77 + 78 + return () => es.close(); 79 + }, [queryClient]); 80 + };
+8
apps/web-mobile/src/layouts/Navbar/Navbar.tsx
··· 109 109 </Link> 110 110 )} 111 111 <Link 112 + to="/recommendations" 113 + className="py-3.5 px-2 no-underline font-medium text-base block" 114 + style={{ color: "var(--color-text)" }} 115 + onClick={() => setMenuOpen(false)} 116 + > 117 + Recommendations 118 + </Link> 119 + <Link 112 120 to="/wrapped" 113 121 className="py-3.5 px-2 no-underline font-medium text-base block" 114 122 style={{ color: "var(--color-text)" }}
+105
apps/web-mobile/src/pages/notifications/index.tsx
··· 1 + import { IconUser } from "@tabler/icons-react"; 2 + import { Avatar } from "baseui/avatar"; 3 + import { useEffect } from "react"; 4 + import { Link } from "react-router-dom"; 5 + import type { NotificationView } from "../../api/notifications"; 6 + import Main from "../../layouts/Main"; 7 + import { 8 + useMarkSeenMutation, 9 + useNotificationListQuery, 10 + } from "../../hooks/useNotifications"; 11 + 12 + const VERB: Record<string, string> = { 13 + like_scrobble: "liked your scrobble", 14 + follow: "followed you", 15 + comment_scrobble: "commented on your scrobble", 16 + comment_profile: "commented on your profile", 17 + reply: "replied to your comment", 18 + react_comment: "reacted to your comment", 19 + }; 20 + 21 + const timeAgo = (iso: string): string => { 22 + const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); 23 + if (seconds < 60) return "just now"; 24 + const minutes = Math.floor(seconds / 60); 25 + if (minutes < 60) return `${minutes}m`; 26 + const hours = Math.floor(minutes / 60); 27 + if (hours < 24) return `${hours}h`; 28 + const days = Math.floor(hours / 24); 29 + if (days < 7) return `${days}d`; 30 + return new Date(iso).toLocaleDateString(); 31 + }; 32 + 33 + function NotificationRow({ notification }: { notification: NotificationView }) { 34 + const actor = notification.actor; 35 + const name = actor?.displayName || actor?.handle || "Someone"; 36 + const verb = VERB[notification.type] ?? "sent you a notification"; 37 + const isJpegPlaceholder = actor?.avatar?.endsWith("/@jpeg"); 38 + 39 + return ( 40 + <Link 41 + to={`/profile/${actor?.handle ?? actor?.did ?? ""}`} 42 + className="flex items-start gap-3 px-4 py-3 no-underline" 43 + style={{ borderBottom: "1px solid var(--color-border)" }} 44 + > 45 + {actor?.avatar && !isJpegPlaceholder ? ( 46 + <Avatar src={actor.avatar} name={name} size="40px" /> 47 + ) : ( 48 + <div 49 + className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" 50 + style={{ backgroundColor: "var(--color-avatar-background)" }} 51 + > 52 + <IconUser size={22} color="#fff" /> 53 + </div> 54 + )} 55 + <div className="min-w-0 flex-1"> 56 + <p className="text-sm m-0" style={{ color: "var(--color-text)" }}> 57 + <b>{name}</b> {verb} 58 + {notification.shoutContent ? `: "${notification.shoutContent}"` : ""} 59 + </p> 60 + <p 61 + className="text-xs m-0 mt-0.5" 62 + style={{ color: "var(--color-text-muted)" }} 63 + > 64 + {timeAgo(notification.createdAt)} 65 + </p> 66 + </div> 67 + </Link> 68 + ); 69 + } 70 + 71 + export default function NotificationsPage() { 72 + const { data, isLoading } = useNotificationListQuery(true); 73 + const markSeen = useMarkSeenMutation(); 74 + 75 + // Opening the screen marks everything as viewed. 76 + useEffect(() => { 77 + markSeen.mutate(undefined); 78 + // eslint-disable-next-line react-hooks/exhaustive-deps 79 + }, []); 80 + 81 + return ( 82 + <Main> 83 + <div className="pt-16 pb-24"> 84 + <h1 85 + className="text-xl font-bold px-4 py-3 m-0" 86 + style={{ color: "var(--color-text)" }} 87 + > 88 + Notifications 89 + </h1> 90 + {!isLoading && (!data || data.notifications.length === 0) ? ( 91 + <p 92 + className="text-center py-16 px-4" 93 + style={{ color: "var(--color-text-muted)" }} 94 + > 95 + No notifications yet 96 + </p> 97 + ) : ( 98 + data?.notifications.map((n) => ( 99 + <NotificationRow key={n.id} notification={n} /> 100 + )) 101 + )} 102 + </div> 103 + </Main> 104 + ); 105 + }
+71
apps/web/src/api/notifications.ts
··· 1 + import { client } from "."; 2 + 3 + export interface NotificationActor { 4 + id?: string; 5 + did?: string; 6 + handle?: string; 7 + displayName?: string; 8 + avatar?: string; 9 + } 10 + 11 + export type NotificationType = 12 + | "like_scrobble" 13 + | "follow" 14 + | "comment_scrobble" 15 + | "comment_profile" 16 + | "reply" 17 + | "react_comment"; 18 + 19 + export interface NotificationView { 20 + id: string; 21 + type: NotificationType; 22 + read: boolean; 23 + createdAt: string; 24 + subjectUri?: string; 25 + shoutId?: string; 26 + shoutContent?: string; 27 + actor?: NotificationActor; 28 + } 29 + 30 + export interface NotificationList { 31 + notifications: NotificationView[]; 32 + unreadCount: number; 33 + cursor?: string; 34 + } 35 + 36 + const authHeaders = () => ({ 37 + Authorization: `Bearer ${localStorage.getItem("token")}`, 38 + }); 39 + 40 + export const getUnreadCount = async (): Promise<number> => { 41 + const response = await client.get( 42 + "/xrpc/app.rocksky.notification.getUnreadCount", 43 + { headers: authHeaders() }, 44 + ); 45 + return response.data?.count ?? 0; 46 + }; 47 + 48 + export const listNotifications = async ( 49 + cursor?: string, 50 + ): Promise<NotificationList> => { 51 + const response = await client.get( 52 + "/xrpc/app.rocksky.notification.listNotifications", 53 + { 54 + params: { limit: 30, ...(cursor ? { cursor } : {}) }, 55 + headers: authHeaders(), 56 + }, 57 + ); 58 + return response.data; 59 + }; 60 + 61 + /** Mark notifications as viewed. Omit `ids` to mark the whole inbox. */ 62 + export const markNotificationsSeen = async ( 63 + ids?: string[], 64 + ): Promise<number> => { 65 + const response = await client.post( 66 + "/xrpc/app.rocksky.notification.updateSeen", 67 + ids?.length ? { ids } : {}, 68 + { headers: authHeaders() }, 69 + ); 70 + return response.data?.unreadCount ?? 0; 71 + };
+80
apps/web/src/hooks/useNotifications.tsx
··· 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { useEffect } from "react"; 3 + import { 4 + getUnreadCount, 5 + listNotifications, 6 + markNotificationsSeen, 7 + } from "../api/notifications"; 8 + import { API_URL } from "../consts"; 9 + 10 + const UNREAD_KEY = ["notifications", "unreadCount"]; 11 + const LIST_KEY = ["notifications", "list"]; 12 + 13 + export const useUnreadCountQuery = () => 14 + useQuery({ 15 + queryKey: UNREAD_KEY, 16 + queryFn: getUnreadCount, 17 + enabled: !!localStorage.getItem("token"), 18 + }); 19 + 20 + export const useNotificationListQuery = (enabled: boolean) => 21 + useQuery({ 22 + queryKey: LIST_KEY, 23 + queryFn: () => listNotifications(), 24 + enabled, 25 + }); 26 + 27 + export const useMarkSeenMutation = () => { 28 + const queryClient = useQueryClient(); 29 + return useMutation({ 30 + mutationFn: (ids?: string[]) => markNotificationsSeen(ids), 31 + onSuccess: (unreadCount) => { 32 + queryClient.setQueryData(UNREAD_KEY, unreadCount); 33 + queryClient.invalidateQueries({ queryKey: LIST_KEY }); 34 + }, 35 + }); 36 + }; 37 + 38 + /** 39 + * Subscribes to the notifications SSE stream and keeps the react-query caches 40 + * in sync. The browser's EventSource auto-reconnects on transient errors. 41 + */ 42 + export const useNotificationStream = () => { 43 + const queryClient = useQueryClient(); 44 + 45 + useEffect(() => { 46 + const token = localStorage.getItem("token"); 47 + if (!token) return; 48 + 49 + const setCount = (n: number) => 50 + queryClient.setQueryData(UNREAD_KEY, n); 51 + 52 + const es = new EventSource( 53 + `${API_URL}/notifications/stream?token=${encodeURIComponent(token)}`, 54 + ); 55 + 56 + const onCount = (e: MessageEvent) => { 57 + try { 58 + setCount(JSON.parse(e.data).unreadCount); 59 + } catch { 60 + // ignore malformed payloads 61 + } 62 + }; 63 + 64 + es.addEventListener("unread", onCount as EventListener); 65 + es.addEventListener("seen", onCount as EventListener); 66 + es.addEventListener("notification", ((e: MessageEvent) => { 67 + try { 68 + const payload = JSON.parse(e.data); 69 + if (typeof payload.unreadCount === "number") { 70 + setCount(payload.unreadCount); 71 + } 72 + } catch { 73 + // ignore 74 + } 75 + queryClient.invalidateQueries({ queryKey: LIST_KEY }); 76 + }) as EventListener); 77 + 78 + return () => es.close(); 79 + }, [queryClient]); 80 + };
+3
apps/web/src/layouts/Navbar/Navbar.tsx
··· 24 24 import WebScrobblerScreenshot from "../../assets/webscrobbler.png"; 25 25 import { IconUser, IconMenu2, IconX } from "@tabler/icons-react"; 26 26 import { displayDrawerAtom } from "../../atoms/drawer"; 27 + import NotificationBell from "./NotificationBell"; 27 28 28 29 const Container = styled.div` 29 30 position: fixed; ··· 214 215 )} 215 216 </button> 216 217 </Menu> 218 + 219 + {profile && jwt && <NotificationBell />} 217 220 218 221 {profile && jwt && ( 219 222 <StatefulPopover
+223
apps/web/src/layouts/Navbar/NotificationBell.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { IconBell, IconUser } from "@tabler/icons-react"; 3 + import { Link } from "@tanstack/react-router"; 4 + import { Avatar } from "baseui/avatar"; 5 + import { PLACEMENT, Popover } from "baseui/popover"; 6 + import { useState } from "react"; 7 + import type { NotificationView } from "../../api/notifications"; 8 + import { 9 + useMarkSeenMutation, 10 + useNotificationListQuery, 11 + useNotificationStream, 12 + useUnreadCountQuery, 13 + } from "../../hooks/useNotifications"; 14 + 15 + const BellButton = styled.button` 16 + position: relative; 17 + margin-left: 15px; 18 + border: none; 19 + background: transparent; 20 + cursor: pointer; 21 + display: flex; 22 + align-items: center; 23 + justify-content: center; 24 + padding: 6px; 25 + `; 26 + 27 + const Badge = styled.span` 28 + position: absolute; 29 + top: 0; 30 + right: 0; 31 + min-width: 18px; 32 + height: 18px; 33 + padding: 0 5px; 34 + border-radius: 999px; 35 + background: #e0245e; 36 + color: #fff; 37 + font-size: 11px; 38 + font-weight: 700; 39 + line-height: 18px; 40 + text-align: center; 41 + box-sizing: border-box; 42 + `; 43 + 44 + const Dropdown = styled.div` 45 + width: 360px; 46 + max-height: 460px; 47 + overflow-y: auto; 48 + border: 1px solid var(--color-border); 49 + border-radius: 6px; 50 + background: var(--color-background); 51 + `; 52 + 53 + const DropdownHeader = styled.div` 54 + padding: 14px 16px; 55 + font-weight: 700; 56 + font-size: 16px; 57 + color: var(--color-text); 58 + border-bottom: 1px solid var(--color-border); 59 + `; 60 + 61 + const EmptyState = styled.div` 62 + padding: 32px 16px; 63 + text-align: center; 64 + color: var(--color-text-muted); 65 + `; 66 + 67 + const Row = styled(Link)` 68 + display: flex; 69 + align-items: flex-start; 70 + gap: 10px; 71 + padding: 12px 16px; 72 + text-decoration: none; 73 + border-bottom: 1px solid var(--color-border); 74 + 75 + &:hover { 76 + background: var(--color-menu-hover); 77 + } 78 + `; 79 + 80 + const RowText = styled.div` 81 + flex: 1; 82 + color: var(--color-text); 83 + font-size: 14px; 84 + `; 85 + 86 + const RowTime = styled.div` 87 + margin-top: 2px; 88 + color: var(--color-text-muted); 89 + font-size: 12px; 90 + `; 91 + 92 + const AvatarFallback = styled.div` 93 + width: 36px; 94 + height: 36px; 95 + border-radius: 999px; 96 + background: var(--color-avatar-background); 97 + display: flex; 98 + align-items: center; 99 + justify-content: center; 100 + flex-shrink: 0; 101 + `; 102 + 103 + const VERB: Record<string, string> = { 104 + like_scrobble: "liked your scrobble", 105 + follow: "followed you", 106 + comment_scrobble: "commented on your scrobble", 107 + comment_profile: "commented on your profile", 108 + reply: "replied to your comment", 109 + react_comment: "reacted to your comment", 110 + }; 111 + 112 + const timeAgo = (iso: string): string => { 113 + const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); 114 + if (seconds < 60) return "just now"; 115 + const minutes = Math.floor(seconds / 60); 116 + if (minutes < 60) return `${minutes}m`; 117 + const hours = Math.floor(minutes / 60); 118 + if (hours < 24) return `${hours}h`; 119 + const days = Math.floor(hours / 24); 120 + if (days < 7) return `${days}d`; 121 + return new Date(iso).toLocaleDateString(); 122 + }; 123 + 124 + function NotificationRow({ 125 + notification, 126 + onNavigate, 127 + }: { 128 + notification: NotificationView; 129 + onNavigate: () => void; 130 + }) { 131 + const actor = notification.actor; 132 + const name = actor?.displayName || actor?.handle || "Someone"; 133 + const verb = VERB[notification.type] ?? "sent you a notification"; 134 + const isJpegPlaceholder = actor?.avatar?.endsWith("/@jpeg"); 135 + 136 + return ( 137 + <Row 138 + to="/profile/$did" 139 + params={{ did: actor?.handle ?? actor?.did ?? "" }} 140 + onClick={onNavigate} 141 + > 142 + {actor?.avatar && !isJpegPlaceholder ? ( 143 + <Avatar src={actor.avatar} name={name} size="36px" /> 144 + ) : ( 145 + <AvatarFallback> 146 + <IconUser size={20} color="#fff" /> 147 + </AvatarFallback> 148 + )} 149 + <div style={{ flex: 1 }}> 150 + <RowText> 151 + <b>{name}</b> {verb} 152 + {notification.shoutContent ? `: "${notification.shoutContent}"` : ""} 153 + </RowText> 154 + <RowTime>{timeAgo(notification.createdAt)}</RowTime> 155 + </div> 156 + </Row> 157 + ); 158 + } 159 + 160 + function NotificationBell() { 161 + const [open, setOpen] = useState(false); 162 + useNotificationStream(); 163 + const { data: unreadCount = 0 } = useUnreadCountQuery(); 164 + const { data: list } = useNotificationListQuery(open); 165 + const markSeen = useMarkSeenMutation(); 166 + 167 + const handleOpen = () => { 168 + setOpen(true); 169 + if (unreadCount > 0) { 170 + markSeen.mutate(undefined); 171 + } 172 + }; 173 + 174 + return ( 175 + <Popover 176 + isOpen={open} 177 + onClickOutside={() => setOpen(false)} 178 + onEsc={() => setOpen(false)} 179 + placement={PLACEMENT.bottomRight} 180 + overrides={{ 181 + Body: { 182 + style: { 183 + zIndex: 2, 184 + backgroundColor: "var(--color-background)", 185 + }, 186 + }, 187 + Inner: { 188 + style: { 189 + backgroundColor: "var(--color-background)", 190 + }, 191 + }, 192 + }} 193 + content={() => ( 194 + <Dropdown> 195 + <DropdownHeader>Notifications</DropdownHeader> 196 + {!list || list.notifications.length === 0 ? ( 197 + <EmptyState>No notifications yet</EmptyState> 198 + ) : ( 199 + list.notifications.map((n) => ( 200 + <NotificationRow 201 + key={n.id} 202 + notification={n} 203 + onNavigate={() => setOpen(false)} 204 + /> 205 + )) 206 + )} 207 + </Dropdown> 208 + )} 209 + > 210 + <BellButton 211 + onClick={() => (open ? setOpen(false) : handleOpen())} 212 + aria-label="Notifications" 213 + > 214 + <IconBell size={24} color="var(--color-text)" /> 215 + {unreadCount > 0 && ( 216 + <Badge>{unreadCount > 99 ? "99+" : unreadCount}</Badge> 217 + )} 218 + </BellButton> 219 + </Popover> 220 + ); 221 + } 222 + 223 + export default NotificationBell;