import { all, chunks } from "./db"; import { accountDb, repoDb, stmt } from "./scoped-db"; import type { ScopedStatement } from "./scoped-db"; import { backfillDone, backfillRepos } from "./backfill"; /** * Activity rows live in each repository's RepoState; the contribution graph needs "this account's activity in a * time range". Each actor's AccountState keeps `activity_repos` (repository, first and last counted activity) so * the graph visits only those repositories. RepoState feeds it from every activity insert it runs. */ export const ACTIVITY_INDEX = "activity-repos"; /** Kinds that never count as contributions (contributions.ts COUNTED_ACTIVITY) and so are not indexed. */ const UNCOUNTED = new Set(["view", "push"]); const ACTIVITY_INSERT = /^\s*INSERT\s+INTO\s+activity\s*\(\s*id\s*,\s*actor_id\s*,\s*repo_id\s*,\s*kind\s*,/i; export type ActivityTouch = { actorId: string; repoId: string; at: number }; /** The counted activity inserts among `statements` (repoActivityStatement's shape), for the actor index. */ export function activityTouches(statements: ScopedStatement[]): ActivityTouch[] { const out: ActivityTouch[] = []; for (const { sql, params } of statements) { if (!ACTIVITY_INSERT.test(sql)) continue; const [, actorId, repoId, kind, , createdAt] = params; if (typeof actorId !== "string" || typeof repoId !== "string" || UNCOUNTED.has(String(kind))) continue; out.push({ actorId, repoId, at: Number(createdAt) || Date.now() }); } return out; } export async function indexActivity(touches: ActivityTouch[]): Promise { await Promise.all( touches.map((touch) => accountDb(touch.actorId).run( `INSERT INTO activity_repos (repo_id, first_at, last_at) VALUES (?, ?, ?) ON CONFLICT (repo_id) DO UPDATE SET first_at = MIN(first_at, excluded.first_at), last_at = MAX(last_at, excluded.last_at)`, touch.repoId, touch.at, touch.at, ), ), ); } /** * Repositories `accountId` has counted activity in that overlaps `[start, end)` (all of them when no range), * from the index once it is complete; until then every repository, as before. */ export async function activityRepoIds( accountId: string, range: { start: number; end: number } | null, ): Promise { if (!(await backfillDone(ACTIVITY_INDEX))) { return (await all<{ id: string }>("SELECT id FROM repos")).map((row) => row.id); } const rows = range ? await accountDb(accountId).all<{ repo_id: string }>( "SELECT repo_id FROM activity_repos WHERE last_at >= ? AND first_at < ?", range.start, range.end, ) : await accountDb(accountId).all<{ repo_id: string }>("SELECT repo_id FROM activity_repos"); return rows.map((row) => row.repo_id); } /** The ids among `repoIds` that pass `filter` (SQL over `repos r`, may use ?1 for the account). */ export async function filterRepoIds( repoIds: string[], filter: string, accountId: string, ): Promise> { const out: Array<{ id: string; owner: string; name: string; private: number }> = []; for (const part of chunks(repoIds, 80)) { out.push( ...(await all<{ id: string; owner: string; name: string; private: number }>( `SELECT r.id, a.name AS owner, r.name, r.visibility != 'public' AS private FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id IN (${part.map((_, index) => `?${index + 2}`).join(", ")}) AND ${filter}`, accountId, ...part, )), ); } return out; } /** Cron step: indexes activity recorded before the index existed. */ export async function backfillActivityIndex(): Promise { await backfillRepos(ACTIVITY_INDEX, async (repoId) => { const rows = await repoDb(repoId).all<{ actor_id: string; first_at: number; last_at: number }>( `SELECT actor_id, MIN(created_at) AS first_at, MAX(created_at) AS last_at FROM activity WHERE actor_id IS NOT NULL AND kind NOT IN ('view', 'push') GROUP BY actor_id`, ); for (const row of rows) { await accountDb(row.actor_id).batch([ stmt( `INSERT INTO activity_repos (repo_id, first_at, last_at) VALUES (?, ?, ?) ON CONFLICT (repo_id) DO UPDATE SET first_at = MIN(first_at, excluded.first_at), last_at = MAX(last_at, excluded.last_at)`, repoId, row.first_at, row.last_at, ), ]); } }); }