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
68 changes: 68 additions & 0 deletions backend/src/application/plugins/get-plugin-weekly-loaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "bun:test";
import type { PluginWeeklyLoaders } from "@/domain/plugin";
import type { IPluginRepository } from "@/domain/ports/plugin-repository";
import { getPluginWeeklyLoaders } from "./get-plugin-weekly-loaders";

function makeDeps(impl: () => Promise<PluginWeeklyLoaders>) {
const calls: unknown[][] = [];
const plugins: IPluginRepository = {
listWithStats: async () => [],
listSkillsWithActivations: async () => [],
listTopUsers: async () => [],
findByName: async () => null,
upsert: async () => {},
upsertIfAbsent: async () => {},
update: async () => null,
updateStatusByMarketplace: async () => {},
markRemovedByMarketplace: async () => [],
reactivateRemovedByMarketplace: async () => [],
listNamesByMarketplace: async () => [],
orphanByMarketplace: async () => [],
deleteByMarketplace: async () => [],
getLoadStats: async () => ({ totalLoads: 0, uniqueLoadedPlugins: 0, uniqueLoaders: 0 }),
getWeeklyLoadersByVersion: async (...args: unknown[]) => {
calls.push(args);
return impl();
},
};
return { deps: { plugins }, calls };
}

describe("getPluginWeeklyLoaders", () => {
it("forwards plugin name and marketplace name to the repository", async () => {
const fixture: PluginWeeklyLoaders = {
weeks: [
{ weekStart: "2026-03-02", total: 4, perVersion: { "1.0.0": 4 } },
{ weekStart: "2026-03-09", total: 7, perVersion: { "1.0.0": 5, "1.1.0": 3 } },
],
versions: ["1.0.0", "1.1.0"],
};
const { deps, calls } = makeDeps(async () => fixture);

const result = await getPluginWeeklyLoaders(deps, {
pluginName: "my-plugin",
marketplaceName: "acme",
});

expect(result).toEqual(fixture);
expect(calls).toEqual([["my-plugin", "acme"]]);
});

it("passes a null marketplace through unchanged", async () => {
const { deps, calls } = makeDeps(async () => ({ weeks: [], versions: [] }));

await getPluginWeeklyLoaders(deps, { pluginName: "p", marketplaceName: null });

expect(calls).toEqual([["p", null]]);
});

it("propagates repository rejection", async () => {
const { deps } = makeDeps(async () => {
throw new Error("boom");
});

await expect(
getPluginWeeklyLoaders(deps, { pluginName: "p", marketplaceName: null }),
).rejects.toThrow("boom");
});
});
9 changes: 9 additions & 0 deletions backend/src/application/plugins/get-plugin-weekly-loaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { PluginWeeklyLoaders } from "@/domain/plugin";
import type { IPluginRepository } from "@/domain/ports/plugin-repository";

export async function getPluginWeeklyLoaders(
deps: { plugins: IPluginRepository },
input: { pluginName: string; marketplaceName: string | null },
): Promise<PluginWeeklyLoaders> {
return deps.plugins.getWeeklyLoadersByVersion(input.pluginName, input.marketplaceName);
}
6 changes: 5 additions & 1 deletion backend/src/application/plugins/list-plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
PluginSkillActivation,
PluginUserActivation,
PluginVersionRow,
PluginWeeklyLoaders,
} from "@/domain/plugin";
import type { IPluginRepository } from "@/domain/ports/plugin-repository";
import type { IPluginVersionRepository } from "@/domain/ports/plugin-version-repository";
Expand All @@ -14,22 +15,25 @@ export interface PluginDrawerData {
topUsers: PluginUserActivation[];
versions: PluginVersionRow[];
latestVersion: string | null;
weeklyLoaders: PluginWeeklyLoaders;
}

