See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add 5-minute in-memory cache for ClickHouse getTopPosts queries

Reduces ClickHouse load by caching top-posts results per DID/kind/daysWindow
using @adonisjs/cache with an in-memory LRU store. Includes Date re-hydration
for postCreatedAt fields that survive JSON serialization as strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+290 -5
+1
adonisrc.ts
··· 64 64 () => import('@adonisjs/lucid/database_provider'), 65 65 () => import('@adonisjs/queue/queue_provider'), 66 66 () => import('@adonisjs/auth/auth_provider'), 67 + () => import('@adonisjs/cache/cache_provider'), 67 68 () => import('#providers/atproto_provider'), 68 69 () => import('#providers/atproto_oauth_provider'), 69 70 () => import('#providers/clickhouse_provider'),
+16 -5
app/controllers/profile_controller.ts
··· 1 1 import { inject } from '@adonisjs/core' 2 2 import type { HttpContext } from '@adonisjs/core/http' 3 3 import logger from '@adonisjs/core/services/logger' 4 + import cache from '@adonisjs/cache/services/main' 4 5 import { HandleResolver, InvalidHandleError, HandleNotFoundError } from '#services/handle_resolver' 5 6 import { AtprotoClient, BlueskyRateLimitedError } from '#lib/atproto/index' 6 7 import type { Facet, FacetLink, FacetMention } from '#lib/atproto/index' ··· 270 271 avatarUrl: user.avatarUrl, 271 272 } 272 273 try { 274 + const cacheKey = `topPosts:${user.did}:${kind}:${daysWindow ?? 'all'}` 273 275 const [postsResult, profileResult] = await Promise.all([ 274 - this.clickHouseStore.getTopPosts({ 275 - authorDid: user.did, 276 - kind, 277 - daysWindow, 276 + cache.getOrSet({ 277 + key: cacheKey, 278 + ttl: '5m', 279 + factory: () => 280 + this.clickHouseStore.getTopPosts({ 281 + authorDid: user.did, 282 + kind, 283 + daysWindow, 284 + }), 278 285 }), 279 286 this.atprotoClient 280 287 .getProfile(user.did) ··· 284 291 return null 285 292 }), 286 293 ]) 287 - posts = postsResult 294 + // Re-hydrate Date fields that survive cache serialization as strings 295 + posts = postsResult.map((p) => ({ 296 + ...p, 297 + postCreatedAt: new Date(p.postCreatedAt), 298 + })) 288 299 if (profileResult) { 289 300 profile = profileResult 290 301 }
+8
config/cache.ts
··· 1 + import { defineConfig, store, drivers } from '@adonisjs/cache' 2 + 3 + export default defineConfig({ 4 + default: 'memory', 5 + stores: { 6 + memory: store().useL1Layer(drivers.memory({ maxSize: 10 * 1024 * 1024 })), 7 + }, 8 + })
+1
package.json
··· 73 73 }, 74 74 "dependencies": { 75 75 "@adonisjs/auth": "^10.1.0", 76 + "@adonisjs/cache": "^2.1.0", 76 77 "@adonisjs/core": "^7.3.0", 77 78 "@adonisjs/lucid": "^22.4.0", 78 79 "@adonisjs/otel": "^1.2.3",
+138
pnpm-lock.yaml
··· 11 11 '@adonisjs/auth': 12 12 specifier: ^10.1.0 13 13 version: 10.1.0(8824ab621c81534275012d39ae9b10b6) 14 + '@adonisjs/cache': 15 + specifier: ^2.1.0 16 + version: 2.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@adonisjs/core@7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@adonisjs/core@7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.3.1)(better-sqlite3@12.8.0)(luxon@3.7.2))(knex@3.2.9(better-sqlite3@12.8.0)) 14 17 '@adonisjs/core': 15 18 specifier: ^7.3.0 16 19 version: 7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1) ··· 206 209 engines: {node: '>=24.0.0'} 207 210 peerDependencies: 208 211 '@adonisjs/http-server': ^8.0.0-next.17 || ^8.0.0 212 + 213 + '@adonisjs/cache@2.1.0': 214 + resolution: {integrity: sha512-rrdpJ10zF+6IWfAjz6wj1dBqPrtzk3q53ezsm+sYymNeG8C31Cj5FvwdbbhcXoQvR/SLOEi6SONtDxHK/D8doA==} 215 + engines: {node: '>=22.0.0'} 216 + peerDependencies: 217 + '@adonisjs/assembler': ^8.0.0-next.19 218 + '@adonisjs/core': ^7.0.0-next.13 219 + '@adonisjs/lucid': ^22.0.0-next.1 220 + '@adonisjs/redis': ^10.0.0-next.1 221 + peerDependenciesMeta: 222 + '@adonisjs/lucid': 223 + optional: true 224 + '@adonisjs/redis': 225 + optional: true 209 226 210 227 '@adonisjs/config@6.1.0': 211 228 resolution: {integrity: sha512-YVDRL8xHCtM6iMnAefOBaz6iXVpojwBPDQWPKxnVSucycYeNGrGitJiLy+cGaeAU7Gjm8al9SJRJt3rRPr5PKg==} ··· 613 630 '@borewit/text-codec@0.2.2': 614 631 resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} 615 632 633 + '@boringnode/bus@0.9.0': 634 + resolution: {integrity: sha512-X7lGyI6x1ObulCmH+0vyJ3/T6RyTBlp1hunRXjsenlkafJMIvneAx8B2V7OGLgZAxdsAbKaySjqZRj6GlX84Qw==} 635 + engines: {node: '>=20.6'} 636 + peerDependencies: 637 + ioredis: ^5.0.0 638 + peerDependenciesMeta: 639 + ioredis: 640 + optional: true 641 + 616 642 '@boringnode/encryption@1.0.0': 617 643 resolution: {integrity: sha512-wGGOE7ywA4W6KAVoVC7s1P4ULzFLIQA/JvthGAa41EA0CaH7kGGawkBB5t5tvWopgBNMhOpIg3uxvULxqf2rQw==} 618 644 engines: {node: '>=20.6'} ··· 966 992 '@js-sdsl/ordered-map@4.4.2': 967 993 resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} 968 994 995 + '@julr/utils@1.9.0': 996 + resolution: {integrity: sha512-hKKa29qRusABvUDt5QnJydG9aigplMJMaOrGwu11DAbZyxjqiytfOku6KbrdA1z0yUSvnFU8fSeJMXroYt+bwA==} 997 + 969 998 '@lukeed/ms@2.0.2': 970 999 resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} 971 1000 engines: {node: '>=8'} ··· 973 1002 '@noble/hashes@1.8.0': 974 1003 resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} 975 1004 engines: {node: ^14.21.3 || >=16} 1005 + 1006 + '@noble/hashes@2.2.0': 1007 + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} 1008 + engines: {node: '>= 20.19.0'} 976 1009 977 1010 '@nodelib/fs.scandir@2.1.5': 978 1011 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} ··· 1527 1560 '@paralleldrive/cuid2@2.3.1': 1528 1561 resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} 1529 1562 1563 + '@paralleldrive/cuid2@3.3.0': 1564 + resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} 1565 + hasBin: true 1566 + 1530 1567 '@phc/format@1.0.0': 1531 1568 resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} 1532 1569 engines: {node: '>=10'} ··· 1588 1625 1589 1626 '@poppinss/types@1.2.1': 1590 1627 resolution: {integrity: sha512-qUYnzl0m9HJTWsXtr8Xo7CwDx6wcjrvo14bOVbIMIlKJCzKrm3LX55dRTDr1/x4PpSvKVgmxvC6Ly2YiqXKOvQ==} 1628 + 1629 + '@poppinss/utils@6.10.1': 1630 + resolution: {integrity: sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA==} 1631 + engines: {node: '>=18.16.0'} 1591 1632 1592 1633 '@poppinss/utils@7.0.1': 1593 1634 resolution: {integrity: sha512-mveSvLI2YPC114mK5HCuSYfUtjpClf1wHG1VCqZJCp4U2ypPhIt62Iku5urh0kPAFvnvCVHx2bXBSH14qMTOlQ==} ··· 2212 2253 resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} 2213 2254 hasBin: true 2214 2255 2256 + async-mutex@0.5.0: 2257 + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} 2258 + 2215 2259 async-retry@1.3.3: 2216 2260 resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} 2217 2261 ··· 2241 2285 resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} 2242 2286 engines: {node: '>= 0.8'} 2243 2287 2288 + bentocache@1.6.1: 2289 + resolution: {integrity: sha512-UuWL3k9A62ygI1/ws6x4r6lFkM/jTu24zTTuhtYa4jpd26BF5O4Y64KxWXJbcmpXo1swLQdBlA+sWGDuuIbKaw==} 2290 + peerDependencies: 2291 + '@aws-sdk/client-dynamodb': ^3.438.0 2292 + ioredis: ^5.3.2 2293 + knex: ^3.0.1 2294 + kysely: ^0.27.3 2295 + orchid-orm: ^1.24.0 2296 + peerDependenciesMeta: 2297 + '@aws-sdk/client-dynamodb': 2298 + optional: true 2299 + ioredis: 2300 + optional: true 2301 + knex: 2302 + optional: true 2303 + kysely: 2304 + optional: true 2305 + orchid-orm: 2306 + optional: true 2307 + 2244 2308 better-sqlite3@12.8.0: 2245 2309 resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} 2246 2310 engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} ··· 2569 2633 environment@1.1.0: 2570 2634 resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} 2571 2635 engines: {node: '>=18'} 2636 + 2637 + error-causes@3.0.2: 2638 + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} 2572 2639 2573 2640 error-stack-parser-es@1.0.5: 2574 2641 resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} ··· 3419 3486 resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} 3420 3487 engines: {node: '>=18'} 3421 3488 3489 + object-hash@3.0.0: 3490 + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} 3491 + engines: {node: '>= 6'} 3492 + 3422 3493 object-inspect@1.13.4: 3423 3494 resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} 3424 3495 engines: {node: '>= 0.4'} ··· 3453 3524 p-locate@5.0.0: 3454 3525 resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} 3455 3526 engines: {node: '>=10'} 3527 + 3528 + p-timeout@7.0.1: 3529 + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} 3530 + engines: {node: '>=20'} 3456 3531 3457 3532 package-manager-detector@1.6.0: 3458 3533 resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} ··· 4328 4403 transitivePeerDependencies: 4329 4404 - supports-color 4330 4405 4406 + '@adonisjs/cache@2.1.0(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@adonisjs/core@7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1))(@adonisjs/lucid@22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@adonisjs/core@7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.3.1)(better-sqlite3@12.8.0)(luxon@3.7.2))(knex@3.2.9(better-sqlite3@12.8.0))': 4407 + dependencies: 4408 + '@adonisjs/assembler': 8.4.0(typescript@6.0.2) 4409 + '@adonisjs/core': 7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1) 4410 + bentocache: 1.6.1(knex@3.2.9(better-sqlite3@12.8.0)) 4411 + optionalDependencies: 4412 + '@adonisjs/lucid': 22.4.2(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@adonisjs/core@7.3.1(@adonisjs/assembler@8.4.0(typescript@6.0.2))(@vinejs/vine@4.3.1)(edge.js@6.5.0)(pino-pretty@13.1.3)(youch@4.1.1))(@vinejs/vine@4.3.1)(better-sqlite3@12.8.0)(luxon@3.7.2) 4413 + transitivePeerDependencies: 4414 + - '@aws-sdk/client-dynamodb' 4415 + - ioredis 4416 + - knex 4417 + - kysely 4418 + - orchid-orm 4419 + 4331 4420 '@adonisjs/config@6.1.0': 4332 4421 dependencies: 4333 4422 '@poppinss/utils': 7.0.1 ··· 4806 4895 4807 4896 '@borewit/text-codec@0.2.2': {} 4808 4897 4898 + '@boringnode/bus@0.9.0': 4899 + dependencies: 4900 + '@paralleldrive/cuid2': 3.3.0 4901 + '@poppinss/utils': 6.10.1 4902 + object-hash: 3.0.0 4903 + 4809 4904 '@boringnode/encryption@1.0.0': 4810 4905 dependencies: 4811 4906 '@poppinss/utils': 7.0.1 ··· 5078 5173 5079 5174 '@js-sdsl/ordered-map@4.4.2': {} 5080 5175 5176 + '@julr/utils@1.9.0': 5177 + dependencies: 5178 + '@lukeed/ms': 2.0.2 5179 + bytes: 3.1.2 5180 + 5081 5181 '@lukeed/ms@2.0.2': {} 5082 5182 5083 5183 '@noble/hashes@1.8.0': {} 5184 + 5185 + '@noble/hashes@2.2.0': {} 5084 5186 5085 5187 '@nodelib/fs.scandir@2.1.5': 5086 5188 dependencies: ··· 5867 5969 dependencies: 5868 5970 '@noble/hashes': 1.8.0 5869 5971 5972 + '@paralleldrive/cuid2@3.3.0': 5973 + dependencies: 5974 + '@noble/hashes': 2.2.0 5975 + bignumber.js: 9.3.1 5976 + error-causes: 3.0.2 5977 + 5870 5978 '@phc/format@1.0.0': {} 5871 5979 5872 5980 '@pinojs/redact@0.4.0': {} ··· 5939 6047 - '@swc/helpers' 5940 6048 5941 6049 '@poppinss/types@1.2.1': {} 6050 + 6051 + '@poppinss/utils@6.10.1': 6052 + dependencies: 6053 + '@poppinss/exception': 1.2.3 6054 + '@poppinss/object-builder': 1.1.0 6055 + '@poppinss/string': 1.7.1 6056 + flattie: 1.1.1 6057 + safe-stable-stringify: 2.5.0 6058 + secure-json-parse: 4.1.0 5942 6059 5943 6060 '@poppinss/utils@7.0.1': 5944 6061 dependencies: ··· 6459 6576 assertion-error@2.0.1: {} 6460 6577 6461 6578 astring@1.9.0: {} 6579 + 6580 + async-mutex@0.5.0: 6581 + dependencies: 6582 + tslib: 2.8.1 6462 6583 6463 6584 async-retry@1.3.3: 6464 6585 dependencies: ··· 6480 6601 dependencies: 6481 6602 safe-buffer: 5.1.2 6482 6603 6604 + bentocache@1.6.1(knex@3.2.9(better-sqlite3@12.8.0)): 6605 + dependencies: 6606 + '@boringnode/bus': 0.9.0 6607 + '@julr/utils': 1.9.0 6608 + '@poppinss/exception': 1.2.3 6609 + async-mutex: 0.5.0 6610 + lru-cache: 11.3.3 6611 + p-timeout: 7.0.1 6612 + optionalDependencies: 6613 + knex: 3.2.9(better-sqlite3@12.8.0) 6614 + 6483 6615 better-sqlite3@12.8.0: 6484 6616 dependencies: 6485 6617 bindings: 1.5.0 ··· 6777 6909 strip-ansi: 6.0.1 6778 6910 6779 6911 environment@1.1.0: {} 6912 + 6913 + error-causes@3.0.2: {} 6780 6914 6781 6915 error-stack-parser-es@1.0.5: {} 6782 6916 ··· 7544 7678 path-key: 4.0.0 7545 7679 unicorn-magic: 0.3.0 7546 7680 7681 + object-hash@3.0.0: {} 7682 + 7547 7683 object-inspect@1.13.4: {} 7548 7684 7549 7685 on-exit-leak-free@2.1.2: {} ··· 7585 7721 p-locate@5.0.0: 7586 7722 dependencies: 7587 7723 p-limit: 3.1.0 7724 + 7725 + p-timeout@7.0.1: {} 7588 7726 7589 7727 package-manager-detector@1.6.0: {} 7590 7728
+126
tests/functional/profile_cache.spec.ts
··· 1 + /** 2 + * Tests for in-memory caching of ClickHouse getTopPosts queries. 3 + */ 4 + import { test } from '@japa/runner' 5 + import testUtils from '@adonisjs/core/services/test_utils' 6 + import cache from '@adonisjs/cache/services/main' 7 + import { ClickHouseStore } from '#lib/clickhouse/index' 8 + import { AtprotoClient } from '#lib/atproto/index' 9 + import TrackedProfile from '#models/tracked_profile' 10 + 11 + // --------------------------------------------------------------------------- 12 + // Helpers 13 + // --------------------------------------------------------------------------- 14 + 15 + function makeFakeStore(getTopPostsCalls: number[]) { 16 + return { 17 + async getTopPosts() { 18 + getTopPostsCalls.push(1) 19 + return [ 20 + { 21 + postUri: 'at://did:plc:cached001/app.bsky.feed.post/rk1', 22 + postText: 'Cached post', 23 + postCreatedAt: new Date('2024-06-01T00:00:00Z'), 24 + likes: 10, 25 + reposts: 2, 26 + quotes: 0, 27 + embed: null, 28 + facets: [], 29 + replyParentUri: null, 30 + replyParentAuthorHandle: null, 31 + }, 32 + ] 33 + }, 34 + async getOldestPostDate() { 35 + return null 36 + }, 37 + } as unknown as ClickHouseStore 38 + } 39 + 40 + const fakeAtprotoClient = { 41 + async getProfile() { 42 + return { displayName: 'cached-user', avatarUrl: null } 43 + }, 44 + } as unknown as AtprotoClient 45 + 46 + // --------------------------------------------------------------------------- 47 + // Cache behavior 48 + // --------------------------------------------------------------------------- 49 + 50 + test.group('Profile ClickHouse cache', (group) => { 51 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 52 + group.each.teardown(() => cache.clear()) 53 + 54 + test('second request for same profile serves cached getTopPosts result', async ({ 55 + client, 56 + assert, 57 + swap, 58 + }) => { 59 + const calls: number[] = [] 60 + swap(ClickHouseStore, makeFakeStore(calls)) 61 + swap(AtprotoClient, fakeAtprotoClient) 62 + 63 + await TrackedProfile.create({ 64 + did: 'did:plc:cached001', 65 + handle: 'cached.test', 66 + firstSeenAt: Date.now(), 67 + backfilledAt: Date.now(), 68 + }) 69 + 70 + // First request — should call getTopPosts 71 + const r1 = await client.get('/profile/cached.test/likes') 72 + r1.assertStatus(200) 73 + assert.include(r1.text(), 'Cached post') 74 + assert.equal(calls.length, 1) 75 + 76 + // Second request — should use cache, not call getTopPosts again 77 + const r2 = await client.get('/profile/cached.test/likes') 78 + r2.assertStatus(200) 79 + assert.include(r2.text(), 'Cached post') 80 + assert.equal(calls.length, 1) 81 + }) 82 + 83 + test('different kind (likes vs reposts) uses separate cache entries', async ({ 84 + client, 85 + assert, 86 + swap, 87 + }) => { 88 + const calls: number[] = [] 89 + swap(ClickHouseStore, makeFakeStore(calls)) 90 + swap(AtprotoClient, fakeAtprotoClient) 91 + 92 + await TrackedProfile.create({ 93 + did: 'did:plc:cached001', 94 + handle: 'cached.test', 95 + firstSeenAt: Date.now(), 96 + backfilledAt: Date.now(), 97 + }) 98 + 99 + await client.get('/profile/cached.test/likes') 100 + assert.equal(calls.length, 1) 101 + 102 + // Different kind — should NOT hit the likes cache 103 + await client.get('/profile/cached.test/reposts') 104 + assert.equal(calls.length, 2) 105 + }) 106 + 107 + test('different daysWindow uses separate cache entries', async ({ client, assert, swap }) => { 108 + const calls: number[] = [] 109 + swap(ClickHouseStore, makeFakeStore(calls)) 110 + swap(AtprotoClient, fakeAtprotoClient) 111 + 112 + await TrackedProfile.create({ 113 + did: 'did:plc:cached001', 114 + handle: 'cached.test', 115 + firstSeenAt: Date.now(), 116 + backfilledAt: Date.now(), 117 + }) 118 + 119 + await client.get('/profile/cached.test/likes') 120 + assert.equal(calls.length, 1) 121 + 122 + // Different days window — separate cache key 123 + await client.get('/profile/cached.test/likes?days=30') 124 + assert.equal(calls.length, 2) 125 + }) 126 + })