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 follow/unfollow button to profile pages

Signed-in users can now follow or unfollow the profile they're viewing,
with an optimistic-update Alpine component backed by a new /api/follow
endpoint that writes app.bsky.graph.follow records via the viewer's
OAuth agent. The profile controller fetches viewer.following state in
parallel with the existing getPosts call. The button shows "Unfollow"
in red on hover/focus when already following.

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

+545 -18
+1
.adonisjs/server/controllers.ts
··· 6 6 export const controllers = { 7 7 Engagement: () => import('#controllers/engagement_controller'), 8 8 FeedGenerator: () => import('#controllers/feed_generator_controller'), 9 + Follow: () => import('#controllers/follow_controller'), 9 10 HealthChecks: () => import('#controllers/health_checks_controller'), 10 11 Landing: () => import('#controllers/landing_controller'), 11 12 Oauth: () => import('#controllers/oauth_controller'),
+4
.adonisjs/server/routes.d.ts
··· 20 20 'api.like.delete': { paramsTuple?: []; params?: {} } 21 21 'api.repost.create': { paramsTuple?: []; params?: {} } 22 22 'api.repost.delete': { paramsTuple?: []; params?: {} } 23 + 'api.follow.create': { paramsTuple?: []; params?: {} } 24 + 'api.follow.delete': { paramsTuple?: []; params?: {} } 23 25 'og.landing': { paramsTuple?: []; params?: {} } 24 26 'og.profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } 25 27 'sitemap.index': { paramsTuple?: []; params?: {} } ··· 78 80 'oauth.logout': { paramsTuple?: []; params?: {} } 79 81 'api.like.create': { paramsTuple?: []; params?: {} } 80 82 'api.repost.create': { paramsTuple?: []; params?: {} } 83 + 'api.follow.create': { paramsTuple?: []; params?: {} } 81 84 } 82 85 DELETE: { 83 86 'api.like.delete': { paramsTuple?: []; params?: {} } 84 87 'api.repost.delete': { paramsTuple?: []; params?: {} } 88 + 'api.follow.delete': { paramsTuple?: []; params?: {} } 85 89 } 86 90 } 87 91 declare module '@adonisjs/core/types/http' {
+62
app/controllers/follow_controller.ts
··· 1 + import { inject } from '@adonisjs/core' 2 + import type { HttpContext } from '@adonisjs/core/http' 3 + import vine from '@vinejs/vine' 4 + import AtprotoOAuthService from '#services/atproto_oauth' 5 + import { getPostHogClient } from '#services/posthog' 6 + 7 + const createValidator = vine.compile( 8 + vine.object({ 9 + did: vine.string().trim().minLength(1), 10 + }) 11 + ) 12 + 13 + const deleteValidator = vine.compile( 14 + vine.object({ 15 + uri: vine.string().trim().minLength(1), 16 + }) 17 + ) 18 + 19 + @inject() 20 + export default class FollowController { 21 + constructor(private readonly oauthService: AtprotoOAuthService) {} 22 + 23 + async follow({ request, auth, response }: HttpContext) { 24 + const { did } = await request.validateUsing(createValidator) 25 + const viewerDid = auth.use('web').user!.did 26 + 27 + if (did === viewerDid) { 28 + return response.status(400).json({ message: "You can't follow yourself." }) 29 + } 30 + 31 + try { 32 + const agent = await this.oauthService.getAgent(viewerDid) 33 + const result = await agent.follow(did) 34 + getPostHogClient()?.capture({ 35 + distinctId: viewerDid, 36 + event: 'follow_created', 37 + properties: { target_did: did }, 38 + }) 39 + return response.json({ uri: result.uri }) 40 + } catch (error) { 41 + return response.status(502).json({ message: 'Failed to create follow on Bluesky' }) 42 + } 43 + } 44 + 45 + async unfollow({ request, auth, response }: HttpContext) { 46 + const { uri } = await request.validateUsing(deleteValidator) 47 + const viewerDid = auth.use('web').user!.did 48 + 49 + try { 50 + const agent = await this.oauthService.getAgent(viewerDid) 51 + await agent.deleteFollow(uri) 52 + getPostHogClient()?.capture({ 53 + distinctId: viewerDid, 54 + event: 'follow_removed', 55 + properties: { follow_uri: uri }, 56 + }) 57 + return response.json({}) 58 + } catch (error) { 59 + return response.status(502).json({ message: 'Failed to delete follow on Bluesky' }) 60 + } 61 + } 62 + }
+30 -5
app/controllers/profile_controller.ts
··· 341 341 // TODO: re-enable once PDS'es support granular rpc scopes. 342 342 let viewer: Record<string, { likeUri: string | null; repostUri: string | null }> | null = null 343 343 const cidMap = new Map<string, string>() 344 + const viewerDid = isAuthenticated ? ctx.auth.user!.did : null 345 + const isOwnProfile = viewerDid === user.did 346 + let viewerFollowUri: string | null = null 344 347 345 348 if (isAuthenticated) { 346 - const viewerDid = ctx.auth.user!.did 347 - const agent = await this.atprotoOAuthService.getAgent(viewerDid) 349 + const agent = await this.atprotoOAuthService.getAgent(viewerDid!) 348 350 const postUris = posts.map((p) => p.postUri) 349 - if (postUris.length > 0) { 350 - const res = await agent.app.bsky.feed.getPosts({ uris: postUris }) 351 + 352 + const fetchViewerProfile = async () => { 353 + if (isOwnProfile) return null 354 + try { 355 + return await agent.app.bsky.actor.getProfile({ actor: user.did }) 356 + } catch (err) { 357 + logger.error({ err, targetDid: user.did }, 'Failed to fetch viewer follow state') 358 + return null 359 + } 360 + } 361 + 362 + const [postsResult, profileResult] = await Promise.all([ 363 + postUris.length > 0 ? agent.app.bsky.feed.getPosts({ uris: postUris }) : null, 364 + fetchViewerProfile(), 365 + ]) 366 + 367 + if (postsResult) { 351 368 viewer = {} 352 - for (const post of res.data.posts) { 369 + for (const post of postsResult.data.posts) { 353 370 viewer[post.uri] = { 354 371 likeUri: post.viewer?.like ?? null, 355 372 repostUri: post.viewer?.repost ?? null, ··· 357 374 cidMap.set(post.uri, post.cid) 358 375 } 359 376 } 377 + 378 + if (profileResult) { 379 + viewerFollowUri = 380 + (profileResult.data.viewer as { following?: string } | undefined)?.following ?? null 381 + } 360 382 } 361 383 362 384 // 7. Add bsky.app URL and postCid to each post ··· 384 406 response.header('Vary', 'Cookie') 385 407 return view.render('pages/profile/show', { 386 408 handle: canonicalHandle, 409 + profileDid: user.did, 387 410 displayName: profile.displayName, 388 411 avatarUrl: profile.avatarUrl, 389 412 kind, ··· 395 418 ogImageUrl, 396 419 indexedSince, 397 420 viewer, 421 + isOwnProfile, 422 + viewerFollowUri, 398 423 }) 399 424 } 400 425
+118
resources/js/app.js
··· 302 302 } 303 303 }) 304 304 305 + Alpine.data('profileFollow', function () { 306 + return { 307 + following: false, 308 + followUri: null, 309 + targetDid: '', 310 + csrfToken: '', 311 + busy: false, 312 + hovered: false, 313 + 314 + init() { 315 + this.following = this.$el.dataset.following === 'true' 316 + this.followUri = this.$el.dataset.followUri || null 317 + this.targetDid = this.$el.dataset.targetDid || '' 318 + var meta = document.querySelector('meta[name="csrf-token"]') 319 + this.csrfToken = meta ? meta.getAttribute('content') : '' 320 + }, 321 + 322 + onEnter() { 323 + this.hovered = true 324 + }, 325 + 326 + onLeave() { 327 + this.hovered = false 328 + }, 329 + 330 + toggle() { 331 + if (this.busy) return 332 + this.busy = true 333 + 334 + if (this.following) { 335 + var uri = this.followUri 336 + this.following = false 337 + this.followUri = null 338 + 339 + fetch('/api/follow', { 340 + method: 'DELETE', 341 + headers: { 342 + 'Content-Type': 'application/json', 343 + 'Accept': 'application/json', 344 + 'x-csrf-token': this.csrfToken, 345 + }, 346 + body: JSON.stringify({ uri: uri }), 347 + }) 348 + .then( 349 + function (resp) { 350 + if (!resp.ok) { 351 + this.following = true 352 + this.followUri = uri 353 + } 354 + }.bind(this) 355 + ) 356 + .catch( 357 + function () { 358 + this.following = true 359 + this.followUri = uri 360 + }.bind(this) 361 + ) 362 + .finally( 363 + function () { 364 + this.busy = false 365 + }.bind(this) 366 + ) 367 + } else { 368 + this.following = true 369 + 370 + fetch('/api/follow', { 371 + method: 'POST', 372 + headers: { 373 + 'Content-Type': 'application/json', 374 + 'Accept': 'application/json', 375 + 'x-csrf-token': this.csrfToken, 376 + }, 377 + body: JSON.stringify({ did: this.targetDid }), 378 + }) 379 + .then( 380 + function (resp) { 381 + if (resp.ok) { 382 + return resp.json() 383 + } 384 + this.following = false 385 + return null 386 + }.bind(this) 387 + ) 388 + .then( 389 + function (data) { 390 + if (data && data.uri) { 391 + this.followUri = data.uri 392 + } 393 + }.bind(this) 394 + ) 395 + .catch( 396 + function () { 397 + this.following = false 398 + }.bind(this) 399 + ) 400 + .finally( 401 + function () { 402 + this.busy = false 403 + }.bind(this) 404 + ) 405 + } 406 + }, 407 + 408 + get label() { 409 + if (!this.following) return 'Follow' 410 + return this.hovered ? 'Unfollow' : 'Following' 411 + }, 412 + 413 + get buttonClass() { 414 + if (!this.following) return 'bg-blue-600 text-white hover:bg-blue-700' 415 + if (this.hovered) { 416 + return 'bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 ring-1 ring-inset ring-red-300 dark:ring-red-900' 417 + } 418 + return 'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100' 419 + }, 420 + } 421 + }) 422 + 305 423 Alpine.start() 306 424 307 425 tippy('[data-tippy-content]')
+38 -13
resources/views/pages/profile/show.edge
··· 16 16 @slot('main') 17 17 <div class="pt-8 pb-4"> 18 18 {{-- Profile header --}} 19 - <a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="group flex items-center gap-4 no-underline mb-6"> 20 - @if(avatarUrl) 21 - <img src="{{ avatarUrl }}" alt="{{ '@' + handle }} avatar" class="size-14 rounded-full shrink-0 object-cover"> 22 - @else 23 - <div class="size-14 rounded-full bg-gray-300 dark:bg-gray-700 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 24 - @endif 25 - <div> 26 - @if(displayName) 27 - <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ displayName }}</h1> 28 - <div class="text-gray-600 dark:text-gray-400 group-hover:underline">{{ '@' + handle }}</div> 19 + <div class="flex items-center gap-4 mb-6"> 20 + <a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="group flex items-center gap-4 no-underline flex-1 min-w-0"> 21 + @if(avatarUrl) 22 + <img src="{{ avatarUrl }}" alt="{{ '@' + handle }} avatar" class="size-14 rounded-full shrink-0 object-cover"> 29 23 @else 30 - <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ '@' + handle }}</h1> 24 + <div class="size-14 rounded-full bg-gray-300 dark:bg-gray-700 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 31 25 @endif 32 - </div> 33 - </a> 26 + <div class="min-w-0"> 27 + @if(displayName) 28 + <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight truncate">{{ displayName }}</h1> 29 + <div class="text-gray-600 dark:text-gray-400 group-hover:underline truncate">{{ '@' + handle }}</div> 30 + @else 31 + <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight truncate">{{ '@' + handle }}</h1> 32 + @endif 33 + </div> 34 + </a> 35 + @if(auth && auth.isAuthenticated && !isOwnProfile) 36 + <div 37 + x-data="profileFollow" 38 + data-target-did="{{ profileDid }}" 39 + data-following="{{ viewerFollowUri ? 'true' : 'false' }}" 40 + data-follow-uri="{{ viewerFollowUri ? viewerFollowUri : '' }}" 41 + class="shrink-0" 42 + > 43 + <button 44 + x-on:click="toggle" 45 + x-on:mouseenter="onEnter" 46 + x-on:mouseleave="onLeave" 47 + x-on:focus="onEnter" 48 + x-on:blur="onLeave" 49 + x-bind:class="buttonClass" 50 + x-bind:disabled="busy" 51 + class="text-sm font-medium rounded-full px-4 py-1.5 min-w-[96px] transition-colors duration-150 cursor-pointer disabled:cursor-wait" 52 + aria-label="Follow" 53 + > 54 + <span x-text="label"></span> 55 + </button> 56 + </div> 57 + @endif 58 + </div> 34 59 35 60 {{-- Controls --}} 36 61 <div class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-6">
+3
start/routes.ts
··· 16 16 const ProfileController = () => import('#controllers/profile_controller') 17 17 const OAuthController = () => import('#controllers/oauth_controller') 18 18 const EngagementController = () => import('#controllers/engagement_controller') 19 + const FollowController = () => import('#controllers/follow_controller') 19 20 const HealthChecksController = () => import('#controllers/health_checks_controller') 20 21 const OgImageController = () => import('#controllers/og_image_controller') 21 22 const SitemapController = () => import('#controllers/sitemap_controller') ··· 83 84 router.delete('/like', [EngagementController, 'deleteLike']).as('api.like.delete') 84 85 router.post('/repost', [EngagementController, 'repost']).as('api.repost.create') 85 86 router.delete('/repost', [EngagementController, 'deleteRepost']).as('api.repost.delete') 87 + router.post('/follow', [FollowController, 'follow']).as('api.follow.create') 88 + router.delete('/follow', [FollowController, 'unfollow']).as('api.follow.delete') 86 89 }) 87 90 .prefix('/api') 88 91 .use(middleware.auth())
+89
tests/functional/follow.spec.ts
··· 1 + /** 2 + * Functional tests for the follow API (/api/follow). 3 + * 4 + * We can't hit real Bluesky in tests, so we focus on: 5 + * - Auth guard (401 for unauthenticated requests) 6 + * - Input validation (422 for missing / invalid body) 7 + * - Self-follow rejection (400 — following yourself isn't meaningful) 8 + */ 9 + import { test } from '@japa/runner' 10 + import testUtils from '@adonisjs/core/services/test_utils' 11 + import Account from '#models/account' 12 + 13 + test.group('Follow API | auth guard', () => { 14 + test('POST /api/follow without auth returns 401', async ({ client }) => { 15 + const response = await client 16 + .post('/api/follow') 17 + .withCsrfToken() 18 + .header('Accept', 'application/json') 19 + .json({ did: 'did:plc:target123' }) 20 + response.assertStatus(401) 21 + }) 22 + 23 + test('DELETE /api/follow without auth returns 401', async ({ client }) => { 24 + const response = await client 25 + .delete('/api/follow') 26 + .withCsrfToken() 27 + .header('Accept', 'application/json') 28 + .json({ uri: 'at://did:plc:abc/app.bsky.graph.follow/xyz' }) 29 + response.assertStatus(401) 30 + }) 31 + }) 32 + 33 + test.group('Follow API | validation', (group) => { 34 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 35 + 36 + test('POST /api/follow with missing body returns 422', async ({ client }) => { 37 + const account = await Account.create({ 38 + did: 'did:plc:followvalid1', 39 + handle: 'followvalid.bsky.social', 40 + sessionData: '{}', 41 + createdAt: Date.now(), 42 + updatedAt: Date.now(), 43 + }) 44 + 45 + const response = await client 46 + .post('/api/follow') 47 + .loginAs(account) 48 + .withCsrfToken() 49 + .header('Accept', 'application/json') 50 + .json({}) 51 + response.assertStatus(422) 52 + }) 53 + 54 + test('DELETE /api/follow with missing body returns 422', async ({ client }) => { 55 + const account = await Account.create({ 56 + did: 'did:plc:followvalid2', 57 + handle: 'followvalid2.bsky.social', 58 + sessionData: '{}', 59 + createdAt: Date.now(), 60 + updatedAt: Date.now(), 61 + }) 62 + 63 + const response = await client 64 + .delete('/api/follow') 65 + .loginAs(account) 66 + .withCsrfToken() 67 + .header('Accept', 'application/json') 68 + .json({}) 69 + response.assertStatus(422) 70 + }) 71 + 72 + test('POST /api/follow with own DID returns 400', async ({ client }) => { 73 + const account = await Account.create({ 74 + did: 'did:plc:selffollow', 75 + handle: 'selffollow.bsky.social', 76 + sessionData: '{}', 77 + createdAt: Date.now(), 78 + updatedAt: Date.now(), 79 + }) 80 + 81 + const response = await client 82 + .post('/api/follow') 83 + .loginAs(account) 84 + .withCsrfToken() 85 + .header('Accept', 'application/json') 86 + .json({ did: 'did:plc:selffollow' }) 87 + response.assertStatus(400) 88 + }) 89 + })
+200
tests/functional/profile_controller.spec.ts
··· 709 709 assert.include(html, 'Secret post for followers') 710 710 assert.notInclude(html, 'Sign in to view') 711 711 }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 712 + 713 + // Follow button: rendered for authenticated viewers looking at someone else's profile 714 + test('profile page renders Follow button for authenticated viewer on other profile', async ({ 715 + client, 716 + assert, 717 + swap, 718 + }) => { 719 + swap(ClickHouseStore, store) 720 + swap(AtprotoClient, { 721 + async getProfile() { 722 + return { displayName: null, avatarUrl: null } 723 + }, 724 + } as unknown as AtprotoClient) 725 + swap(AtprotoOAuthService, { 726 + async getAgent() { 727 + return { 728 + app: { 729 + bsky: { 730 + feed: { 731 + async getPosts() { 732 + return { data: { posts: [] } } 733 + }, 734 + }, 735 + actor: { 736 + async getProfile() { 737 + return { data: { viewer: {} } } 738 + }, 739 + }, 740 + }, 741 + }, 742 + } 743 + }, 744 + } as unknown as AtprotoOAuthService) 745 + 746 + await TrackedProfile.create({ 747 + did: 'did:plc:followtarget001', 748 + handle: 'target.bsky.social', 749 + firstSeenAt: Date.now(), 750 + backfilledAt: Date.now(), 751 + }) 752 + 753 + const account = await Account.create({ 754 + did: 'did:plc:followviewer001', 755 + handle: 'followviewer.bsky.social', 756 + sessionData: '{}', 757 + createdAt: Date.now(), 758 + updatedAt: Date.now(), 759 + }) 760 + 761 + const response = await client.get('/profile/target.bsky.social/likes').loginAs(account) 762 + response.assertStatus(200) 763 + const html = response.text() 764 + assert.include(html, 'x-data="profileFollow"') 765 + assert.include(html, 'data-target-did="did:plc:followtarget001"') 766 + assert.include(html, 'data-following="false"') 767 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 768 + 769 + // Follow button: shows "following" state when viewer already follows the profile 770 + test('profile page marks Follow button as following when viewer already follows', async ({ 771 + client, 772 + assert, 773 + swap, 774 + }) => { 775 + swap(ClickHouseStore, store) 776 + swap(AtprotoClient, { 777 + async getProfile() { 778 + return { displayName: null, avatarUrl: null } 779 + }, 780 + } as unknown as AtprotoClient) 781 + swap(AtprotoOAuthService, { 782 + async getAgent() { 783 + return { 784 + app: { 785 + bsky: { 786 + feed: { 787 + async getPosts() { 788 + return { data: { posts: [] } } 789 + }, 790 + }, 791 + actor: { 792 + async getProfile() { 793 + return { 794 + data: { 795 + viewer: { 796 + following: 'at://did:plc:followviewer002/app.bsky.graph.follow/followrkey', 797 + }, 798 + }, 799 + } 800 + }, 801 + }, 802 + }, 803 + }, 804 + } 805 + }, 806 + } as unknown as AtprotoOAuthService) 807 + 808 + await TrackedProfile.create({ 809 + did: 'did:plc:followtarget002', 810 + handle: 'target2.bsky.social', 811 + firstSeenAt: Date.now(), 812 + backfilledAt: Date.now(), 813 + }) 814 + 815 + const account = await Account.create({ 816 + did: 'did:plc:followviewer002', 817 + handle: 'followviewer2.bsky.social', 818 + sessionData: '{}', 819 + createdAt: Date.now(), 820 + updatedAt: Date.now(), 821 + }) 822 + 823 + const response = await client.get('/profile/target2.bsky.social/likes').loginAs(account) 824 + response.assertStatus(200) 825 + const html = response.text() 826 + assert.include(html, 'data-following="true"') 827 + assert.include( 828 + html, 829 + 'data-follow-uri="at://did:plc:followviewer002/app.bsky.graph.follow/followrkey"' 830 + ) 831 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 832 + 833 + // Follow button: NOT rendered when viewing your own profile 834 + test('profile page does not render Follow button on own profile', async ({ 835 + client, 836 + assert, 837 + swap, 838 + }) => { 839 + swap(ClickHouseStore, store) 840 + swap(AtprotoClient, { 841 + async getProfile() { 842 + return { displayName: null, avatarUrl: null } 843 + }, 844 + } as unknown as AtprotoClient) 845 + swap(AtprotoOAuthService, { 846 + async getAgent() { 847 + return { 848 + app: { 849 + bsky: { 850 + feed: { 851 + async getPosts() { 852 + return { data: { posts: [] } } 853 + }, 854 + }, 855 + actor: { 856 + async getProfile() { 857 + throw new Error('should not be called for own profile') 858 + }, 859 + }, 860 + }, 861 + }, 862 + } 863 + }, 864 + } as unknown as AtprotoOAuthService) 865 + 866 + await TrackedProfile.create({ 867 + did: 'did:plc:ownprofile001', 868 + handle: 'ownprofile.bsky.social', 869 + firstSeenAt: Date.now(), 870 + backfilledAt: Date.now(), 871 + }) 872 + 873 + const account = await Account.create({ 874 + did: 'did:plc:ownprofile001', 875 + handle: 'ownprofile.bsky.social', 876 + sessionData: '{}', 877 + createdAt: Date.now(), 878 + updatedAt: Date.now(), 879 + }) 880 + 881 + const response = await client.get('/profile/ownprofile.bsky.social/likes').loginAs(account) 882 + response.assertStatus(200) 883 + const html = response.text() 884 + assert.notInclude(html, 'x-data="profileFollow"') 885 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 886 + 887 + // Follow button: NOT rendered for unauthenticated viewers 888 + test('profile page does not render Follow button for unauthenticated viewer', async ({ 889 + client, 890 + assert, 891 + swap, 892 + }) => { 893 + swap(ClickHouseStore, store) 894 + swap(AtprotoClient, { 895 + async getProfile() { 896 + return { displayName: null, avatarUrl: null } 897 + }, 898 + } as unknown as AtprotoClient) 899 + 900 + await TrackedProfile.create({ 901 + did: 'did:plc:anonview001', 902 + handle: 'anonview.bsky.social', 903 + firstSeenAt: Date.now(), 904 + backfilledAt: Date.now(), 905 + }) 906 + 907 + const response = await client.get('/profile/anonview.bsky.social/likes') 908 + response.assertStatus(200) 909 + const html = response.text() 910 + assert.notInclude(html, 'x-data="profileFollow"') 911 + }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 712 912 }) 713 913 714 914 // ---------------------------------------------------------------------------