export async function listPluginSkills(
deps: { plugins: IPluginRepository; pluginVersions: IPluginVersionRepository },
pluginName: string,
marketplaceName: string | null,
): Promise<PluginDrawerData> {
const [skills, topUsers, versions] = await Promise.all([
const [skills, topUsers, versions, weeklyLoaders] = await Promise.all([
deps.plugins.listSkillsWithActivations(pluginName),
deps.plugins.listTopUsers(pluginName, TOP_USERS_LIMIT),
deps.pluginVersions.listForPlugin(pluginName, marketplaceName),
deps.plugins.getWeeklyLoadersByVersion(pluginName, marketplaceName),
]);
return {
skills,
topUsers,
versions,
latestVersion: maxSemver(versions.map((v) => v.version)),
weeklyLoaders,
};
}
11 changes: 11 additions & 0 deletions backend/src/domain/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export interface PluginVersionSeen {
version: string;
}

export interface PluginWeeklyLoadersBucket {
weekStart: string;
total: number;
perVersion: Record<string, number>;
}

export interface PluginWeeklyLoaders {
weeks: PluginWeeklyLoadersBucket[];
versions: string[];
}

export interface NewPlugin {
pluginName: string;
marketplaceName: string | null;
Expand Down
5 changes: 5 additions & 0 deletions backend/src/domain/ports/plugin-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
PluginSkillActivation,
PluginStatus,
PluginUserActivation,
PluginWeeklyLoaders,
PluginWithStats,
} from "@/domain/plugin";
import type { TimeWindow } from "@/domain/ports/skill-repository";
Expand All @@ -31,4 +32,8 @@ export interface IPluginRepository {
orphanByMarketplace(marketplaceName: string): Promise<string[]>;
deleteByMarketplace(marketplaceName: string): Promise<string[]>;
getLoadStats(window: TimeWindow): Promise<PluginLoadStats>;
getWeeklyLoadersByVersion(
pluginName: string,
marketplaceName: string | null,
): Promise<PluginWeeklyLoaders>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
PluginSkillActivation,
PluginStatus,
PluginUserActivation,
PluginWeeklyLoaders,
PluginWeeklyLoadersBucket,
PluginWithStats,
} from "@/domain/plugin";

Expand Down Expand Up @@ -375,6 +377,86 @@ export class DrizzlePluginRepository implements IPluginRepository {
};
}

async getWeeklyLoadersByVersion(
pluginName: string,
marketplaceName: string | null,
): Promise<PluginWeeklyLoaders> {
// Marketplace matching mirrors listWithStats: events tagged with the
// synthetic "inline" marketplace bucket are normalized to empty so they
// join the null-marketplace plugin row.
const marketplaceKey = marketplaceName ?? "";
const rows = (await this.db.execute(sql`
WITH spine AS (
SELECT generate_series(
date_trunc('week', NOW()) - INTERVAL '8 weeks',
date_trunc('week', NOW()),
INTERVAL '1 week'
)::date AS week_start
),
loads AS (
SELECT
date_trunc('week', timestamp)::date AS week_start,
attributes->>'plugin.version' AS version,
user_email
FROM events
WHERE event_name = ${EVENT_NAMES.PLUGIN_LOADED}
AND attributes->>'plugin.name' = ${pluginName}
AND attributes->>'plugin.name' <> 'third-party'
AND COALESCE(NULLIF(attributes->>'marketplace.name', 'inline'), '') = ${marketplaceKey}
AND user_email IS NOT NULL
AND timestamp >= NOW() - INTERVAL '60 days'
),
per_version AS (
SELECT week_start, version, COUNT(DISTINCT user_email)::int AS loaders
FROM loads
WHERE version IS NOT NULL
GROUP BY 1, 2
),
totals AS (
SELECT week_start, COUNT(DISTINCT user_email)::int AS total
FROM loads
GROUP BY 1
)
SELECT
to_char(s.week_start, 'YYYY-MM-DD') AS "weekStart",
COALESCE(t.total, 0)::int AS "total",
pv.version AS "version",
pv.loaders AS "loaders"
FROM spine s
LEFT JOIN totals t ON t.week_start = s.week_start
LEFT JOIN per_version pv ON pv.week_start = s.week_start
ORDER BY s.week_start ASC, pv.version ASC
`)) as unknown as Array<{
weekStart: string;
total: number;
version: string | null;
loaders: number | null;
}>;

const bucketsByWeek = new Map<string, PluginWeeklyLoadersBucket>();
const versionTotals = new Map<string, number>();
for (const r of rows) {
let bucket = bucketsByWeek.get(r.weekStart);
if (!bucket) {
bucket = { weekStart: r.weekStart, total: r.total, perVersion: {} };
bucketsByWeek.set(r.weekStart, bucket);
}
if (r.version !== null && r.loaders !== null) {
bucket.perVersion[r.version] = r.loaders;
versionTotals.set(r.version, (versionTotals.get(r.version) ?? 0) + r.loaders);
}
}

const weeks = Array.from(bucketsByWeek.values()).sort((a, b) =>
a.weekStart.localeCompare(b.weekStart),
);
const versions = Array.from(versionTotals.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([v]) => v);

return { weeks, versions };
}

async deleteByMarketplace(marketplaceName: string): Promise<string[]> {
const deleted = await this.db
.delete(plugins)
Expand Down
Loading
Loading