Files
every-app__open-seo/src/db/pg/audit.schema.ts
T

182 lines
6.8 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
real,
text,
} from "drizzle-orm/pg-core";
import { PAGE_FETCH_CLASSES } from "@/shared/audit-fetch-class";
import { projects } from "./app.schema";
// Timestamps are stored as *text* (same column shape as the SQLite schema); see
// the note in pg/app.schema.ts. `isoNow` matches `new Date().toISOString()` so
// DB-defaulted and app-written values sort together lexicographically.
const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
const timestampColumn = (name: string) => text(name);
// ============================================================================
// Site Audit tables
// ============================================================================
// One row per audit run
export const audits = pgTable(
"audits",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
startedByUserId: text("started_by_user_id").notNull(),
startUrl: text("start_url").notNull(),
status: text("status", {
enum: ["running", "completed", "failed"],
})
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, lighthouseStrategy }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
lighthouseTotal: integer("lighthouse_total").notNull().default(0),
lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
// Failure diagnostics; null unless status = "failed". errorCode is a
// closed vocabulary (see classifyAuditError) so failures are aggregable;
// errorDetail is the raw message, truncated. failedPhase records which
// currentPhase the audit was in when it died.
errorCode: text("error_code"),
errorDetail: text("error_detail"),
failedPhase: text("failed_phase"),
startedAt: timestampColumn("started_at").notNull().default(isoNow),
completedAt: timestampColumn("completed_at"),
},
(table) => [
index("audits_project_id_idx").on(table.projectId),
index("audits_started_by_user_id_idx").on(table.startedByUserId),
],
);
// One row per crawled page
export const auditPages = pgTable(
"audit_pages",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
url: text("url").notNull(),
statusCode: integer("status_code"),
redirectUrl: text("redirect_url"),
// Metadata
title: text("title"),
metaDescription: text("meta_description"),
canonicalUrl: text("canonical_url"),
robotsMeta: text("robots_meta"),
// Open Graph
ogTitle: text("og_title"),
ogDescription: text("og_description"),
ogImage: text("og_image"),
// Headings
h1Count: integer("h1_count").notNull().default(0),
h2Count: integer("h2_count").notNull().default(0),
h3Count: integer("h3_count").notNull().default(0),
h4Count: integer("h4_count").notNull().default(0),
h5Count: integer("h5_count").notNull().default(0),
h6Count: integer("h6_count").notNull().default(0),
headingOrderJson: text("heading_order_json"),
// Content
wordCount: integer("word_count").notNull().default(0),
// Images
imagesTotal: integer("images_total").notNull().default(0),
imagesMissingAlt: integer("images_missing_alt").notNull().default(0),
imagesJson: text("images_json"),
// Links
internalLinkCount: integer("internal_link_count").notNull().default(0),
externalLinkCount: integer("external_link_count").notNull().default(0),
// Structured data
hasStructuredData: boolean("has_structured_data").notNull().default(false),
// Hreflang
hreflangTagsJson: text("hreflang_tags_json"),
// Indexability
isIndexable: boolean("is_indexable").notNull().default(true),
// Indexability/canonical signals from response headers
xRobotsTag: text("x_robots_tag"),
headerCanonicalUrl: text("header_canonical_url"),
// Crawl metadata
// null depth = not reached via links (e.g. sitemap-seeded)
crawlDepth: integer("crawl_depth"),
inSitemap: boolean("in_sitemap").notNull().default(false),
// SHA-256 of the visible body text, for duplicate-content grouping
contentHash: text("content_hash"),
fetchClass: text("fetch_class", { enum: PAGE_FETCH_CLASSES })
.notNull()
.default("ok"),
// Performance
responseTimeMs: integer("response_time_ms"),
},
(table) => [index("audit_pages_audit_url_idx").on(table.auditId, table.url)],
);
// Link edges live in the per-audit AuditScratchpad Durable Object for the
// duration of the crawl; they are never persisted to the app DB.
// One row per (issue type, affected page)
export const auditIssues = pgTable(
"audit_issues",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id").references(() => auditPages.id, {
onDelete: "cascade",
}),
pageUrl: text("page_url").notNull(),
issueType: text("issue_type").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] })
.notNull()
.default("info"),
// JSON details specific to the issue type (e.g. broken link target)
detailsJson: text("details_json"),
},
(table) => [
index("audit_issues_audit_type_idx").on(table.auditId, table.issueType),
index("audit_issues_page_id_idx").on(table.pageId),
],
);
// One row per Lighthouse test (mobile + desktop per page).
export const auditLighthouseResults = pgTable(
"audit_lighthouse_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
pageId: text("page_id")
.notNull()
.references(() => auditPages.id, { onDelete: "cascade" }),
strategy: text("strategy", { enum: ["mobile", "desktop"] }).notNull(),
performanceScore: integer("performance_score"),
accessibilityScore: integer("accessibility_score"),
bestPracticesScore: integer("best_practices_score"),
seoScore: integer("seo_score"),
lcpMs: real("lcp_ms"),
cls: real("cls"),
inpMs: real("inp_ms"),
ttfbMs: real("ttfb_ms"),
errorMessage: text("error_message"),
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
(table) => [
index("audit_lighthouse_results_audit_id_idx").on(table.auditId),
index("audit_lighthouse_results_page_id_idx").on(table.pageId),
],
);