From a3e39f812d8eaf469b0823f61dc3f8432e0b00e0 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:24:55 +0200 Subject: [PATCH 1/2] Serve the catalog's install order publicly for the website --- .../src/adapters/adapters.controller.ts | 23 +++++++++ .../src/adapters/adapters.service.spec.ts | 50 +++++++++++++++++++ .../backend/src/adapters/adapters.service.ts | 34 +++++++++++++ .../backend/src/adapters/install-ranking.ts | 15 ++++++ 4 files changed, 122 insertions(+) create mode 100644 packages/backend/src/adapters/install-ranking.ts diff --git a/packages/backend/src/adapters/adapters.controller.ts b/packages/backend/src/adapters/adapters.controller.ts index 4661b015..749cfba4 100644 --- a/packages/backend/src/adapters/adapters.controller.ts +++ b/packages/backend/src/adapters/adapters.controller.ts @@ -78,6 +78,29 @@ export class AdaptersPublicController { res.type('application/json').send(body); } + // Two path segments, so the authenticated `GET :slug` never sees it. + @Get('ranking/installs') + @ApiOperation({ + summary: 'Listed adapters, most-installed first', + description: + 'The slugs of listed adapters ordered by how many workspaces on this deployment have them installed, ' + + 'for the marketing website to order its marketplace and guides. Only the order is returned, never a count; ' + + 'adapters installed by fewer than 3 workspaces are left out. Public.', + }) + async installRanking( + @Headers('if-none-match') ifNoneMatch: string | undefined, + @Res() res: Response, + ) { + const { body, etag } = await this.adaptersService.installRanking(); + res.setHeader('ETag', etag); + res.setHeader('Cache-Control', PUBLIC_CATALOG_CACHE_CONTROL); + if (etagMatches(ifNoneMatch, etag)) { + res.status(304).end(); + return; + } + res.type('application/json').send(body); + } + // Two path segments, so the authenticated `GET :slug` never sees it. @Get(':slug/instructions') @ApiOperation({ diff --git a/packages/backend/src/adapters/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index cf4dc615..299d781d 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -847,6 +847,56 @@ describe('AdaptersService popular connectors', () => { }); }); +describe('AdaptersService install ranking', () => { + function service(opts: { rows?: Array<{ slug: string | null }>; fail?: boolean } = {}) { + const svc = Object.create(AdaptersService.prototype) as AdaptersService; + (svc as any).logger = { warn: jest.fn() }; + (svc as any).configService = { get: (k: string) => (k === 'DEPLOYMENT_MODE' ? 'cloud' : undefined) }; + const queryRaw = opts.fail + ? jest.fn().mockRejectedValue(new Error('db down')) + : jest.fn().mockResolvedValue(opts.rows ?? []); + (svc as any).prisma = { $queryRaw: queryRaw }; + return { svc, queryRaw }; + } + const slugsOf = async (svc: AdaptersService) => JSON.parse((await svc.installRanking()).body).slugs; + + it('keeps the order of the query, which ranks by installing workspaces', async () => { + const { svc } = service({ rows: [{ slug: 'telegram-bot' }, { slug: 'etsy' }, { slug: 'odoo' }] }); + expect(await slugsOf(svc)).toEqual(['telegram-bot', 'etsy', 'odoo']); + }); + + it('sends only slugs, never a count, and asks for at least three workspaces', async () => { + const { svc, queryRaw } = service({ rows: [{ slug: 'etsy' }] }); + expect(JSON.parse((await svc.installRanking()).body)).toEqual({ slugs: ['etsy'] }); + const [sql, ...values] = queryRaw.mock.calls[0]; + expect(sql.join('?')).toMatch(/HAVING COUNT\(DISTINCT c\.organization_id\) >= \?/); + expect(values).toEqual([3]); + }); + + it('leaves out adapters that are not listed here (self-host only on the cloud, unknown, null)', async () => { + const { svc } = service({ rows: [{ slug: 'etsy' }, { slug: 'no-such-adapter' }, { slug: 'idealista' }, { slug: null }] }); + expect(await slugsOf(svc)).toEqual(['etsy']); + }); + + it('ranks once per cache window, not on every request', async () => { + const { svc, queryRaw } = service({ rows: [{ slug: 'etsy' }] }); + await svc.installRanking(); + await svc.installRanking(); + expect(queryRaw).toHaveBeenCalledTimes(1); + }); + + it('serves the last good ranking when the query fails, and an empty one before any', async () => { + const { svc } = service({ fail: true }); + expect(await slugsOf(svc)).toEqual([]); + + const ok = service({ rows: [{ slug: 'etsy' }] }); + await ok.svc.installRanking(); + (ok.svc as any).installRankingCache.at = 0; + (ok.svc as any).prisma.$queryRaw = jest.fn().mockRejectedValue(new Error('db down')); + expect(await slugsOf(ok.svc)).toEqual(['etsy']); + }); +}); + describe('sap-s4hana-cloud: token URL and refused client credentials', () => { const adapter = require('./intl/sap-s4hana-cloud.json'); diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 11b045fe..d4d42e56 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -30,6 +30,7 @@ import { POPULAR_MIN_WORKSPACES, POPULAR_WINDOW_DAYS, } from './popular-connectors'; +import { INSTALL_RANKING_CACHE_MS, INSTALL_RANKING_MIN_WORKSPACES } from './install-ranking'; import { ConnectorsService } from '../connectors/connectors.service'; import { classifyToolExecutionError } from '../connectors/connector-error.util'; import { applyResponseTransform } from '../connectors/response-transform.util'; @@ -248,6 +249,39 @@ export class AdaptersService { return slugs; } + private installRankingCache: { at: number; ranking: SerializedBody } | null = null; + + /** + * `{ slugs }`: the listed adapters, most-installed first, as a ready-to-send + * body (see ./install-ranking.ts). Cached for six hours; when the query + * fails, the last good ranking (or an empty one) is served and the next + * request tries again. + */ + async installRanking(): Promise { + const now = Date.now(); + const cached = this.installRankingCache; + if (cached && now - cached.at < INSTALL_RANKING_CACHE_MS) return cached.ranking; + try { + const rows = await this.prisma.$queryRaw>` + SELECT c.config->>'adapterSlug' AS slug + FROM connectors c + WHERE c.config ? 'adapterSlug' + GROUP BY 1 + HAVING COUNT(DISTINCT c.organization_id) >= ${INSTALL_RANKING_MIN_WORKSPACES} + ORDER BY COUNT(DISTINCT c.organization_id) DESC, 1`; + const listed = new Set(this.listAll().map((a) => a.slug)); + const slugs = rows + .map((r) => r.slug) + .filter((s): s is string => typeof s === 'string' && listed.has(s)); + const ranking = serializeWithEtag({ slugs }); + this.installRankingCache = { at: now, ranking }; + return ranking; + } catch (err: any) { + this.logger.warn(`Could not rank adapters by installs: ${err?.message ?? err}`); + return cached?.ranking ?? serializeWithEtag({ slugs: [] }); + } + } + /** Catalog slugs this workspace already has a connector for. */ async installedAdapterSlugs(organizationId: string): Promise> { const rows = await this.prisma.connector.findMany({ diff --git a/packages/backend/src/adapters/install-ranking.ts b/packages/backend/src/adapters/install-ranking.ts new file mode 100644 index 00000000..03e369d5 --- /dev/null +++ b/packages/backend/src/adapters/install-ranking.ts @@ -0,0 +1,15 @@ +/** + * The catalog in install order, for the website: its connector marketplace + * and its guides lead with the connectors most workspaces install, instead of + * the newest guide or the longest tool list. + * + * Ranked from this deployment's own data: how many workspaces have a + * connector installed from the adapter today (one workspace with five Odoo + * connectors counts once). Only the order leaves the server, never a count, + * and an adapter needs MIN_WORKSPACES workspaces before it is ranked, so one + * tenant's choice never shows through. Adapters below that, and every adapter + * on a fresh self-host with no data, are left to the caller's own order. + */ +export const INSTALL_RANKING_MIN_WORKSPACES = 3; +/** Installs move slowly and the website caches for a day anyway. */ +export const INSTALL_RANKING_CACHE_MS = 6 * 60 * 60 * 1000; From 80a1bf071fbd3fa3e919648f464c751ee0210e76 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:05 +0200 Subject: [PATCH 2/2] Rank adapters only by workspaces that have used them --- packages/backend/src/adapters/adapters.service.spec.ts | 3 ++- packages/backend/src/adapters/adapters.service.ts | 6 ++++++ packages/backend/src/adapters/install-ranking.ts | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/adapters/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index 299d781d..357a00a5 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -865,10 +865,11 @@ describe('AdaptersService install ranking', () => { expect(await slugsOf(svc)).toEqual(['telegram-bot', 'etsy', 'odoo']); }); - it('sends only slugs, never a count, and asks for at least three workspaces', async () => { + it('sends only slugs, never a count, and asks for three workspaces that used it', async () => { const { svc, queryRaw } = service({ rows: [{ slug: 'etsy' }] }); expect(JSON.parse((await svc.installRanking()).body)).toEqual({ slugs: ['etsy'] }); const [sql, ...values] = queryRaw.mock.calls[0]; + expect(sql.join('?')).toMatch(/ti\.connector_id = c\.id AND ti\.status = 'SUCCESS'/); expect(sql.join('?')).toMatch(/HAVING COUNT\(DISTINCT c\.organization_id\) >= \?/); expect(values).toEqual([3]); }); diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index d4d42e56..eb8fb233 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -262,10 +262,16 @@ export class AdaptersService { const cached = this.installRankingCache; if (cached && now - cached.at < INSTALL_RANKING_CACHE_MS) return cached.ranking; try { + // The EXISTS goes through tool_invocations' (connector_id, created_at) + // index and stops at the first successful call of each connector. const rows = await this.prisma.$queryRaw>` SELECT c.config->>'adapterSlug' AS slug FROM connectors c WHERE c.config ? 'adapterSlug' + AND EXISTS ( + SELECT 1 FROM tool_invocations ti + WHERE ti.connector_id = c.id AND ti.status = 'SUCCESS' + ) GROUP BY 1 HAVING COUNT(DISTINCT c.organization_id) >= ${INSTALL_RANKING_MIN_WORKSPACES} ORDER BY COUNT(DISTINCT c.organization_id) DESC, 1`; diff --git a/packages/backend/src/adapters/install-ranking.ts b/packages/backend/src/adapters/install-ranking.ts index 03e369d5..5f40139c 100644 --- a/packages/backend/src/adapters/install-ranking.ts +++ b/packages/backend/src/adapters/install-ranking.ts @@ -4,8 +4,10 @@ * the newest guide or the longest tool list. * * Ranked from this deployment's own data: how many workspaces have a - * connector installed from the adapter today (one workspace with five Odoo - * connectors counts once). Only the order leaves the server, never a count, + * connector installed from the adapter today that has answered at least one + * call (one workspace with five Odoo connectors counts once). A connector + * installed and never used, as in the free starter pack many sign-ups add in + * one click, does not count. Only the order leaves the server, never a count, * and an adapter needs MIN_WORKSPACES workspaces before it is ranked, so one * tenant's choice never shows through. Adapters below that, and every adapter * on a fresh self-host with no data, are left to the caller's own order.