Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/backend/src/adapters/adapters.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
51 changes: 51 additions & 0 deletions packages/backend/src/adapters/adapters.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/adapters/adapters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SerializedBody> {
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<Array<{ slug: string | null }>>`
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<Set<string>> {
const rows = await this.prisma.connector.findMany({
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/adapters/install-ranking.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading