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..357a00a5 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -847,6 +847,57 @@ 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 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]); + }); + + 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..eb8fb233 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,45 @@ 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 { + // 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`; + 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..5f40139c --- /dev/null +++ b/packages/backend/src/adapters/install-ranking.ts @@ -0,0 +1,17 @@ +/** + * 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 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. + */ +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;