feat(metrics): cached project metrics recomputed on ingest
This commit is contained in:
parent
dbf524b04c
commit
cc20cf4bfd
5 changed files with 173 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
27
src/app/actions/metrics.ts
Normal file
27
src/app/actions/metrics.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
76
src/lib/metrics/store.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue