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

Add ws app with JetStream WebSocket service

+2094
+3
apps/ws/.env.example
··· 1 + XATA_POSTGRES_URL="postgresql://postgres:mysecretpassword@localhost:5433/rocksky?sslmode=disable" 2 + ANALYTICS_URL="http://localhost:7879" 3 + JETSTREAM_SERVER=wss://jetstream1.us-west.bsky.network
+25
apps/ws/.zed/settings.json
··· 1 + { 2 + "lsp": { 3 + "deno": { 4 + "settings": { 5 + "deno": { 6 + "enable": true, 7 + "unstable": false, 8 + "lint": true, 9 + "cache": null 10 + } 11 + } 12 + } 13 + }, 14 + "languages": { 15 + "TypeScript": { 16 + "language_servers": ["deno", "!typescript-language-server"] 17 + }, 18 + "TSX": { 19 + "language_servers": ["deno", "!typescript-language-server"] 20 + }, 21 + "JavaScript": { 22 + "language_servers": ["deno", "!typescript-language-server"] 23 + } 24 + } 25 + }
+18
apps/ws/deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run --env-file=.env -A --watch src/main.ts" 4 + }, 5 + "imports": { 6 + "@atp/identity": "jsr:@atp/identity@^0.1.0-alpha.1", 7 + "@atp/sync": "jsr:@atp/sync@^0.1.0-alpha.4", 8 + "@logtape/logtape": "npm:@logtape/logtape@^1.3.5", 9 + "@std/assert": "jsr:@std/assert@1", 10 + "axios": "npm:axios@^1.13.2", 11 + "drizzle-kit": "npm:drizzle-kit@^0.31.8", 12 + "drizzle-orm": "npm:drizzle-orm@^0.45.1", 13 + "effect": "npm:effect@^3.19.13", 14 + "lodash": "npm:lodash@^4.17.21", 15 + "pg": "npm:pg@^8.16.3", 16 + "ramda": "npm:ramda@^0.32.0" 17 + } 18 + }
+844
apps/ws/deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@atp/bytes@~0.1.0-alpha.1": "0.1.0-alpha.1", 5 + "jsr:@atp/common@~0.1.0-alpha.4": "0.1.0-alpha.6", 6 + "jsr:@atp/common@~0.1.0-alpha.5": "0.1.0-alpha.6", 7 + "jsr:@atp/common@~0.1.0-alpha.6": "0.1.0-alpha.6", 8 + "jsr:@atp/crypto@~0.1.0-alpha.1": "0.1.0-alpha.2", 9 + "jsr:@atp/crypto@~0.1.0-alpha.2": "0.1.0-alpha.2", 10 + "jsr:@atp/identity@~0.1.0-alpha.1": "0.1.0-alpha.1", 11 + "jsr:@atp/lexicon@~0.1.0-alpha.2": "0.1.0-alpha.3", 12 + "jsr:@atp/lexicon@~0.1.0-alpha.3": "0.1.0-alpha.3", 13 + "jsr:@atp/repo@~0.1.0-alpha.2": "0.1.0-alpha.4", 14 + "jsr:@atp/sync@~0.1.0-alpha.4": "0.1.0-alpha.4", 15 + "jsr:@atp/syntax@~0.1.0-alpha.1": "0.1.0-alpha.2", 16 + "jsr:@atp/syntax@~0.1.0-alpha.2": "0.1.0-alpha.2", 17 + "jsr:@atp/xrpc-server@~0.1.0-alpha.2": "0.1.0-alpha.5", 18 + "jsr:@atp/xrpc@~0.1.0-alpha.3": "0.1.0-alpha.3", 19 + "jsr:@hono/hono@^4.10.8": "4.11.1", 20 + "jsr:@logtape/file@^1.2.2": "1.3.5", 21 + "jsr:@logtape/logtape@^1.2.2": "1.3.5", 22 + "jsr:@logtape/logtape@^1.3.5": "1.3.5", 23 + "jsr:@noble/curves@^2.0.1": "2.0.1", 24 + "jsr:@noble/hashes@2": "2.0.1", 25 + "jsr:@noble/hashes@^2.0.1": "2.0.1", 26 + "jsr:@std/assert@1": "1.0.16", 27 + "jsr:@std/assert@^1.0.16": "1.0.16", 28 + "jsr:@std/bytes@^1.0.6": "1.0.6", 29 + "jsr:@std/cbor@~0.1.9": "0.1.9", 30 + "jsr:@std/encoding@^1.0.10": "1.0.10", 31 + "jsr:@std/fs@^1.0.20": "1.0.20", 32 + "jsr:@std/internal@^1.0.12": "1.0.12", 33 + "jsr:@std/streams@^1.0.14": "1.0.14", 34 + "jsr:@zod/zod@^4.1.13": "4.1.13", 35 + "npm:@ipld/dag-cbor@^9.2.5": "9.2.5", 36 + "npm:@logtape/logtape@^1.3.5": "1.3.5", 37 + "npm:axios@^1.13.2": "1.13.2", 38 + "npm:drizzle-kit@~0.31.8": "0.31.8_esbuild@0.25.12", 39 + "npm:drizzle-orm@~0.45.1": "0.45.1_pg@8.16.3", 40 + "npm:effect@^3.19.13": "3.19.13", 41 + "npm:lodash@^4.17.21": "4.17.21", 42 + "npm:multiformats@^13.4.1": "13.4.2", 43 + "npm:p-queue@^8.1.1": "8.1.1", 44 + "npm:pg@^8.16.3": "8.16.3", 45 + "npm:ramda@0.32": "0.32.0", 46 + "npm:rate-limiter-flexible@9": "9.0.1" 47 + }, 48 + "jsr": { 49 + "@atp/bytes@0.1.0-alpha.1": { 50 + "integrity": "51ab3c11252f29265b9ac382b6b2f7023643fe74682114300f4efceee87cfd5c", 51 + "dependencies": [ 52 + "npm:multiformats" 53 + ] 54 + }, 55 + "@atp/common@0.1.0-alpha.6": { 56 + "integrity": "a27d967787c5036800a63c9b866ee5ee2ba839d5064ae038a9ed750a38d21685", 57 + "dependencies": [ 58 + "jsr:@atp/bytes", 59 + "jsr:@logtape/file", 60 + "jsr:@logtape/logtape@^1.2.2", 61 + "jsr:@std/cbor", 62 + "jsr:@std/encoding", 63 + "jsr:@std/fs", 64 + "jsr:@zod/zod", 65 + "npm:@ipld/dag-cbor", 66 + "npm:multiformats" 67 + ] 68 + }, 69 + "@atp/crypto@0.1.0-alpha.2": { 70 + "integrity": "594b0211ab82cc530bcd6f4a494ce192b0d5be60c01304d7096e030ef2baae11", 71 + "dependencies": [ 72 + "jsr:@atp/bytes", 73 + "jsr:@noble/curves", 74 + "jsr:@noble/hashes@^2.0.1" 75 + ] 76 + }, 77 + "@atp/identity@0.1.0-alpha.1": { 78 + "integrity": "a548f4715abeca8ed6f6f359cdaf277bf1a41cd455170d8e13a629bcf6351016", 79 + "dependencies": [ 80 + "jsr:@atp/common@~0.1.0-alpha.4", 81 + "jsr:@atp/crypto@~0.1.0-alpha.1" 82 + ] 83 + }, 84 + "@atp/lexicon@0.1.0-alpha.3": { 85 + "integrity": "5caf556fde5e2b3df66de07fc28603631aeb4e2d1699b544ed34e368b49ba61f", 86 + "dependencies": [ 87 + "jsr:@atp/common@~0.1.0-alpha.5", 88 + "jsr:@atp/syntax@~0.1.0-alpha.2", 89 + "jsr:@zod/zod", 90 + "npm:multiformats" 91 + ] 92 + }, 93 + "@atp/repo@0.1.0-alpha.4": { 94 + "integrity": "7cbe260d2305bd670b5db85bff6d77ce113cbd3f8f98c8597bd310ecf2bb1c15", 95 + "dependencies": [ 96 + "jsr:@atp/bytes", 97 + "jsr:@atp/common@~0.1.0-alpha.6", 98 + "jsr:@atp/crypto@~0.1.0-alpha.2", 99 + "jsr:@atp/lexicon@~0.1.0-alpha.3", 100 + "jsr:@std/encoding", 101 + "jsr:@zod/zod", 102 + "npm:@ipld/dag-cbor", 103 + "npm:multiformats" 104 + ] 105 + }, 106 + "@atp/sync@0.1.0-alpha.4": { 107 + "integrity": "9b6aa6ccc9447270843272e40bfcb26520eddaf37f98202bcbab6c0bee0a602b", 108 + "dependencies": [ 109 + "jsr:@atp/common@~0.1.0-alpha.4", 110 + "jsr:@atp/identity", 111 + "jsr:@atp/lexicon@~0.1.0-alpha.2", 112 + "jsr:@atp/repo", 113 + "jsr:@atp/syntax@~0.1.0-alpha.1", 114 + "jsr:@atp/xrpc-server", 115 + "npm:multiformats", 116 + "npm:p-queue" 117 + ] 118 + }, 119 + "@atp/syntax@0.1.0-alpha.2": { 120 + "integrity": "f7ab598b6b3c3b01dc446077b4c57acc1f1cb8a45f91bd3eb394997408a712a2" 121 + }, 122 + "@atp/xrpc@0.1.0-alpha.3": { 123 + "integrity": "315fe6ff02a1e41975e8716df2a389ab117223430b112e009d07176bafe4ccfc", 124 + "dependencies": [ 125 + "jsr:@atp/lexicon@~0.1.0-alpha.3", 126 + "jsr:@zod/zod" 127 + ] 128 + }, 129 + "@atp/xrpc-server@0.1.0-alpha.5": { 130 + "integrity": "971d92b419dead72038cf3bceecbf19578d7a7eb0c05c17f0d7cc5a740d9ca39", 131 + "dependencies": [ 132 + "jsr:@atp/bytes", 133 + "jsr:@atp/common@~0.1.0-alpha.6", 134 + "jsr:@atp/crypto@~0.1.0-alpha.2", 135 + "jsr:@atp/lexicon@~0.1.0-alpha.3", 136 + "jsr:@atp/xrpc", 137 + "jsr:@hono/hono", 138 + "jsr:@std/assert@^1.0.16", 139 + "jsr:@zod/zod", 140 + "npm:rate-limiter-flexible" 141 + ] 142 + }, 143 + "@hono/hono@4.11.1": { 144 + "integrity": "28bb28c7322a1379e7132af05ef76aef09f86bb8cdd7077c67e004dea288ac0d" 145 + }, 146 + "@logtape/file@1.3.5": { 147 + "integrity": "6e5248e873e260109267b79bd3fb19f307a664a2233c5ae6f699d697549db985", 148 + "dependencies": [ 149 + "jsr:@logtape/logtape@^1.3.5" 150 + ] 151 + }, 152 + "@logtape/logtape@1.3.5": { 153 + "integrity": "a5cdb130daf1a9d384006b0f850cc4443bfc2e163dadc6fa667875e79770beb3" 154 + }, 155 + "@noble/curves@2.0.1": { 156 + "integrity": "21ef41d207a203f60ba37a4fdcbc4f4a545b10c5dab7f293889f18292f81ab23", 157 + "dependencies": [ 158 + "jsr:@noble/hashes@2" 159 + ] 160 + }, 161 + "@noble/hashes@2.0.1": { 162 + "integrity": "e0e908292a0bf91099cf8ba0720a1647cef82ab38b588815b5e9535b4ff4d7bb" 163 + }, 164 + "@std/assert@1.0.16": { 165 + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", 166 + "dependencies": [ 167 + "jsr:@std/internal" 168 + ] 169 + }, 170 + "@std/bytes@1.0.6": { 171 + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 172 + }, 173 + "@std/cbor@0.1.9": { 174 + "integrity": "fe1f61f445a34c8f97973b58fecbfb24e48fcc88df7b1253d9b6fe5d2ea16936", 175 + "dependencies": [ 176 + "jsr:@std/bytes", 177 + "jsr:@std/streams" 178 + ] 179 + }, 180 + "@std/encoding@1.0.10": { 181 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 182 + }, 183 + "@std/fs@1.0.20": { 184 + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187" 185 + }, 186 + "@std/internal@1.0.12": { 187 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 188 + }, 189 + "@std/streams@1.0.14": { 190 + "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411" 191 + }, 192 + "@zod/zod@4.1.13": { 193 + "integrity": "fef799152d630583b248645fcac03abedd13e39fd2b752d9466b905d73619bfd" 194 + } 195 + }, 196 + "npm": { 197 + "@drizzle-team/brocli@0.10.2": { 198 + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==" 199 + }, 200 + "@esbuild-kit/core-utils@3.3.2": { 201 + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", 202 + "dependencies": [ 203 + "esbuild@0.18.20", 204 + "source-map-support" 205 + ], 206 + "deprecated": true 207 + }, 208 + "@esbuild-kit/esm-loader@2.6.5": { 209 + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", 210 + "dependencies": [ 211 + "@esbuild-kit/core-utils", 212 + "get-tsconfig" 213 + ], 214 + "deprecated": true 215 + }, 216 + "@esbuild/aix-ppc64@0.25.12": { 217 + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", 218 + "os": ["aix"], 219 + "cpu": ["ppc64"] 220 + }, 221 + "@esbuild/android-arm64@0.18.20": { 222 + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", 223 + "os": ["android"], 224 + "cpu": ["arm64"] 225 + }, 226 + "@esbuild/android-arm64@0.25.12": { 227 + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", 228 + "os": ["android"], 229 + "cpu": ["arm64"] 230 + }, 231 + "@esbuild/android-arm@0.18.20": { 232 + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", 233 + "os": ["android"], 234 + "cpu": ["arm"] 235 + }, 236 + "@esbuild/android-arm@0.25.12": { 237 + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", 238 + "os": ["android"], 239 + "cpu": ["arm"] 240 + }, 241 + "@esbuild/android-x64@0.18.20": { 242 + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", 243 + "os": ["android"], 244 + "cpu": ["x64"] 245 + }, 246 + "@esbuild/android-x64@0.25.12": { 247 + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", 248 + "os": ["android"], 249 + "cpu": ["x64"] 250 + }, 251 + "@esbuild/darwin-arm64@0.18.20": { 252 + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", 253 + "os": ["darwin"], 254 + "cpu": ["arm64"] 255 + }, 256 + "@esbuild/darwin-arm64@0.25.12": { 257 + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", 258 + "os": ["darwin"], 259 + "cpu": ["arm64"] 260 + }, 261 + "@esbuild/darwin-x64@0.18.20": { 262 + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", 263 + "os": ["darwin"], 264 + "cpu": ["x64"] 265 + }, 266 + "@esbuild/darwin-x64@0.25.12": { 267 + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", 268 + "os": ["darwin"], 269 + "cpu": ["x64"] 270 + }, 271 + "@esbuild/freebsd-arm64@0.18.20": { 272 + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", 273 + "os": ["freebsd"], 274 + "cpu": ["arm64"] 275 + }, 276 + "@esbuild/freebsd-arm64@0.25.12": { 277 + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", 278 + "os": ["freebsd"], 279 + "cpu": ["arm64"] 280 + }, 281 + "@esbuild/freebsd-x64@0.18.20": { 282 + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", 283 + "os": ["freebsd"], 284 + "cpu": ["x64"] 285 + }, 286 + "@esbuild/freebsd-x64@0.25.12": { 287 + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", 288 + "os": ["freebsd"], 289 + "cpu": ["x64"] 290 + }, 291 + "@esbuild/linux-arm64@0.18.20": { 292 + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", 293 + "os": ["linux"], 294 + "cpu": ["arm64"] 295 + }, 296 + "@esbuild/linux-arm64@0.25.12": { 297 + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", 298 + "os": ["linux"], 299 + "cpu": ["arm64"] 300 + }, 301 + "@esbuild/linux-arm@0.18.20": { 302 + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", 303 + "os": ["linux"], 304 + "cpu": ["arm"] 305 + }, 306 + "@esbuild/linux-arm@0.25.12": { 307 + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", 308 + "os": ["linux"], 309 + "cpu": ["arm"] 310 + }, 311 + "@esbuild/linux-ia32@0.18.20": { 312 + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", 313 + "os": ["linux"], 314 + "cpu": ["ia32"] 315 + }, 316 + "@esbuild/linux-ia32@0.25.12": { 317 + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", 318 + "os": ["linux"], 319 + "cpu": ["ia32"] 320 + }, 321 + "@esbuild/linux-loong64@0.18.20": { 322 + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", 323 + "os": ["linux"], 324 + "cpu": ["loong64"] 325 + }, 326 + "@esbuild/linux-loong64@0.25.12": { 327 + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", 328 + "os": ["linux"], 329 + "cpu": ["loong64"] 330 + }, 331 + "@esbuild/linux-mips64el@0.18.20": { 332 + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", 333 + "os": ["linux"], 334 + "cpu": ["mips64el"] 335 + }, 336 + "@esbuild/linux-mips64el@0.25.12": { 337 + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", 338 + "os": ["linux"], 339 + "cpu": ["mips64el"] 340 + }, 341 + "@esbuild/linux-ppc64@0.18.20": { 342 + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", 343 + "os": ["linux"], 344 + "cpu": ["ppc64"] 345 + }, 346 + "@esbuild/linux-ppc64@0.25.12": { 347 + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", 348 + "os": ["linux"], 349 + "cpu": ["ppc64"] 350 + }, 351 + "@esbuild/linux-riscv64@0.18.20": { 352 + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", 353 + "os": ["linux"], 354 + "cpu": ["riscv64"] 355 + }, 356 + "@esbuild/linux-riscv64@0.25.12": { 357 + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", 358 + "os": ["linux"], 359 + "cpu": ["riscv64"] 360 + }, 361 + "@esbuild/linux-s390x@0.18.20": { 362 + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", 363 + "os": ["linux"], 364 + "cpu": ["s390x"] 365 + }, 366 + "@esbuild/linux-s390x@0.25.12": { 367 + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", 368 + "os": ["linux"], 369 + "cpu": ["s390x"] 370 + }, 371 + "@esbuild/linux-x64@0.18.20": { 372 + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", 373 + "os": ["linux"], 374 + "cpu": ["x64"] 375 + }, 376 + "@esbuild/linux-x64@0.25.12": { 377 + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", 378 + "os": ["linux"], 379 + "cpu": ["x64"] 380 + }, 381 + "@esbuild/netbsd-arm64@0.25.12": { 382 + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", 383 + "os": ["netbsd"], 384 + "cpu": ["arm64"] 385 + }, 386 + "@esbuild/netbsd-x64@0.18.20": { 387 + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", 388 + "os": ["netbsd"], 389 + "cpu": ["x64"] 390 + }, 391 + "@esbuild/netbsd-x64@0.25.12": { 392 + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", 393 + "os": ["netbsd"], 394 + "cpu": ["x64"] 395 + }, 396 + "@esbuild/openbsd-arm64@0.25.12": { 397 + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", 398 + "os": ["openbsd"], 399 + "cpu": ["arm64"] 400 + }, 401 + "@esbuild/openbsd-x64@0.18.20": { 402 + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", 403 + "os": ["openbsd"], 404 + "cpu": ["x64"] 405 + }, 406 + "@esbuild/openbsd-x64@0.25.12": { 407 + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", 408 + "os": ["openbsd"], 409 + "cpu": ["x64"] 410 + }, 411 + "@esbuild/openharmony-arm64@0.25.12": { 412 + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", 413 + "os": ["openharmony"], 414 + "cpu": ["arm64"] 415 + }, 416 + "@esbuild/sunos-x64@0.18.20": { 417 + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", 418 + "os": ["sunos"], 419 + "cpu": ["x64"] 420 + }, 421 + "@esbuild/sunos-x64@0.25.12": { 422 + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", 423 + "os": ["sunos"], 424 + "cpu": ["x64"] 425 + }, 426 + "@esbuild/win32-arm64@0.18.20": { 427 + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", 428 + "os": ["win32"], 429 + "cpu": ["arm64"] 430 + }, 431 + "@esbuild/win32-arm64@0.25.12": { 432 + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", 433 + "os": ["win32"], 434 + "cpu": ["arm64"] 435 + }, 436 + "@esbuild/win32-ia32@0.18.20": { 437 + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", 438 + "os": ["win32"], 439 + "cpu": ["ia32"] 440 + }, 441 + "@esbuild/win32-ia32@0.25.12": { 442 + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", 443 + "os": ["win32"], 444 + "cpu": ["ia32"] 445 + }, 446 + "@esbuild/win32-x64@0.18.20": { 447 + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", 448 + "os": ["win32"], 449 + "cpu": ["x64"] 450 + }, 451 + "@esbuild/win32-x64@0.25.12": { 452 + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", 453 + "os": ["win32"], 454 + "cpu": ["x64"] 455 + }, 456 + "@ipld/dag-cbor@9.2.5": { 457 + "integrity": "sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==", 458 + "dependencies": [ 459 + "cborg", 460 + "multiformats" 461 + ] 462 + }, 463 + "@logtape/logtape@1.3.5": { 464 + "integrity": "sha512-G+MxWB7Tbv/2764519+Cp6rKXUdRbe/GiRwTvlm/Wv/sNsiquRnx9Hzr9eXaIpAYLT4PrBlkthjJ4gmqdSPrFg==" 465 + }, 466 + "@standard-schema/spec@1.1.0": { 467 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 468 + }, 469 + "asynckit@0.4.0": { 470 + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 471 + }, 472 + "axios@1.13.2": { 473 + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", 474 + "dependencies": [ 475 + "follow-redirects", 476 + "form-data", 477 + "proxy-from-env" 478 + ] 479 + }, 480 + "buffer-from@1.1.2": { 481 + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" 482 + }, 483 + "call-bind-apply-helpers@1.0.2": { 484 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 485 + "dependencies": [ 486 + "es-errors", 487 + "function-bind" 488 + ] 489 + }, 490 + "cborg@4.3.2": { 491 + "integrity": "sha512-l+QzebEAG0vb09YKkaOrMi2zmm80UNjmbvocMIeW5hO7JOXWdrQ/H49yOKfYX0MBgrj/KWgatBnEgRXyNyKD+A==", 492 + "bin": true 493 + }, 494 + "combined-stream@1.0.8": { 495 + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 496 + "dependencies": [ 497 + "delayed-stream" 498 + ] 499 + }, 500 + "debug@4.4.3": { 501 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 502 + "dependencies": [ 503 + "ms" 504 + ] 505 + }, 506 + "delayed-stream@1.0.0": { 507 + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 508 + }, 509 + "drizzle-kit@0.31.8_esbuild@0.25.12": { 510 + "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", 511 + "dependencies": [ 512 + "@drizzle-team/brocli", 513 + "@esbuild-kit/esm-loader", 514 + "esbuild@0.25.12", 515 + "esbuild-register" 516 + ], 517 + "bin": true 518 + }, 519 + "drizzle-orm@0.45.1_pg@8.16.3": { 520 + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", 521 + "dependencies": [ 522 + "pg" 523 + ], 524 + "optionalPeers": [ 525 + "pg" 526 + ] 527 + }, 528 + "dunder-proto@1.0.1": { 529 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 530 + "dependencies": [ 531 + "call-bind-apply-helpers", 532 + "es-errors", 533 + "gopd" 534 + ] 535 + }, 536 + "effect@3.19.13": { 537 + "integrity": "sha512-8MZ783YuHRwHZX2Mmm+bpGxq+7XPd88sWwYAz2Ysry80sEKpftDZXs2Hg9ZyjESi1IBTNHF0oDKe0zJRkUlyew==", 538 + "dependencies": [ 539 + "@standard-schema/spec", 540 + "fast-check" 541 + ] 542 + }, 543 + "es-define-property@1.0.1": { 544 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 545 + }, 546 + "es-errors@1.3.0": { 547 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 548 + }, 549 + "es-object-atoms@1.1.1": { 550 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 551 + "dependencies": [ 552 + "es-errors" 553 + ] 554 + }, 555 + "es-set-tostringtag@2.1.0": { 556 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 557 + "dependencies": [ 558 + "es-errors", 559 + "get-intrinsic", 560 + "has-tostringtag", 561 + "hasown" 562 + ] 563 + }, 564 + "esbuild-register@3.6.0_esbuild@0.25.12": { 565 + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", 566 + "dependencies": [ 567 + "debug", 568 + "esbuild@0.25.12" 569 + ] 570 + }, 571 + "esbuild@0.18.20": { 572 + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", 573 + "optionalDependencies": [ 574 + "@esbuild/android-arm@0.18.20", 575 + "@esbuild/android-arm64@0.18.20", 576 + "@esbuild/android-x64@0.18.20", 577 + "@esbuild/darwin-arm64@0.18.20", 578 + "@esbuild/darwin-x64@0.18.20", 579 + "@esbuild/freebsd-arm64@0.18.20", 580 + "@esbuild/freebsd-x64@0.18.20", 581 + "@esbuild/linux-arm@0.18.20", 582 + "@esbuild/linux-arm64@0.18.20", 583 + "@esbuild/linux-ia32@0.18.20", 584 + "@esbuild/linux-loong64@0.18.20", 585 + "@esbuild/linux-mips64el@0.18.20", 586 + "@esbuild/linux-ppc64@0.18.20", 587 + "@esbuild/linux-riscv64@0.18.20", 588 + "@esbuild/linux-s390x@0.18.20", 589 + "@esbuild/linux-x64@0.18.20", 590 + "@esbuild/netbsd-x64@0.18.20", 591 + "@esbuild/openbsd-x64@0.18.20", 592 + "@esbuild/sunos-x64@0.18.20", 593 + "@esbuild/win32-arm64@0.18.20", 594 + "@esbuild/win32-ia32@0.18.20", 595 + "@esbuild/win32-x64@0.18.20" 596 + ], 597 + "scripts": true, 598 + "bin": true 599 + }, 600 + "esbuild@0.25.12": { 601 + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", 602 + "optionalDependencies": [ 603 + "@esbuild/aix-ppc64", 604 + "@esbuild/android-arm@0.25.12", 605 + "@esbuild/android-arm64@0.25.12", 606 + "@esbuild/android-x64@0.25.12", 607 + "@esbuild/darwin-arm64@0.25.12", 608 + "@esbuild/darwin-x64@0.25.12", 609 + "@esbuild/freebsd-arm64@0.25.12", 610 + "@esbuild/freebsd-x64@0.25.12", 611 + "@esbuild/linux-arm@0.25.12", 612 + "@esbuild/linux-arm64@0.25.12", 613 + "@esbuild/linux-ia32@0.25.12", 614 + "@esbuild/linux-loong64@0.25.12", 615 + "@esbuild/linux-mips64el@0.25.12", 616 + "@esbuild/linux-ppc64@0.25.12", 617 + "@esbuild/linux-riscv64@0.25.12", 618 + "@esbuild/linux-s390x@0.25.12", 619 + "@esbuild/linux-x64@0.25.12", 620 + "@esbuild/netbsd-arm64", 621 + "@esbuild/netbsd-x64@0.25.12", 622 + "@esbuild/openbsd-arm64", 623 + "@esbuild/openbsd-x64@0.25.12", 624 + "@esbuild/openharmony-arm64", 625 + "@esbuild/sunos-x64@0.25.12", 626 + "@esbuild/win32-arm64@0.25.12", 627 + "@esbuild/win32-ia32@0.25.12", 628 + "@esbuild/win32-x64@0.25.12" 629 + ], 630 + "scripts": true, 631 + "bin": true 632 + }, 633 + "eventemitter3@5.0.1": { 634 + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" 635 + }, 636 + "fast-check@3.23.2": { 637 + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", 638 + "dependencies": [ 639 + "pure-rand" 640 + ] 641 + }, 642 + "follow-redirects@1.15.11": { 643 + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" 644 + }, 645 + "form-data@4.0.5": { 646 + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", 647 + "dependencies": [ 648 + "asynckit", 649 + "combined-stream", 650 + "es-set-tostringtag", 651 + "hasown", 652 + "mime-types" 653 + ] 654 + }, 655 + "function-bind@1.1.2": { 656 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 657 + }, 658 + "get-intrinsic@1.3.0": { 659 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 660 + "dependencies": [ 661 + "call-bind-apply-helpers", 662 + "es-define-property", 663 + "es-errors", 664 + "es-object-atoms", 665 + "function-bind", 666 + "get-proto", 667 + "gopd", 668 + "has-symbols", 669 + "hasown", 670 + "math-intrinsics" 671 + ] 672 + }, 673 + "get-proto@1.0.1": { 674 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 675 + "dependencies": [ 676 + "dunder-proto", 677 + "es-object-atoms" 678 + ] 679 + }, 680 + "get-tsconfig@4.13.0": { 681 + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", 682 + "dependencies": [ 683 + "resolve-pkg-maps" 684 + ] 685 + }, 686 + "gopd@1.2.0": { 687 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 688 + }, 689 + "has-symbols@1.1.0": { 690 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 691 + }, 692 + "has-tostringtag@1.0.2": { 693 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 694 + "dependencies": [ 695 + "has-symbols" 696 + ] 697 + }, 698 + "hasown@2.0.2": { 699 + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 700 + "dependencies": [ 701 + "function-bind" 702 + ] 703 + }, 704 + "lodash@4.17.21": { 705 + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 706 + }, 707 + "math-intrinsics@1.1.0": { 708 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 709 + }, 710 + "mime-db@1.52.0": { 711 + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 712 + }, 713 + "mime-types@2.1.35": { 714 + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 715 + "dependencies": [ 716 + "mime-db" 717 + ] 718 + }, 719 + "ms@2.1.3": { 720 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 721 + }, 722 + "multiformats@13.4.2": { 723 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" 724 + }, 725 + "p-queue@8.1.1": { 726 + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", 727 + "dependencies": [ 728 + "eventemitter3", 729 + "p-timeout" 730 + ] 731 + }, 732 + "p-timeout@6.1.4": { 733 + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==" 734 + }, 735 + "pg-cloudflare@1.2.7": { 736 + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==" 737 + }, 738 + "pg-connection-string@2.9.1": { 739 + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" 740 + }, 741 + "pg-int8@1.0.1": { 742 + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" 743 + }, 744 + "pg-pool@3.10.1_pg@8.16.3": { 745 + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", 746 + "dependencies": [ 747 + "pg" 748 + ] 749 + }, 750 + "pg-protocol@1.10.3": { 751 + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" 752 + }, 753 + "pg-types@2.2.0": { 754 + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", 755 + "dependencies": [ 756 + "pg-int8", 757 + "postgres-array", 758 + "postgres-bytea", 759 + "postgres-date", 760 + "postgres-interval" 761 + ] 762 + }, 763 + "pg@8.16.3": { 764 + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", 765 + "dependencies": [ 766 + "pg-connection-string", 767 + "pg-pool", 768 + "pg-protocol", 769 + "pg-types", 770 + "pgpass" 771 + ], 772 + "optionalDependencies": [ 773 + "pg-cloudflare" 774 + ] 775 + }, 776 + "pgpass@1.0.5": { 777 + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", 778 + "dependencies": [ 779 + "split2" 780 + ] 781 + }, 782 + "postgres-array@2.0.0": { 783 + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" 784 + }, 785 + "postgres-bytea@1.0.1": { 786 + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" 787 + }, 788 + "postgres-date@1.0.7": { 789 + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" 790 + }, 791 + "postgres-interval@1.2.0": { 792 + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", 793 + "dependencies": [ 794 + "xtend" 795 + ] 796 + }, 797 + "proxy-from-env@1.1.0": { 798 + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 799 + }, 800 + "pure-rand@6.1.0": { 801 + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 802 + }, 803 + "ramda@0.32.0": { 804 + "integrity": "sha512-GQWAHhxhxWBWA8oIBr1XahFVjQ9Fic6MK9ikijfd4TZHfE2+urfk+irVlR5VOn48uwMgM+loRRBJd6Yjsbc0zQ==" 805 + }, 806 + "rate-limiter-flexible@9.0.1": { 807 + "integrity": "sha512-sO+QdoGPCxroi4VkO2FIVjfUGuexhRkBc9ROHqu5eVEEz+oPHzQqvCc25ajFfMUBosbNGb6qpNa8xmxH9YNZsg==" 808 + }, 809 + "resolve-pkg-maps@1.0.0": { 810 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" 811 + }, 812 + "source-map-support@0.5.21": { 813 + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 814 + "dependencies": [ 815 + "buffer-from", 816 + "source-map" 817 + ] 818 + }, 819 + "source-map@0.6.1": { 820 + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" 821 + }, 822 + "split2@4.2.0": { 823 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" 824 + }, 825 + "xtend@4.0.2": { 826 + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" 827 + } 828 + }, 829 + "workspace": { 830 + "dependencies": [ 831 + "jsr:@atp/identity@~0.1.0-alpha.1", 832 + "jsr:@atp/sync@~0.1.0-alpha.4", 833 + "jsr:@std/assert@1", 834 + "npm:@logtape/logtape@^1.3.5", 835 + "npm:axios@^1.13.2", 836 + "npm:drizzle-kit@~0.31.8", 837 + "npm:drizzle-orm@~0.45.1", 838 + "npm:effect@^3.19.13", 839 + "npm:lodash@^4.17.21", 840 + "npm:pg@^8.16.3", 841 + "npm:ramda@0.32" 842 + ] 843 + } 844 + }
+9
apps/ws/drizzle.config.ts
··· 1 + import { defineConfig } from "drizzle-kit"; 2 + 3 + export default defineConfig({ 4 + dialect: "postgresql", 5 + schema: "./src/schema", 6 + dbCredentials: { 7 + url: Deno.env.get("XATA_POSTGRES_URL")!, 8 + }, 9 + });
+9
apps/ws/src/context.ts
··· 1 + import drizzle from "./drizzle.ts"; 2 + import axios from "axios"; 3 + 4 + export const ctx = { 5 + db: drizzle.db, 6 + analytics: axios.create({ baseURL: Deno.env.get("ANALYTICS_URL") }), 7 + }; 8 + 9 + export type Context = typeof ctx;
+10
apps/ws/src/drizzle.ts
··· 1 + import { drizzle } from "drizzle-orm/node-postgres"; 2 + import pg from "pg"; 3 + 4 + const pool = new pg.Pool({ 5 + connectionString: Deno.env.get("XATA_POSTGRES_URL"), 6 + max: 20, 7 + }); 8 + const db = drizzle(pool); 9 + 10 + export default { db };
+285
apps/ws/src/jetstream.ts
··· 1 + export interface JetStreamEvent { 2 + did: string; 3 + time_us: number; 4 + kind: "commit" | "identity" | "account"; 5 + commit?: { 6 + rev: string; 7 + operation: "create" | "update" | "delete"; 8 + collection: string; 9 + rkey: string; 10 + record?: Record<string, unknown>; 11 + cid?: string; 12 + }; 13 + identity?: { 14 + did: string; 15 + handle?: string; 16 + seq?: number; 17 + time?: string; 18 + }; 19 + account?: { 20 + active: boolean; 21 + did: string; 22 + seq: number; 23 + time: string; 24 + }; 25 + } 26 + 27 + export interface JetStreamClientOptions { 28 + endpoint?: string; 29 + wantedCollections?: string[]; 30 + wantedDids?: string[]; 31 + maxReconnectAttempts?: number; 32 + reconnectDelay?: number; 33 + maxReconnectDelay?: number; 34 + backoffMultiplier?: number; 35 + debug?: boolean; 36 + } 37 + 38 + export type JetStreamEventType = 39 + | "open" 40 + | "message" 41 + | "error" 42 + | "close" 43 + | "reconnect"; 44 + 45 + export class JetStreamClient { 46 + private ws: WebSocket | null = null; 47 + private options: Required<JetStreamClientOptions>; 48 + private reconnectAttempts = 0; 49 + private reconnectTimer: number | null = null; 50 + private isManualClose = false; 51 + private eventHandlers: Map< 52 + JetStreamEventType, 53 + Set<(data?: unknown) => void> 54 + > = new Map(); 55 + private cursor: number | null = null; 56 + 57 + constructor(options: JetStreamClientOptions = {}) { 58 + this.options = { 59 + endpoint: 60 + options.endpoint || "wss://jetstream1.us-east.bsky.network/subscribe", 61 + wantedCollections: options.wantedCollections || [], 62 + wantedDids: options.wantedDids || [], 63 + maxReconnectAttempts: options.maxReconnectAttempts ?? Infinity, 64 + reconnectDelay: options.reconnectDelay ?? 1000, 65 + maxReconnectDelay: options.maxReconnectDelay ?? 30000, 66 + backoffMultiplier: options.backoffMultiplier ?? 1.5, 67 + debug: options.debug ?? false, 68 + }; 69 + 70 + // Initialize event handler sets 71 + ["open", "message", "error", "close", "reconnect"].forEach((event) => { 72 + this.eventHandlers.set(event as JetStreamEventType, new Set()); 73 + }); 74 + } 75 + 76 + /** 77 + * Register an event handler 78 + */ 79 + on(event: JetStreamEventType, handler: (data?: unknown) => void): this { 80 + this.eventHandlers.get(event)?.add(handler); 81 + return this; 82 + } 83 + 84 + /** 85 + * Remove an event handler 86 + */ 87 + off(event: JetStreamEventType, handler: (data?: unknown) => void): this { 88 + this.eventHandlers.get(event)?.delete(handler); 89 + return this; 90 + } 91 + 92 + /** 93 + * Emit an event to all registered handlers 94 + */ 95 + private emit(event: JetStreamEventType, data?: unknown): void { 96 + this.eventHandlers.get(event)?.forEach((handler) => { 97 + try { 98 + handler(data); 99 + } catch (error) { 100 + this.log("error", `Handler error for ${event}:`, error); 101 + } 102 + }); 103 + } 104 + 105 + /** 106 + * Build the WebSocket URL with query parameters 107 + */ 108 + private buildUrl(): string { 109 + const url = new URL(this.options.endpoint); 110 + 111 + if (this.options.wantedCollections.length > 0) { 112 + this.options.wantedCollections.forEach((collection) => { 113 + url.searchParams.append("wantedCollections", collection); 114 + }); 115 + } 116 + 117 + if (this.options.wantedDids.length > 0) { 118 + this.options.wantedDids.forEach((did) => { 119 + url.searchParams.append("wantedDids", did); 120 + }); 121 + } 122 + 123 + if (this.cursor !== null) { 124 + url.searchParams.set("cursor", this.cursor.toString()); 125 + } 126 + 127 + return url.toString(); 128 + } 129 + 130 + /** 131 + * Connect to the JetStream WebSocket 132 + */ 133 + connect(): void { 134 + if (this.ws && this.ws.readyState === WebSocket.OPEN) { 135 + this.log("warn", "Already connected"); 136 + return; 137 + } 138 + 139 + this.isManualClose = false; 140 + const url = this.buildUrl(); 141 + this.log("info", `Connecting to ${url}`); 142 + 143 + try { 144 + this.ws = new WebSocket(url); 145 + 146 + this.ws.onopen = () => { 147 + this.log("info", "Connected successfully"); 148 + this.reconnectAttempts = 0; 149 + this.emit("open"); 150 + }; 151 + 152 + this.ws.onmessage = (event) => { 153 + try { 154 + const data = JSON.parse(event.data) as JetStreamEvent; 155 + 156 + // Update cursor for resumption 157 + if (data.time_us) { 158 + this.cursor = data.time_us; 159 + } 160 + 161 + this.emit("message", data); 162 + } catch (error) { 163 + this.log("error", "Failed to parse message:", error); 164 + this.emit("error", { type: "parse_error", error }); 165 + } 166 + }; 167 + 168 + this.ws.onerror = (event) => { 169 + this.log("error", "WebSocket error:", event); 170 + this.emit("error", event); 171 + }; 172 + 173 + this.ws.onclose = (event) => { 174 + this.log("info", `Connection closed: ${event.code} ${event.reason}`); 175 + this.emit("close", event); 176 + 177 + if (!this.isManualClose) { 178 + this.scheduleReconnect(); 179 + } 180 + }; 181 + } catch (error) { 182 + this.log("error", "Failed to create WebSocket:", error); 183 + this.emit("error", { type: "connection_error", error }); 184 + this.scheduleReconnect(); 185 + } 186 + } 187 + 188 + /** 189 + * Schedule a reconnection attempt with exponential backoff 190 + */ 191 + private scheduleReconnect(): void { 192 + if (this.reconnectAttempts >= this.options.maxReconnectAttempts) { 193 + this.log("error", "Max reconnection attempts reached"); 194 + return; 195 + } 196 + 197 + const delay = Math.min( 198 + this.options.reconnectDelay * 199 + Math.pow(this.options.backoffMultiplier, this.reconnectAttempts), 200 + this.options.maxReconnectDelay, 201 + ); 202 + 203 + this.reconnectAttempts++; 204 + this.log( 205 + "info", 206 + `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`, 207 + ); 208 + 209 + this.reconnectTimer = setTimeout(() => { 210 + this.emit("reconnect", { attempt: this.reconnectAttempts }); 211 + this.connect(); 212 + }, delay); 213 + } 214 + 215 + /** 216 + * Manually disconnect from the WebSocket 217 + */ 218 + disconnect(): void { 219 + this.isManualClose = true; 220 + 221 + if (this.reconnectTimer !== null) { 222 + clearTimeout(this.reconnectTimer); 223 + this.reconnectTimer = null; 224 + } 225 + 226 + if (this.ws) { 227 + this.ws.close(); 228 + this.ws = null; 229 + } 230 + 231 + this.log("info", "Disconnected"); 232 + } 233 + 234 + /** 235 + * Update subscription filters (requires reconnection) 236 + */ 237 + updateFilters(options: { 238 + wantedCollections?: string[]; 239 + wantedDids?: string[]; 240 + }): void { 241 + if (options.wantedCollections) { 242 + this.options.wantedCollections = options.wantedCollections; 243 + } 244 + if (options.wantedDids) { 245 + this.options.wantedDids = options.wantedDids; 246 + } 247 + 248 + // Reconnect with new filters 249 + if (this.ws) { 250 + this.disconnect(); 251 + this.connect(); 252 + } 253 + } 254 + 255 + /** 256 + * Get current connection state 257 + */ 258 + get readyState(): number { 259 + return this.ws?.readyState ?? WebSocket.CLOSED; 260 + } 261 + 262 + /** 263 + * Check if currently connected 264 + */ 265 + get isConnected(): boolean { 266 + return this.ws?.readyState === WebSocket.OPEN; 267 + } 268 + 269 + /** 270 + * Get current cursor position 271 + */ 272 + get currentCursor(): number | null { 273 + return this.cursor; 274 + } 275 + 276 + /** 277 + * Logging utility 278 + */ 279 + private log(level: "info" | "warn" | "error", ...args: unknown[]): void { 280 + if (this.options.debug || level === "error") { 281 + const prefix = `[JetStream ${level.toUpperCase()}]`; 282 + console[level](prefix, ...args); 283 + } 284 + } 285 + }
+39
apps/ws/src/lib/deepCamelKeys.ts
··· 1 + import _ from "lodash"; 2 + import * as R from "ramda"; 3 + 4 + type AnyObject = Record<string, any>; 5 + 6 + const isObject = (val: unknown): val is AnyObject => 7 + typeof val === "object" && val !== null && !Array.isArray(val); 8 + 9 + export const deepCamelCaseKeys = <T>(obj: T): any => { 10 + if (Array.isArray(obj)) { 11 + return obj.map(deepCamelCaseKeys); 12 + } else if (isObject(obj)) { 13 + return R.pipe( 14 + R.toPairs, 15 + R.map( 16 + ([key, value]) => 17 + [_.camelCase(String(key)), deepCamelCaseKeys(value)] as [string, any], 18 + ), 19 + R.fromPairs, 20 + )(obj as object); 21 + } 22 + return obj; 23 + }; 24 + 25 + export const deepSnakeCaseKeys = <T>(obj: T): any => { 26 + if (Array.isArray(obj)) { 27 + return obj.map(deepSnakeCaseKeys); 28 + } else if (isObject(obj)) { 29 + return R.pipe( 30 + R.toPairs, 31 + R.map( 32 + ([key, value]) => 33 + [_.snakeCase(String(key)), deepSnakeCaseKeys(value)] as [string, any], 34 + ), 35 + R.fromPairs, 36 + )(obj as object); 37 + } 38 + return obj; 39 + };
+128
apps/ws/src/main.ts
··· 1 + import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 2 + import { JetStreamClient, JetStreamEvent } from "./jetstream.ts"; 3 + import getNowPlayings from "./services/getNowPlayings.ts"; 4 + import { ctx } from "./context.ts"; 5 + import getScrobbles from "./services/getScrobbles.ts"; 6 + import getScrobblesChart from "./services/getScrobblesChart.ts"; 7 + import getActorAlbums from "./services/getActorAlbums.ts"; 8 + import getActorArtists from "./services/getActorArtists.ts"; 9 + import getActorScrobbles from "./services/getActorScrobbles.ts"; 10 + 11 + await configure({ 12 + sinks: { console: getConsoleSink() }, 13 + loggers: [{ category: "ws", lowestLevel: "debug", sinks: ["console"] }], 14 + }); 15 + 16 + const logger = getLogger("ws"); 17 + 18 + const clients = new Map<WebSocket, Set<string>>(); 19 + 20 + const getEndpoint = () => { 21 + const endpoint = Deno.env.get("JETSTREAM_SERVER") 22 + ? Deno.env.get("JETSTREAM_SERVER") 23 + : "wss://jetstream1.us-west.bsky.network/subscribe"; 24 + 25 + if (endpoint?.endsWith("/subscribe")) { 26 + return endpoint; 27 + } 28 + 29 + return `${endpoint}/subscribe`; 30 + }; 31 + 32 + const client = new JetStreamClient({ 33 + wantedCollections: ["app.rocksky.scrobble"], 34 + endpoint: getEndpoint(), 35 + 36 + // Optional: filter by specific DIDs 37 + // wantedDids: ["did:plc:example123"], 38 + 39 + // Reconnection settings 40 + maxReconnectAttempts: 10, 41 + reconnectDelay: 1000, 42 + maxReconnectDelay: 30000, 43 + backoffMultiplier: 1.5, 44 + 45 + // Enable debug logging 46 + debug: true, 47 + }); 48 + 49 + client.on("open", () => { 50 + logger.info`✅ Connected to JetStream!`; 51 + }); 52 + 53 + client.on("message", async (data) => { 54 + const event = data as JetStreamEvent; 55 + 56 + if (event.kind === "commit" && event.commit) { 57 + const { operation, collection, record, rkey } = event.commit; 58 + 59 + logger.info`\n📡 New event:`; 60 + logger.info` Operation: ${operation}`; 61 + logger.info` Collection: ${collection}`; 62 + logger.info` DID: ${event.did}`; 63 + logger.info` Uri: at://${event.did}/${collection}/${rkey}`; 64 + 65 + if (operation === "create" && record) { 66 + console.log(JSON.stringify(record, null, 2)); 67 + } 68 + 69 + logger.info` Cursor: ${event.time_us}`; 70 + 71 + for (const [socket, channels] of clients) { 72 + if (channels.has(collection) && socket.readyState === WebSocket.OPEN) { 73 + try { 74 + await new Promise((resolve) => setTimeout(resolve, 5000)); 75 + const nowPlayings = await getNowPlayings(ctx); 76 + const scrobbles = await getScrobbles(ctx); 77 + const scrobblesChart = await getScrobblesChart(ctx); 78 + const actorScrobbles = await getActorScrobbles(ctx, event.did); 79 + const actorAlbums = await getActorAlbums(ctx, event.did); 80 + const actorArtists = await getActorArtists(ctx, event.did); 81 + 82 + socket.send( 83 + JSON.stringify({ 84 + nowPlayings, 85 + scrobbles, 86 + scrobblesChart, 87 + actorScrobbles, 88 + actorAlbums, 89 + actorArtists, 90 + uri: `at://${event.did}/${collection}/${rkey}`, 91 + did: event.did, 92 + }), 93 + ); 94 + } catch (error) { 95 + logger.error`Failed to send data to client: ${error}`; 96 + } 97 + } 98 + } 99 + } 100 + }); 101 + 102 + client.on("error", (error) => { 103 + logger.error`❌ Error: ${error}`; 104 + }); 105 + 106 + client.on("reconnect", (data) => { 107 + const { attempt } = data as { attempt: number }; 108 + logger.info`🔄 Reconnecting... (attempt ${attempt})`; 109 + }); 110 + 111 + client.connect(); 112 + 113 + Deno.serve((req) => { 114 + if (req.headers.get("upgrade") != "websocket") { 115 + return new Response(null, { status: 426 }); 116 + } 117 + const { socket, response } = Deno.upgradeWebSocket(req); 118 + socket.addEventListener("open", () => { 119 + logger.info`a client connected!`; 120 + clients.set(socket, new Set(["app.rocksky.scrobble"])); 121 + }); 122 + socket.addEventListener("message", (event) => { 123 + if (event.data === "ping") { 124 + socket.send("pong"); 125 + } 126 + }); 127 + return response; 128 + });
+28
apps/ws/src/schema/albums.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + 4 + const albums = pgTable("albums", { 5 + id: text("xata_id") 6 + .primaryKey() 7 + .default(sql`xata_id()`), 8 + title: text("title").notNull(), 9 + artist: text("artist").notNull(), 10 + releaseDate: text("release_date"), 11 + year: integer("year"), 12 + albumArt: text("album_art"), 13 + uri: text("uri").unique(), 14 + artistUri: text("artist_uri"), 15 + appleMusicLink: text("apple_music_link").unique(), 16 + spotifyLink: text("spotify_link").unique(), 17 + tidalLink: text("tidal_link").unique(), 18 + youtubeLink: text("youtube_link").unique(), 19 + sha256: text("sha256").unique().notNull(), 20 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 21 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 22 + xataVersion: integer("xata_version"), 23 + }); 24 + 25 + export type SelectAlbum = InferSelectModel<typeof albums>; 26 + export type InsertAlbum = InferInsertModel<typeof albums>; 27 + 28 + export default albums;
+29
apps/ws/src/schema/artists.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + 4 + const artists = pgTable("artists", { 5 + id: text("xata_id") 6 + .primaryKey() 7 + .default(sql`xata_id()`), 8 + name: text("name").notNull(), 9 + biography: text("biography"), 10 + born: timestamp("born"), 11 + bornIn: text("born_in"), 12 + died: timestamp("died"), 13 + picture: text("picture"), 14 + sha256: text("sha256").unique().notNull(), 15 + uri: text("uri").unique(), 16 + appleMusicLink: text("apple_music_link"), 17 + spotifyLink: text("spotify_link"), 18 + tidalLink: text("tidal_link"), 19 + youtubeLink: text("youtube_link"), 20 + genres: text("genres").array(), 21 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 22 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 23 + xataVersion: integer("xata_version"), 24 + }); 25 + 26 + export type SelectArtist = InferSelectModel<typeof artists>; 27 + export type InsertArtist = InferInsertModel<typeof artists>; 28 + 29 + export default artists;
+23
apps/ws/src/schema/loved-tracks.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + import tracks from "./tracks.ts"; 4 + import users from "./users.ts"; 5 + 6 + const lovedTracks = pgTable("loved_tracks", { 7 + id: text("xata_id") 8 + .primaryKey() 9 + .default(sql`xata_id()`), 10 + userId: text("user_id") 11 + .notNull() 12 + .references(() => users.id), 13 + trackId: text("track_id") 14 + .notNull() 15 + .references(() => tracks.id), 16 + uri: text("uri").unique(), 17 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 18 + }); 19 + 20 + export type SelectLovedTrack = InferSelectModel<typeof lovedTracks>; 21 + export type InsertLovedTrack = InferInsertModel<typeof lovedTracks>; 22 + 23 + export default lovedTracks;
+15
apps/ws/src/schema/mod.ts
··· 1 + import albums from "./albums.ts"; 2 + import artists from "./artists.ts"; 3 + import tracks from "./tracks.ts"; 4 + import scrobbles from "./scrobbles.ts"; 5 + import users from "./users.ts"; 6 + import lovedTracks from "./loved-tracks.ts"; 7 + 8 + export default { 9 + albums, 10 + artists, 11 + lovedTracks, 12 + tracks, 13 + scrobbles, 14 + users, 15 + };
+26
apps/ws/src/schema/scrobbles.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + import albums from "./albums.ts"; 4 + import artists from "./artists.ts"; 5 + import tracks from "./tracks.ts"; 6 + import users from "./users.ts"; 7 + 8 + const scrobbles = pgTable("scrobbles", { 9 + id: text("xata_id") 10 + .primaryKey() 11 + .default(sql`xata_id()`), 12 + userId: text("user_id").references(() => users.id), 13 + trackId: text("track_id").references(() => tracks.id), 14 + albumId: text("album_id").references(() => albums.id), 15 + artistId: text("artist_id").references(() => artists.id), 16 + uri: text("uri").unique(), 17 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 18 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 19 + xataVersion: integer("xata_version"), 20 + timestamp: timestamp("timestamp").defaultNow().notNull(), 21 + }); 22 + 23 + export type SelectScrobble = InferSelectModel<typeof scrobbles>; 24 + export type InsertScrobble = InferInsertModel<typeof scrobbles>; 25 + 26 + export default scrobbles;
+38
apps/ws/src/schema/tracks.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 2 + import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + 4 + const tracks = pgTable("tracks", { 5 + id: text("xata_id") 6 + .primaryKey() 7 + .default(sql`xata_id()`), 8 + title: text("title").notNull(), 9 + artist: text("artist").notNull(), 10 + albumArtist: text("album_artist").notNull(), 11 + albumArt: text("album_art"), 12 + album: text("album").notNull(), 13 + trackNumber: integer("track_number"), 14 + duration: integer("duration").notNull(), 15 + mbId: text("mb_id").unique(), 16 + youtubeLink: text("youtube_link").unique(), 17 + spotifyLink: text("spotify_link").unique(), 18 + appleMusicLink: text("apple_music_link").unique(), 19 + tidalLink: text("tidal_link").unique(), 20 + sha256: text("sha256").unique().notNull(), 21 + discNumber: integer("disc_number"), 22 + lyrics: text("lyrics"), 23 + composer: text("composer"), 24 + genre: text("genre"), 25 + label: text("label"), 26 + copyrightMessage: text("copyright_message"), 27 + uri: text("uri").unique(), 28 + albumUri: text("album_uri"), 29 + artistUri: text("artist_uri"), 30 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 31 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 32 + xataVersion: integer("xata_version"), 33 + }); 34 + 35 + export type SelectTrack = InferSelectModel<typeof tracks>; 36 + export type InsertTrack = InferInsertModel<typeof tracks>; 37 + 38 + export default tracks;
+20
apps/ws/src/schema/users.ts
··· 1 + import { type InferSelectModel, sql } from "drizzle-orm"; 2 + import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 + 4 + const users = pgTable("users", { 5 + id: text("xata_id") 6 + .primaryKey() 7 + .default(sql`xata_id()`), 8 + did: text("did").unique().notNull(), 9 + displayName: text("display_name"), 10 + handle: text("handle").unique().notNull(), 11 + avatar: text("avatar").notNull(), 12 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 13 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 14 + xataVersion: integer("xata_version"), 15 + }); 16 + 17 + export type SelectUser = InferSelectModel<typeof users>; 18 + export type InsertUser = InferSelectModel<typeof users>; 19 + 20 + export default users;
+96
apps/ws/src/services/getActorAlbums.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + import { deepCamelCaseKeys } from "../lib/deepCamelKeys.ts"; 4 + 5 + export default function (ctx: Context, did: string) { 6 + return Effect.runPromise( 7 + pipe( 8 + retrieve({ 9 + ctx, 10 + params: { 11 + did, 12 + offset: 0, 13 + limit: 12, 14 + }, 15 + }), 16 + Effect.flatMap(presentation), 17 + Effect.retry({ times: 3 }), 18 + Effect.timeout("10 seconds"), 19 + Effect.catchAll((error) => 20 + Effect.fail(new Error(`Failed to retrieve albums: ${error}`)), 21 + ), 22 + ), 23 + ); 24 + } 25 + 26 + const retrieve = ({ 27 + params, 28 + ctx, 29 + }: { 30 + params: { 31 + did: string; 32 + offset: number; 33 + limit: number; 34 + }; 35 + ctx: Context; 36 + }): Effect.Effect<{ data: Album[] }, Error> => { 37 + return Effect.tryPromise({ 38 + try: () => 39 + ctx.analytics.post("library.getTopAlbums", { 40 + user_did: params.did, 41 + pagination: { 42 + skip: params.offset || 0, 43 + take: params.limit || 10, 44 + }, 45 + }), 46 + catch: (error) => new Error(`Failed to retrieve artists: ${error}`), 47 + }); 48 + }; 49 + 50 + const presentation = ({ 51 + data, 52 + }: { 53 + data: Album[]; 54 + }): Effect.Effect<{ albums: AlbumViewBasic[] }, never> => { 55 + return Effect.sync(() => ({ albums: deepCamelCaseKeys(data) })); 56 + }; 57 + 58 + type Album = { 59 + id: string; 60 + uri: string; 61 + title: string; 62 + artist: string; 63 + artist_uri: string; 64 + year: number; 65 + album_art: string; 66 + release_date: string; 67 + sha256: string; 68 + play_count: number; 69 + unique_listeners: number; 70 + }; 71 + 72 + export interface AlbumViewBasic { 73 + /** The unique identifier of the album. */ 74 + id?: string; 75 + /** The URI of the album. */ 76 + uri?: string; 77 + /** The title of the album. */ 78 + title?: string; 79 + /** The artist of the album. */ 80 + artist?: string; 81 + /** The URI of the album's artist. */ 82 + artistUri?: string; 83 + /** The year the album was released. */ 84 + year?: number; 85 + /** The URL of the album art image. */ 86 + albumArt?: string; 87 + /** The release date of the album. */ 88 + releaseDate?: string; 89 + /** The SHA256 hash of the album. */ 90 + sha256?: string; 91 + /** The number of times the album has been played. */ 92 + playCount?: number; 93 + /** The number of unique listeners who have played the album. */ 94 + uniqueListeners?: number; 95 + [k: string]: unknown; 96 + }
+77
apps/ws/src/services/getActorArtists.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + import { deepCamelCaseKeys } from "../lib/deepCamelKeys.ts"; 4 + 5 + export default function (ctx: Context, did: string) { 6 + return Effect.runPromise( 7 + pipe( 8 + retrieve({ ctx, params: { did, offset: 0, limit: 20 } }), 9 + Effect.flatMap(presentation), 10 + Effect.retry({ times: 3 }), 11 + Effect.timeout("10 seconds"), 12 + Effect.catchAll((error) => 13 + Effect.fail(new Error(`Failed to retrieve artists: ${error}`)), 14 + ), 15 + ), 16 + ); 17 + } 18 + 19 + const retrieve = ({ 20 + params, 21 + ctx, 22 + }: { 23 + params: { 24 + did: string; 25 + offset: number; 26 + limit: number; 27 + }; 28 + ctx: Context; 29 + }): Effect.Effect<{ data: Artist[] }, Error> => { 30 + return Effect.tryPromise({ 31 + try: () => 32 + ctx.analytics.post("library.getTopArtists", { 33 + user_did: params.did, 34 + pagination: { 35 + skip: params.offset || 0, 36 + take: params.limit || 10, 37 + }, 38 + }), 39 + catch: (error) => new Error(`Failed to retrieve artists: ${error}`), 40 + }); 41 + }; 42 + 43 + const presentation = ({ 44 + data, 45 + }: { 46 + data: Artist[]; 47 + }): Effect.Effect<{ artists: ArtistViewBasic[] }, never> => { 48 + return Effect.sync(() => ({ artists: deepCamelCaseKeys(data) })); 49 + }; 50 + 51 + type Artist = { 52 + id: string; 53 + name: string; 54 + picture: string; 55 + play_count: number; 56 + sha256: string; 57 + unique_listeners: number; 58 + uri: string; 59 + }; 60 + 61 + export interface ArtistViewBasic { 62 + /** The unique identifier of the artist. */ 63 + id?: string; 64 + /** The URI of the artist. */ 65 + uri?: string; 66 + /** The name of the artist. */ 67 + name?: string; 68 + /** The picture of the artist. */ 69 + picture?: string; 70 + /** The SHA256 hash of the artist. */ 71 + sha256?: string; 72 + /** The number of times the artist has been played. */ 73 + playCount?: number; 74 + /** The number of unique listeners who have played the artist. */ 75 + uniqueListeners?: number; 76 + [k: string]: unknown; 77 + }
+102
apps/ws/src/services/getActorScrobbles.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + import { deepCamelCaseKeys } from "../lib/deepCamelKeys.ts"; 4 + 5 + export default function (ctx: Context, did: string) { 6 + return pipe( 7 + retrieve({ 8 + ctx, 9 + params: { 10 + did, 11 + offset: 0, 12 + limit: 10, 13 + }, 14 + }), 15 + Effect.flatMap(presentation), 16 + Effect.retry({ times: 3 }), 17 + Effect.timeout("10 seconds"), 18 + Effect.catchAll((error) => 19 + Effect.fail(new Error(`Failed to retrieve scrobbles: ${error}`)), 20 + ), 21 + ); 22 + } 23 + 24 + const retrieve = ({ 25 + params, 26 + ctx, 27 + }: { 28 + params: { 29 + did: string; 30 + offset: number; 31 + limit: number; 32 + }; 33 + ctx: Context; 34 + }): Effect.Effect<{ data: Scrobble[] }, Error> => { 35 + return Effect.tryPromise({ 36 + try: () => 37 + ctx.analytics.post("library.getScrobbles", { 38 + user_did: params.did, 39 + pagination: { 40 + skip: params.offset, 41 + take: params.limit, 42 + }, 43 + }), 44 + catch: (error) => new Error(`Failed to retrieve scrobles: ${error}`), 45 + }); 46 + }; 47 + 48 + const presentation = ({ 49 + data, 50 + }: { 51 + data: Scrobble[]; 52 + }): Effect.Effect<{ scrobbles: ScrobbleViewBasic[] }, never> => { 53 + return Effect.sync(() => ({ scrobbles: deepCamelCaseKeys(data) })); 54 + }; 55 + 56 + type Scrobble = { 57 + id: string; 58 + track_id: string; 59 + title: string; 60 + artist: string; 61 + album_artist: string; 62 + album_art: string; 63 + album: string; 64 + handle: string; 65 + did: string; 66 + avatar: string | null; 67 + uri: string; 68 + track_uri: string; 69 + artist_uri: string; 70 + album_uri: string; 71 + created_at: string; 72 + }; 73 + 74 + export interface ScrobbleViewBasic { 75 + /** The unique identifier of the scrobble. */ 76 + id?: string; 77 + /** The handle of the user who created the scrobble. */ 78 + user?: string; 79 + /** The display name of the user who created the scrobble. */ 80 + userDisplayName?: string; 81 + /** The avatar URL of the user who created the scrobble. */ 82 + userAvatar?: string; 83 + /** The title of the scrobble. */ 84 + title?: string; 85 + /** The artist of the song. */ 86 + artist?: string; 87 + /** The URI of the artist. */ 88 + artistUri?: string; 89 + /** The album of the song. */ 90 + album?: string; 91 + /** The URI of the album. */ 92 + albumUri?: string; 93 + /** The album art URL of the song. */ 94 + cover?: string; 95 + /** The timestamp when the scrobble was created. */ 96 + date?: string; 97 + /** The URI of the scrobble. */ 98 + uri?: string; 99 + /** The SHA256 hash of the scrobble data. */ 100 + sha256?: string; 101 + [k: string]: unknown; 102 + }
+62
apps/ws/src/services/getActorSongs.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + 4 + export default function (ctx: Context, did: string) { 5 + return pipe( 6 + retrieve({ 7 + ctx, 8 + params: { 9 + did, 10 + offset: 0, 11 + limit: 20, 12 + }, 13 + }), 14 + Effect.retry({ times: 3 }), 15 + Effect.timeout("10 seconds"), 16 + Effect.catchAll((error) => 17 + Effect.fail(new Error(`Failed to retrieve songs: ${error}`)), 18 + ), 19 + ); 20 + } 21 + 22 + const retrieve = ({ 23 + params, 24 + ctx, 25 + }: { 26 + params: { 27 + did: string; 28 + offset: number; 29 + limit: number; 30 + }; 31 + ctx: Context; 32 + }): Effect.Effect<{ data: Scrobble[] }, Error> => { 33 + return Effect.tryPromise({ 34 + try: () => 35 + ctx.analytics.post("library.getTopTracks", { 36 + user_did: params.did, 37 + pagination: { 38 + skip: params.offset, 39 + take: params.limit, 40 + }, 41 + }), 42 + catch: (error) => new Error(`Failed to retrieve tracks: ${error}`), 43 + }); 44 + }; 45 + 46 + type Scrobble = { 47 + id: string; 48 + track_id: string; 49 + title: string; 50 + artist: string; 51 + album_artist: string; 52 + album_art: string; 53 + album: string; 54 + handle: string; 55 + did: string; 56 + avatar: string | null; 57 + uri: string; 58 + track_uri: string; 59 + artist_uri: string; 60 + album_uri: string; 61 + created_at: string; 62 + };
+66
apps/ws/src/services/getNowPlayings.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + import { deepCamelCaseKeys } from "../lib/deepCamelKeys.ts"; 4 + 5 + export default function (ctx: Context) { 6 + return Effect.runPromise( 7 + pipe( 8 + retrieve({ ctx, params: { size: 7 } }), 9 + Effect.flatMap(presentation), 10 + Effect.retry({ times: 3 }), 11 + Effect.timeout("10 seconds"), 12 + Effect.catchAll((error) => 13 + Effect.fail( 14 + new Error(`Failed to retrieve now playing songs: ${error}`), 15 + ), 16 + ), 17 + ), 18 + ); 19 + } 20 + 21 + const retrieve = ({ 22 + ctx, 23 + params, 24 + }: { 25 + ctx: Context; 26 + params: { size: number }; 27 + }) => { 28 + return Effect.tryPromise({ 29 + try: () => 30 + ctx.analytics.post("library.getDistinctScrobbles", { 31 + pagination: { 32 + skip: 0, 33 + take: params.size, 34 + }, 35 + }), 36 + catch: (error) => 37 + new Error(`Failed to retrieve now playing songs: ${error}`), 38 + }); 39 + }; 40 + 41 + const presentation = ({ 42 + data, 43 + }): Effect.Effect<{ nowPlayings: NowPlayingView[] }, never> => { 44 + return Effect.sync(() => ({ 45 + nowPlayings: deepCamelCaseKeys(data), 46 + })); 47 + }; 48 + 49 + export interface NowPlayingView { 50 + album?: string; 51 + albumArt?: string; 52 + albumArtist?: string; 53 + albumUri?: string; 54 + artist?: string; 55 + artistUri?: string; 56 + avatar?: string; 57 + createdAt?: string; 58 + did?: string; 59 + handle?: string; 60 + id?: string; 61 + title?: string; 62 + trackId?: string; 63 + trackUri?: string; 64 + uri?: string; 65 + [k: string]: unknown; 66 + }
+44
apps/ws/src/services/getScrobbles.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { Effect, pipe } from "effect"; 3 + import tables from "../schema/mod.ts"; 4 + import { eq, desc } from "drizzle-orm"; 5 + 6 + export default function (ctx: Context, offset?: number, limit?: number) { 7 + return pipe( 8 + retrieve({ 9 + ctx, 10 + params: { 11 + offset: offset || 0, 12 + limit: limit || 20, 13 + }, 14 + }), 15 + Effect.retry({ times: 3 }), 16 + Effect.timeout("10 seconds"), 17 + Effect.catchAll((error) => 18 + Effect.fail(new Error(`Failed to retrieve scrobbles: ${error}`)), 19 + ), 20 + ); 21 + } 22 + 23 + const retrieve = ({ 24 + ctx, 25 + params, 26 + }: { 27 + ctx: Context; 28 + params: { offset?: number; limit?: number }; 29 + }) => { 30 + return Effect.tryPromise({ 31 + try: () => 32 + ctx.db 33 + .select() 34 + .from(tables.scrobbles) 35 + .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 36 + .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 37 + .orderBy(desc(tables.scrobbles.timestamp)) 38 + .offset(params.offset || 0) 39 + .limit(params.limit || 20) 40 + .execute(), 41 + 42 + catch: (error) => new Error(`Failed to retrieve scrobbles: ${error}`), 43 + }); 44 + };
+98
apps/ws/src/services/getScrobblesChart.ts
··· 1 + import type { Context } from "../context.ts"; 2 + import { eq } from "drizzle-orm"; 3 + import { Effect, Match, pipe } from "effect"; 4 + import tables from "../schema/mod.ts"; 5 + 6 + export default function (ctx: Context, did?: string) { 7 + return pipe( 8 + retrieve({ 9 + ctx, 10 + params: { 11 + did, 12 + }, 13 + }), 14 + Effect.flatMap(presentation), 15 + Effect.retry({ times: 3 }), 16 + Effect.timeout("10 seconds"), 17 + Effect.catchAll((error) => 18 + Effect.fail(new Error(`Failed to retrieve scrobbles chart: ${error}`)), 19 + ), 20 + ); 21 + } 22 + 23 + export type Params = { 24 + did?: string; 25 + artisturi?: string; 26 + albumuri?: string; 27 + songuri?: string; 28 + }; 29 + 30 + const retrieve = ({ params, ctx }: { params: Params; ctx: Context }) => { 31 + return Effect.tryPromise({ 32 + try: () => { 33 + const match = Match.type<Params>().pipe( 34 + Match.when({ did: (did) => !!did }, ({ did }) => 35 + ctx.analytics.post("library.getScrobblesPerDay", { 36 + user_did: did, 37 + }), 38 + ), 39 + Match.when({ artisturi: (uri) => !!uri }, ({ artisturi }) => 40 + ctx.analytics.post("library.getArtistScrobbles", { 41 + artist_id: artisturi, 42 + }), 43 + ), 44 + Match.when({ albumuri: (uri) => !!uri }, ({ albumuri }) => 45 + ctx.analytics.post("library.getAlbumScrobbles", { 46 + album_id: albumuri, 47 + }), 48 + ), 49 + Match.when( 50 + { songuri: (uri) => !!uri && uri.includes("app.rocksky.scrobble") }, 51 + ({ songuri }) => 52 + ctx.db 53 + .select() 54 + .from(tables.scrobbles) 55 + .leftJoin( 56 + tables.tracks, 57 + eq(tables.scrobbles.trackId, tables.tracks.id), 58 + ) 59 + .where(eq(tables.scrobbles.uri, songuri)) 60 + .execute() 61 + .then(([row]) => row?.tracks?.uri) 62 + .then((uri) => 63 + ctx.analytics.post("library.getTrackScrobbles", { 64 + track_id: uri, 65 + }), 66 + ), 67 + ), 68 + Match.when( 69 + { songuri: (uri) => !!uri && !uri.includes("app.rocksky.scrobble") }, 70 + ({ songuri }) => 71 + ctx.analytics.post("library.getTrackScrobbles", { 72 + track_id: songuri, 73 + }), 74 + ), 75 + Match.orElse(() => 76 + ctx.analytics.post("library.getScrobblesPerDay", {}), 77 + ), 78 + ); 79 + 80 + return match(params); 81 + }, 82 + catch: (error) => new Error(`Failed to retrieve scrobbles chart: ${error}`), 83 + }); 84 + }; 85 + 86 + interface ChartsView { 87 + scrobbles: any; 88 + } 89 + 90 + const presentation = ({ 91 + data, 92 + }: { 93 + data: any; 94 + }): Effect.Effect<ChartsView, never> => { 95 + return Effect.sync(() => ({ 96 + scrobbles: data, 97 + })); 98 + };