* Site audit P0 (1/3): issue engine, incremental persistence, block detection
Server-side foundation of the P0 feature set from docs/site-audit-pm-research.md:
- Issue engine: shared registry of issue types (severity, explanation,
how-to-fix). Per-page reporters run inside crawl steps; cross-page checks
(duplicate titles/descriptions/content, broken internal links, redirect
chains/loops, orphan pages) run at finalize as SQL over the persisted crawl.
- New audit_links + audit_issues tables, audit_pages columns (depth, content
hash, header signals, fetch class, sitemap flag); audit tables moved to
src/db/{,pg/}audit.schema.ts; migrations 0029 (D1) / 0006 (PG).
- Incremental persistence: pages/links/issues written inside each crawl-batch
step with deterministic row ids + upserts (retry idempotent); slim step
state; robots.txt checkpointed as step state; merged progress steps keep a
10k-page crawl within the Workflows step budget.
- Crawler: manual redirect handling with inline follow of normalization-
equivalent redirects, response header capture (X-Robots-Tag, Link
rel=canonical), BFS depth, sitemap-last seeding, SSRF check on discovered
links, honest 'we were blocked' classification (403/429/cf-mitigated/
challenge).
- MCP: run_site_audit, get_audit_status, get_audit_issues, get_audit_pages;
limitTier resolved via shared AuditService.resolveAuditLimitTier.
- Lighthouse strategies reduced to auto/none (legacy all/manual map on read).
- Self-healing: getStatus reconciles audits whose workflow instance errored/
terminated without reaching mark-failed.
The Issues UI and the badseo.dev e2e fixture site stack on top of this PR.
Deploy notes: run db:migrate:prod (additive); terminate running audits before
deploying — the workflow step structure changed and in-flight instances cannot
replay under the new code (a finalize guard fails them loudly instead of
completing empty).
* Store only internal link edges in audit_links
Both consumers (broken-internal-link and orphan checks) filter on
isInternal; per-page external counts already live on audit_pages.
Dropping external rows cuts stored edges on outbound-heavy sites.
Column stays so P1 external-link checks can re-add rows without a
migration.
* Review fixes: failAudit CAS guard, dedupe hash helpers, cheaper checks
- failAudit only transitions running audits, so the getStatus reconciler
can't flip a just-completed audit to failed when it races finalize
- collapse the duplicate SHA-256 helper into audit/ids.ts
- finalize integrity guard uses a limit-1 existence probe instead of
fetching every page row
- get_audit_status MCP tool no longer reads the audit row twice when an
explicit auditId is given
446 lines
12 KiB
TypeScript
446 lines
12 KiB
TypeScript
/**
|
|
* Data access layer for site audit tables.
|
|
* Provider-aware (D1 or Postgres) via the `@/db` handle. Covers audits,
|
|
* audit_pages, audit_links, audit_issues, and stored Lighthouse results.
|
|
*/
|
|
import { and, desc, eq } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import {
|
|
audits,
|
|
auditIssues,
|
|
auditLighthouseResults,
|
|
auditLinks,
|
|
auditPages,
|
|
} from "@/db/schema";
|
|
import { executeInBatches } from "@/db/runBatch";
|
|
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
|
import { deterministicAuditRowId } from "@/server/lib/audit/ids";
|
|
import type { DetectedIssue } from "@/server/lib/audit/issues/page-reporters";
|
|
import type {
|
|
AuditConfig,
|
|
CrawledPageResult,
|
|
LighthouseResult,
|
|
} from "@/server/lib/audit/types";
|
|
|
|
// Only internal links are stored: both consumers (broken-internal-link and
|
|
// orphan checks) filter on isInternal, and per-page external counts already
|
|
// live on audit_pages. External rows come back when P1 adds external-link
|
|
// checks. Mega-menu/footer-heavy sites can carry 1000+ links per page; cap
|
|
// what we store so a 10k-page crawl can't write tens of millions of link rows.
|
|
const MAX_STORED_LINKS_PER_PAGE = 500;
|
|
|
|
async function createAudit(data: {
|
|
id: string;
|
|
projectId: string;
|
|
startedByUserId: string;
|
|
startUrl: string;
|
|
workflowInstanceId: string;
|
|
config: AuditConfig;
|
|
pagesTotal: number;
|
|
lighthouseTotal: number;
|
|
}) {
|
|
await db.insert(audits).values({
|
|
id: data.id,
|
|
projectId: data.projectId,
|
|
startedByUserId: data.startedByUserId,
|
|
startUrl: data.startUrl,
|
|
workflowInstanceId: data.workflowInstanceId,
|
|
config: JSON.stringify(data.config),
|
|
status: "running",
|
|
pagesTotal: data.pagesTotal,
|
|
lighthouseTotal: data.lighthouseTotal,
|
|
currentPhase: "discovery",
|
|
});
|
|
}
|
|
|
|
async function updateAuditProgress(
|
|
auditId: string,
|
|
workflowInstanceId: string,
|
|
data: {
|
|
pagesCrawled?: number;
|
|
pagesTotal?: number;
|
|
lighthouseTotal?: number;
|
|
lighthouseCompleted?: number;
|
|
lighthouseFailed?: number;
|
|
currentPhase?: string;
|
|
},
|
|
) {
|
|
await db
|
|
.update(audits)
|
|
.set(data)
|
|
.where(
|
|
and(
|
|
eq(audits.id, auditId),
|
|
eq(audits.workflowInstanceId, workflowInstanceId),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function completeAudit(
|
|
auditId: string,
|
|
workflowInstanceId: string,
|
|
data: {
|
|
pagesCrawled: number;
|
|
pagesTotal: number;
|
|
},
|
|
) {
|
|
await db
|
|
.update(audits)
|
|
.set({
|
|
status: "completed",
|
|
completedAt: new Date().toISOString(),
|
|
currentPhase: "completed",
|
|
...data,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(audits.id, auditId),
|
|
eq(audits.workflowInstanceId, workflowInstanceId),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function failAudit(auditId: string, workflowInstanceId: string) {
|
|
// Only a running audit can transition to failed: the getStatus reconciler
|
|
// races the workflow's own finalize, and without this guard it could flip
|
|
// a just-completed audit to failed.
|
|
await db
|
|
.update(audits)
|
|
.set({
|
|
status: "failed",
|
|
completedAt: new Date().toISOString(),
|
|
currentPhase: "failed",
|
|
})
|
|
.where(
|
|
and(
|
|
eq(audits.id, auditId),
|
|
eq(audits.workflowInstanceId, workflowInstanceId),
|
|
eq(audits.status, "running"),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function getAuditForWorkflow(
|
|
auditId: string,
|
|
workflowInstanceId: string,
|
|
) {
|
|
return db.query.audits.findFirst({
|
|
where: and(
|
|
eq(audits.id, auditId),
|
|
eq(audits.workflowInstanceId, workflowInstanceId),
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist one crawl batch (pages + link edges + per-page issues).
|
|
* Called inside the crawl-batch Workflow step so results land in D1
|
|
* incrementally instead of accumulating in memory until finalize.
|
|
*
|
|
* Idempotent on step retry: callers assign deterministic page ids
|
|
* (deterministicAuditRowId) and link/issue ids are derived from stable
|
|
* content. Page rows upsert (a retried fetch may legitimately differ — last
|
|
* attempt wins, matching what the step returns); links and issues are
|
|
* insert-or-ignore.
|
|
*/
|
|
async function insertCrawledBatch(
|
|
auditId: string,
|
|
pages: CrawledPageResult[],
|
|
issues: DetectedIssue[],
|
|
) {
|
|
await executeInBatches(pages, (tx, page) => {
|
|
const dataColumns = {
|
|
url: page.url,
|
|
statusCode: page.statusCode,
|
|
redirectUrl: page.redirectUrl,
|
|
title: page.title,
|
|
metaDescription: page.metaDescription,
|
|
canonicalUrl: page.canonicalUrl,
|
|
robotsMeta: page.robotsMeta,
|
|
xRobotsTag: page.xRobotsTag,
|
|
headerCanonicalUrl: page.headerCanonicalUrl,
|
|
ogTitle: page.ogTitle,
|
|
ogDescription: page.ogDescription,
|
|
ogImage: page.ogImage,
|
|
h1Count: page.h1Count,
|
|
h2Count: page.h2Count,
|
|
h3Count: page.h3Count,
|
|
h4Count: page.h4Count,
|
|
h5Count: page.h5Count,
|
|
h6Count: page.h6Count,
|
|
headingOrderJson: JSON.stringify(page.headingOrder),
|
|
wordCount: page.wordCount,
|
|
contentHash: page.contentHash,
|
|
imagesTotal: page.imagesTotal,
|
|
imagesMissingAlt: page.imagesMissingAlt,
|
|
imagesJson: JSON.stringify(page.images),
|
|
internalLinkCount: page.links.filter((l) => l.isInternal).length,
|
|
externalLinkCount: page.links.filter((l) => !l.isInternal).length,
|
|
hasStructuredData: page.hasStructuredData,
|
|
hreflangTagsJson: JSON.stringify(page.hreflangTags),
|
|
isIndexable: page.isIndexable,
|
|
fetchClass: page.fetchClass,
|
|
crawlDepth: page.crawlDepth,
|
|
inSitemap: page.inSitemap,
|
|
responseTimeMs: page.responseTimeMs,
|
|
};
|
|
return tx
|
|
.insert(auditPages)
|
|
.values({ id: page.id, auditId, ...dataColumns })
|
|
.onConflictDoUpdate({ target: auditPages.id, set: dataColumns });
|
|
});
|
|
|
|
const linkRows = await Promise.all(
|
|
pages.flatMap((page) =>
|
|
page.links
|
|
.filter((link) => link.isInternal)
|
|
.slice(0, MAX_STORED_LINKS_PER_PAGE)
|
|
.map(async (link) => ({
|
|
id: await deterministicAuditRowId(auditId, page.url, link.targetUrl),
|
|
auditId,
|
|
sourcePageId: page.id,
|
|
sourceUrl: page.url,
|
|
targetUrl: link.targetUrl,
|
|
anchor: link.anchor,
|
|
isInternal: link.isInternal,
|
|
isNofollow: link.isNofollow,
|
|
})),
|
|
),
|
|
);
|
|
await executeInBatches(linkRows, (tx, row) =>
|
|
tx.insert(auditLinks).values(row).onConflictDoNothing(),
|
|
);
|
|
|
|
await insertIssues(auditId, issues);
|
|
}
|
|
|
|
async function insertIssues(auditId: string, issues: DetectedIssue[]) {
|
|
const issueRows = await Promise.all(
|
|
issues.map(async (issue) => ({
|
|
id: await deterministicAuditRowId(
|
|
auditId,
|
|
issue.pageUrl,
|
|
issue.issueType,
|
|
issue.dedupeKey ?? "",
|
|
),
|
|
auditId,
|
|
pageId: issue.pageId,
|
|
pageUrl: issue.pageUrl,
|
|
issueType: issue.issueType,
|
|
severity: AUDIT_ISSUE_TYPES[issue.issueType].severity,
|
|
detailsJson: issue.details ? JSON.stringify(issue.details) : null,
|
|
})),
|
|
);
|
|
await executeInBatches(issueRows, (tx, row) =>
|
|
tx.insert(auditIssues).values(row).onConflictDoNothing(),
|
|
);
|
|
}
|
|
|
|
async function insertLighthouseResults(
|
|
auditId: string,
|
|
lighthouseResults: LighthouseResult[],
|
|
) {
|
|
const rows = await Promise.all(
|
|
lighthouseResults.map(async (result) => ({
|
|
id: await deterministicAuditRowId(
|
|
auditId,
|
|
result.pageId,
|
|
result.strategy,
|
|
),
|
|
auditId,
|
|
pageId: result.pageId,
|
|
strategy: result.strategy,
|
|
performanceScore: result.performanceScore,
|
|
accessibilityScore: result.accessibilityScore,
|
|
bestPracticesScore: result.bestPracticesScore,
|
|
seoScore: result.seoScore,
|
|
lcpMs: result.lcpMs,
|
|
cls: result.cls,
|
|
inpMs: result.inpMs,
|
|
ttfbMs: result.ttfbMs,
|
|
errorMessage: result.errorMessage ?? null,
|
|
r2Key: result.r2Key ?? null,
|
|
payloadSizeBytes: result.payloadSizeBytes ?? null,
|
|
})),
|
|
);
|
|
// Upsert: a step retry can charge a second DataForSEO call whose result
|
|
// must not be silently dropped in favor of a failed first attempt.
|
|
await executeInBatches(rows, (tx, row) => {
|
|
const { id: _id, auditId: _auditId, ...dataColumns } = row;
|
|
return tx.insert(auditLighthouseResults).values(row).onConflictDoUpdate({
|
|
target: auditLighthouseResults.id,
|
|
set: dataColumns,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getAuditForProject(auditId: string, projectId: string) {
|
|
return db.query.audits.findFirst({
|
|
where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)),
|
|
});
|
|
}
|
|
|
|
async function getLatestAuditForProject(projectId: string) {
|
|
return db.query.audits.findFirst({
|
|
where: eq(audits.projectId, projectId),
|
|
orderBy: desc(audits.startedAt),
|
|
});
|
|
}
|
|
|
|
async function getIssuesForAudit(
|
|
auditId: string,
|
|
filters: { severity?: "critical" | "warning" | "info"; issueType?: string },
|
|
) {
|
|
return db.query.auditIssues.findMany({
|
|
where: and(
|
|
eq(auditIssues.auditId, auditId),
|
|
filters.severity ? eq(auditIssues.severity, filters.severity) : undefined,
|
|
filters.issueType
|
|
? eq(auditIssues.issueType, filters.issueType)
|
|
: undefined,
|
|
),
|
|
});
|
|
}
|
|
|
|
async function getPagesForAudit(auditId: string) {
|
|
return db
|
|
.select({
|
|
id: auditPages.id,
|
|
url: auditPages.url,
|
|
statusCode: auditPages.statusCode,
|
|
fetchClass: auditPages.fetchClass,
|
|
redirectUrl: auditPages.redirectUrl,
|
|
title: auditPages.title,
|
|
metaDescription: auditPages.metaDescription,
|
|
wordCount: auditPages.wordCount,
|
|
isIndexable: auditPages.isIndexable,
|
|
crawlDepth: auditPages.crawlDepth,
|
|
inSitemap: auditPages.inSitemap,
|
|
internalLinkCount: auditPages.internalLinkCount,
|
|
responseTimeMs: auditPages.responseTimeMs,
|
|
})
|
|
.from(auditPages)
|
|
.where(eq(auditPages.auditId, auditId));
|
|
}
|
|
|
|
async function hasPagesForAudit(auditId: string): Promise<boolean> {
|
|
const rows = await db
|
|
.select({ id: auditPages.id })
|
|
.from(auditPages)
|
|
.where(eq(auditPages.auditId, auditId))
|
|
.limit(1);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
async function getAuditsByProject(projectId: string) {
|
|
const rows = await db
|
|
.select({ audit: audits })
|
|
.from(audits)
|
|
.where(eq(audits.projectId, projectId))
|
|
.orderBy(desc(audits.startedAt));
|
|
|
|
return rows.map(({ audit }) => audit);
|
|
}
|
|
|
|
async function getAuditUsageForUser(userId: string) {
|
|
const rows = await db.query.audits.findMany({
|
|
where: eq(audits.startedByUserId, userId),
|
|
columns: {
|
|
status: true,
|
|
pagesTotal: true,
|
|
lighthouseTotal: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
capacityUnits: rows.reduce(
|
|
(total, row) => total + row.pagesTotal + row.lighthouseTotal,
|
|
0,
|
|
),
|
|
runningCount: rows.filter((row) => row.status === "running").length,
|
|
};
|
|
}
|
|
|
|
async function getAuditResultsForProject(auditId: string, projectId: string) {
|
|
const audit = await getAuditForProject(auditId, projectId);
|
|
if (!audit) {
|
|
return { audit: null, pages: [], lighthouse: [], issues: [] };
|
|
}
|
|
|
|
const [pages, lighthouse, issues] = await Promise.all([
|
|
db.query.auditPages.findMany({
|
|
where: eq(auditPages.auditId, auditId),
|
|
}),
|
|
db.query.auditLighthouseResults.findMany({
|
|
where: eq(auditLighthouseResults.auditId, auditId),
|
|
}),
|
|
db.query.auditIssues.findMany({
|
|
where: eq(auditIssues.auditId, auditId),
|
|
}),
|
|
]);
|
|
|
|
return { audit, pages, lighthouse, issues };
|
|
}
|
|
|
|
async function getLighthouseResultById(input: {
|
|
lighthouseResultId: string;
|
|
projectId: string;
|
|
}) {
|
|
const lighthouse = await db.query.auditLighthouseResults.findFirst({
|
|
where: eq(auditLighthouseResults.id, input.lighthouseResultId),
|
|
});
|
|
|
|
if (!lighthouse) {
|
|
return null;
|
|
}
|
|
|
|
const [parentAudit, page] = await Promise.all([
|
|
db.query.audits.findFirst({
|
|
where: and(
|
|
eq(audits.id, lighthouse.auditId),
|
|
eq(audits.projectId, input.projectId),
|
|
),
|
|
}),
|
|
db.query.auditPages.findFirst({
|
|
where: eq(auditPages.id, lighthouse.pageId),
|
|
}),
|
|
]);
|
|
|
|
if (!parentAudit) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
lighthouse,
|
|
page,
|
|
audit: parentAudit,
|
|
};
|
|
}
|
|
|
|
async function deleteAuditForProject(auditId: string, projectId: string) {
|
|
await db
|
|
.delete(audits)
|
|
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
|
|
}
|
|
|
|
export const AuditRepository = {
|
|
createAudit,
|
|
updateAuditProgress,
|
|
completeAudit,
|
|
failAudit,
|
|
getAuditForWorkflow,
|
|
insertCrawledBatch,
|
|
insertIssues,
|
|
insertLighthouseResults,
|
|
getAuditForProject,
|
|
getLatestAuditForProject,
|
|
getIssuesForAudit,
|
|
getPagesForAudit,
|
|
hasPagesForAudit,
|
|
getAuditsByProject,
|
|
getAuditUsageForUser,
|
|
getAuditResultsForProject,
|
|
getLighthouseResultById,
|
|
deleteAuditForProject,
|
|
} as const;
|