Postgres advisor flagged seq-scans and redundant indexes across both backends (D1 + Postgres): - add projects(organization_id) — org-scoped project listings seq-scanned - add account(account_id, provider_id) — better-auth sign-in lookup - add verification(expires_at) — expired-token cleanup range scan - drop saved_keyword_tag_assignments_keyword_idx — covered by unique (saved_keyword_id, tag_id) prefix - drop rank_snapshots_run_idx — covered by unique (run_id, tracking_keyword_id, device) prefix Mirrored in both schema dialects + parity-test required-index guard.
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import {
|
|
sqliteTable,
|
|
text,
|
|
integer,
|
|
real,
|
|
uniqueIndex,
|
|
index,
|
|
} from "drizzle-orm/sqlite-core";
|
|
import { sql } from "drizzle-orm";
|
|
import { organization, user } from "./better-auth-schema";
|
|
|
|
export const userOnboardingAnswers = sqliteTable(
|
|
"user_onboarding_answers",
|
|
{
|
|
userId: text("user_id")
|
|
.primaryKey()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
organizationId: text("organization_id")
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: "cascade" }),
|
|
interestedFeatures: text("interested_features").notNull().default("[]"),
|
|
workFor: text("work_for"),
|
|
clientWebsiteCount: text("client_website_count"),
|
|
foundVia: text("found_via"),
|
|
mcpSetupIntent: text("mcp_setup_intent"),
|
|
completedAt: text("completed_at"),
|
|
// Set when the user resolves the Search Console ask, either in current
|
|
// onboarding or via the one-time re-engagement nudge for legacy users.
|
|
// Null = not yet shown/resolved.
|
|
gscNudgeDismissedAt: text("gsc_nudge_dismissed_at"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
updatedAt: text("updated_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
index("user_onboarding_answers_organization_idx").on(table.organizationId),
|
|
],
|
|
);
|
|
|
|
// Projects for keyword research
|
|
export const projects = sqliteTable(
|
|
"projects",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
organizationId: text("organization_id")
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
domain: text("domain"),
|
|
// Default DataForSEO location/language for the project, set during
|
|
// onboarding and reused by every project-scoped data call.
|
|
locationCode: integer("location_code").notNull().default(2840),
|
|
languageCode: text("language_code").notNull().default("en"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
// Soft delete: archived projects are hidden everywhere but their data
|
|
// (keywords, rank tracking, audits) is preserved.
|
|
archivedAt: text("archived_at"),
|
|
},
|
|
(table) => [
|
|
// Only the auto-created Default/null-domain project is a singleton. This
|
|
// guards the get-or-create race that can happen when several requests enter
|
|
// a new organization at once, without forbidding users from manually
|
|
// creating multiple projects with the same name or domain later.
|
|
uniqueIndex("projects_one_default_per_organization_idx")
|
|
.on(table.organizationId)
|
|
.where(
|
|
sql`${table.name} = 'Default' AND ${table.domain} IS NULL AND ${table.archivedAt} IS NULL`,
|
|
),
|
|
// Every project listing filters by organization; the partial-unique index
|
|
// above only covers the Default-project row, so without this the org-scoped
|
|
// list queries seq-scan. Per-org row counts are small, so the archived/
|
|
// created_at ordering sorts cheaply on top of this single-column lookup.
|
|
index("projects_organization_id_idx").on(table.organizationId),
|
|
],
|
|
);
|
|
|
|
// User-saved keywords within a project. This is the canonical saved list.
|
|
export const savedKeywords = sqliteTable(
|
|
"saved_keywords",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
projectId: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
keyword: text("keyword").notNull(),
|
|
locationCode: integer("location_code").notNull().default(2840),
|
|
languageCode: text("language_code").notNull().default("en"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("saved_keywords_unique_project_keyword_location_language").on(
|
|
table.projectId,
|
|
table.keyword,
|
|
table.locationCode,
|
|
table.languageCode,
|
|
),
|
|
index("saved_keywords_project_created_idx").on(
|
|
table.projectId,
|
|
table.createdAt,
|
|
),
|
|
],
|
|
);
|
|
|
|
export const savedKeywordTags = sqliteTable(
|
|
"saved_keyword_tags",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
projectId: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
normalizedName: text("normalized_name").notNull(),
|
|
// Palette key (e.g. "blue", "rose"). Null = derive a stable color from the
|
|
// tag id at render time. See src/shared/tag-colors.ts.
|
|
color: text("color"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("saved_keyword_tags_project_normalized_name_idx").on(
|
|
table.projectId,
|
|
table.normalizedName,
|
|
),
|
|
index("saved_keyword_tags_project_name_idx").on(
|
|
table.projectId,
|
|
table.name,
|
|
),
|
|
],
|
|
);
|
|
|
|
export const savedKeywordTagAssignments = sqliteTable(
|
|
"saved_keyword_tag_assignments",
|
|
{
|
|
savedKeywordId: text("saved_keyword_id")
|
|
.notNull()
|
|
.references(() => savedKeywords.id, { onDelete: "cascade" }),
|
|
tagId: text("tag_id")
|
|
.notNull()
|
|
.references(() => savedKeywordTags.id, { onDelete: "cascade" }),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("saved_keyword_tag_assignments_unique_idx").on(
|
|
table.savedKeywordId,
|
|
table.tagId,
|
|
),
|
|
// No standalone index on savedKeywordId — the unique index above has it as
|
|
// its leftmost column, so it already serves savedKeywordId lookups.
|
|
index("saved_keyword_tag_assignments_tag_idx").on(table.tagId),
|
|
],
|
|
);
|
|
|
|
// Latest cached metrics for a keyword within a project.
|
|
// This is joined onto savedKeywords when rendering the saved keyword list.
|
|
export const keywordMetrics = sqliteTable(
|
|
"keyword_metrics",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
projectId: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
keyword: text("keyword").notNull(),
|
|
locationCode: integer("location_code").notNull(),
|
|
languageCode: text("language_code").notNull().default("en"),
|
|
searchVolume: integer("search_volume"),
|
|
cpc: real("cpc"),
|
|
competition: real("competition"),
|
|
keywordDifficulty: integer("keyword_difficulty"),
|
|
intent: text("intent"),
|
|
monthlySearches: text("monthly_searches"),
|
|
fetchedAt: text("fetched_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("keyword_metrics_unique_project_keyword_location_language").on(
|
|
table.projectId,
|
|
table.keyword,
|
|
table.locationCode,
|
|
table.languageCode,
|
|
),
|
|
index("keyword_metrics_lookup_idx").on(
|
|
table.projectId,
|
|
table.keyword,
|
|
table.locationCode,
|
|
table.languageCode,
|
|
table.fetchedAt,
|
|
),
|
|
],
|
|
);
|
|
|
|
// ============================================================================
|
|
// Rank Tracking tables
|
|
// ============================================================================
|
|
|
|
// One configuration per project+domain — defines what domain to track and how
|
|
export const rankTrackingConfigs = sqliteTable(
|
|
"rank_tracking_configs",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
projectId: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
domain: text("domain").notNull(),
|
|
locationCode: integer("location_code").notNull().default(2840),
|
|
languageCode: text("language_code").notNull().default("en"),
|
|
devices: text("devices", {
|
|
enum: ["both", "desktop", "mobile"],
|
|
})
|
|
.notNull()
|
|
.default("both"),
|
|
serpDepth: integer("serp_depth").notNull(),
|
|
scheduleInterval: text("schedule_interval", {
|
|
enum: ["daily", "weekly", "monthly", "manual"],
|
|
})
|
|
.notNull()
|
|
.default("weekly"),
|
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
|
lastCheckedAt: text("last_checked_at"),
|
|
nextCheckAt: text("next_check_at"),
|
|
lastSkipReason: text("last_skip_reason"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("rank_tracking_configs_project_domain_location_idx").on(
|
|
table.projectId,
|
|
table.domain,
|
|
table.locationCode,
|
|
),
|
|
],
|
|
);
|
|
|
|
// Keywords tracked per domain config
|
|
export const rankTrackingKeywords = sqliteTable(
|
|
"rank_tracking_keywords",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
configId: text("config_id")
|
|
.notNull()
|
|
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
|
|
keyword: text("keyword").notNull(),
|
|
searchVolume: integer("search_volume"),
|
|
keywordDifficulty: integer("keyword_difficulty"),
|
|
cpc: real("cpc"),
|
|
metricsFetchedAt: text("metrics_fetched_at"),
|
|
createdAt: text("created_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
uniqueIndex("rank_tracking_keywords_config_keyword_idx").on(
|
|
table.configId,
|
|
table.keyword,
|
|
),
|
|
],
|
|
);
|
|
|
|
// One row per check execution (manual or scheduled).
|
|
// A partial unique index on `config_id WHERE status IN ('pending','running')`
|
|
// enforces at most one in-flight run per config at the DB level, which is how
|
|
// duplicate-trigger protection is implemented — INSERT of a second pending run
|
|
// for the same config fails with a unique-constraint violation.
|
|
export const rankCheckRuns = sqliteTable(
|
|
"rank_check_runs",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
configId: text("config_id")
|
|
.notNull()
|
|
.references(() => rankTrackingConfigs.id, { onDelete: "cascade" }),
|
|
projectId: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
status: text("status", {
|
|
enum: ["pending", "running", "completed", "failed"],
|
|
})
|
|
.notNull()
|
|
.default("pending"),
|
|
keywordsTotal: integer("keywords_total").notNull().default(0),
|
|
keywordsChecked: integer("keywords_checked").notNull().default(0),
|
|
isSubsetRun: integer("is_subset_run", { mode: "boolean" })
|
|
.notNull()
|
|
.default(false),
|
|
errorMessage: text("error_message"),
|
|
startedAt: text("started_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
completedAt: text("completed_at"),
|
|
},
|
|
(table) => [
|
|
index("rank_check_runs_config_idx").on(table.configId, table.startedAt),
|
|
index("rank_check_runs_project_idx").on(table.projectId, table.startedAt),
|
|
uniqueIndex("rank_check_runs_one_active_per_config_idx")
|
|
.on(table.configId)
|
|
.where(sql`${table.status} IN ('pending', 'running')`),
|
|
],
|
|
);
|
|
|
|
// One row per keyword per device per check run
|
|
export const rankSnapshots = sqliteTable(
|
|
"rank_snapshots",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
runId: text("run_id")
|
|
.notNull()
|
|
.references(() => rankCheckRuns.id, { onDelete: "cascade" }),
|
|
// No FK to rankTrackingKeywords — intentional. Historical snapshots are
|
|
// preserved after a keyword is removed from tracking so users can still
|
|
// see past position data for deleted keywords.
|
|
trackingKeywordId: text("tracking_keyword_id").notNull(),
|
|
keyword: text("keyword").notNull(),
|
|
device: text("device", { enum: ["desktop", "mobile"] }).notNull(),
|
|
position: integer("position"), // null = not found in top 20
|
|
url: text("url"),
|
|
serpFeatures: text("serp_features"), // JSON array of feature type strings
|
|
checkedAt: text("checked_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
},
|
|
(table) => [
|
|
// No standalone index on runId — the unique index below has it as its
|
|
// leftmost column, so it already serves runId lookups.
|
|
index("rank_snapshots_keyword_device_idx").on(
|
|
table.trackingKeywordId,
|
|
table.device,
|
|
table.checkedAt,
|
|
),
|
|
uniqueIndex("rank_snapshots_run_keyword_device_idx").on(
|
|
table.runId,
|
|
table.trackingKeywordId,
|
|
table.device,
|
|
),
|
|
],
|
|
);
|
|
|
|
// ============================================================================
|
|
// Site Audit tables
|
|
// ============================================================================
|
|
|
|
// One row per audit run
|
|
export const audits = sqliteTable(
|
|
"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"),
|
|
startedAt: text("started_at")
|
|
.notNull()
|
|
.default(sql`(current_timestamp)`),
|
|
completedAt: text("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 = sqliteTable(
|
|
"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: integer("has_structured_data", { mode: "boolean" })
|
|
.notNull()
|
|
.default(false),
|
|
// Hreflang
|
|
hreflangTagsJson: text("hreflang_tags_json"),
|
|
// Indexability
|
|
isIndexable: integer("is_indexable", { mode: "boolean" })
|
|
.notNull()
|
|
.default(true),
|
|
// Performance
|
|
responseTimeMs: integer("response_time_ms"),
|
|
},
|
|
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
|
|
);
|
|
|
|
// One row per Lighthouse test (mobile + desktop per page).
|
|
export const auditLighthouseResults = sqliteTable(
|
|
"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)],
|
|
);
|