feat(metrics): cached project metrics recomputed on ingest

This commit is contained in:
Dave 2026-09-16 13:51:03 +02:00
parent dbf524b04c
commit cc20cf4bfd
5 changed files with 173 additions and 0 deletions

View file

@ -140,6 +140,24 @@ async function ensureProjectIngestFields(pb: PocketBase): Promise<void> {
const additions = [
{ name: "webhook_secret", type: "text", required: false, max: 80 },
{ name: "last_ingest_at", type: "date", required: false },
{
name: "session_gap_minutes",
type: "number",
required: false,
onlyInt: true,
},
{
name: "pre_session_minutes",
type: "number",
required: false,
onlyInt: true,
},
{
name: "post_session_minutes",
type: "number",
required: false,
onlyInt: true,
},
];
const fields = [...(projects.fields as unknown[])];
@ -712,6 +730,44 @@ async function main(): Promise<void> {
deleteRule: null,
});
// 10. project_metrics — a computed cache, rebuilt on ingest. The dashboard
// reads this rather than aggregating hundreds of commit rows per page view.
await getOrCreateCollection(pb, {
name: "project_metrics",
type: "base",
listRule: AUTHED,
viewRule: AUTHED,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "project",
type: "relation",
required: true,
collectionId: (await pb.collections.getOne("projects")).id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{ name: "computed_at", type: "date", required: true },
{ name: "payload", type: "json", required: true, maxSize: 4_000_000 },
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_metrics_project ON project_metrics (project)",
],
});
await applyRules(pb, "project_metrics", {
listRule: scopedRead("project.client"),
viewRule: scopedRead("project.client"),
createRule: null,
updateRule: null,
deleteRule: null,
});
// Add ingest-related fields to projects (only if missing — the projects
// collection already exists from earlier migrations).
await ensureProjectIngestFields(pb);

View file

@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/auth/session";
import { recomputeMetrics } from "@/lib/metrics/store";
/**
* Recompute on demand.
*
* Needed because the constants can change without any commit arriving, and
* because a client asking about a stale figure should be answerable in one
* click rather than by waiting for the next push.
*/
export async function recomputeMetricsAction(input: {
projectId: string;
clientSlug: string;
projectSlug: string;
}): Promise<{ commits: number }> {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
const metrics = await recomputeMetrics(input.projectId);
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`);
return { commits: metrics.totalCommits };
}

View file

@ -174,6 +174,10 @@ export async function ingestProject(
const { relinkProject } = await import("@/lib/linking/engine");
await relinkProject(project.id);
// And the dashboard figures must follow.
const { recomputeMetrics } = await import("@/lib/metrics/store");
await recomputeMetrics(project.id);
return { created, skipped, pagesRead: page };
}

76
src/lib/metrics/store.ts Normal file
View file

@ -0,0 +1,76 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import { computeProjectMetrics, type ProjectMetrics } from "@/lib/metrics/compute";
import {
DEFAULT_SESSION_CONSTANTS,
type SessionConstants,
} from "@/lib/metrics/sessions";
import type { CommitRecord, ProjectMetricsRecord, ProjectRecord } from "@/lib/pb/types";
/**
* A project's tuning constants, falling back to the defaults.
*
* Zero and undefined both mean "not set". A project must never compute with a
* gap of zero minutes — every commit would become its own session and the
* estimate would be padding multiplied by commit count.
*/
export function constantsFor(project: Partial<ProjectRecord>): SessionConstants {
return {
sessionGapMinutes:
project.session_gap_minutes || DEFAULT_SESSION_CONSTANTS.sessionGapMinutes,
preSessionMinutes:
project.pre_session_minutes || DEFAULT_SESSION_CONSTANTS.preSessionMinutes,
postSessionMinutes:
project.post_session_minutes || DEFAULT_SESSION_CONSTANTS.postSessionMinutes,
};
}
/**
* Recompute and cache a project's metrics.
*
* The constants used are stored inside the payload by computeProjectMetrics, so
* a figure the client is looking at can always be explained by the settings
* that produced it rather than by whatever they are when someone later asks.
*/
export async function recomputeMetrics(projectId: string): Promise<ProjectMetrics> {
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(projectId);
const commits = await pb.collection("commits").getFullList<CommitRecord>({
filter: `project = "${projectId}"`,
fields: "type,scope,authored_at,is_merge",
});
const metrics = computeProjectMetrics(
commits.map((c) => ({
type: c.type ?? "other",
scope: c.scope ?? "",
authoredAt: c.authored_at,
isMerge: Boolean(c.is_merge),
})),
constantsFor(project)
);
const payload = {
project: projectId,
computed_at: new Date().toISOString(),
payload: metrics,
};
const existing = await pb
.collection("project_metrics")
.getFirstListItem<ProjectMetricsRecord>(`project = "${projectId}"`)
.catch(() => null);
if (existing) {
await pb.collection("project_metrics").update(existing.id, payload);
} else {
await pb.collection("project_metrics").create(payload);
}
return metrics;
}

View file

@ -52,6 +52,9 @@ export interface ProjectRecord extends BaseRecord {
/** Server-only. Must never appear in a browser payload. */
webhook_secret: string;
last_ingest_at: string;
session_gap_minutes: number;
pre_session_minutes: number;
post_session_minutes: number;
}
export interface ColumnRecord extends BaseRecord {
@ -96,6 +99,13 @@ export interface CommentRecord extends BaseRecord {
export type CommitLinkSource = "explicit" | "scope" | "manual" | "none";
export interface ProjectMetricsRecord extends BaseRecord {
project: string;
computed_at: string;
/** A serialised ProjectMetrics from @/lib/metrics/compute. */
payload: unknown;
}
export interface CommitRecord extends BaseRecord {
project: string;
sha: string;