diff --git a/.oxlintrc.json b/.oxlintrc.json index af7c445..ad87fec 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,6 +45,25 @@ ], "eslint/max-depth": ["error", 4], "eslint/max-params": ["error", 5], - "import/no-cycle": "error" - } + "import/no-cycle": "error", + "eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["@/db/d1", "@/db/d1/*", "@/db/pg", "@/db/pg/*"], + "message": "Import the provider-aware `db` from \"@/db\", tables from \"@/db/schema\", and batched writes via \"@/db/runBatch\". Dialect-specific clients/schemas may only be imported by the seam files in src/db and src/lib/auth.ts, otherwise the code silently bypasses DATABASE_PROVIDER." + } + ] + } + ] + }, + "overrides": [ + { + "files": ["src/db/**", "src/lib/auth.ts"], + "rules": { + "eslint/no-restricted-imports": "off" + } + } + ] } diff --git a/.prettierignore b/.prettierignore index 23020aa..02ba39b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ routeTree.gen.ts dist/ drizzle/ +drizzle-pg/ planning/ worker-configuration.d.ts web/ diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index 9f511f9..d5a7d49 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -55,6 +55,12 @@ Migrate local DB: pnpm run db:migrate:local ``` +## Postgres backend (optional) + +D1 (SQLite) is the default. To run against Postgres locally instead — the opt-in +backend for installs that outgrow D1 — see +[`LOCAL_POSTGRES.md`](./LOCAL_POSTGRES.md). + ## Auth Modes - `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`. diff --git a/docs/LOCAL_POSTGRES.md b/docs/LOCAL_POSTGRES.md new file mode 100644 index 0000000..1e86789 --- /dev/null +++ b/docs/LOCAL_POSTGRES.md @@ -0,0 +1,105 @@ +# Running OpenSEO on Postgres locally + +OpenSEO runs on **Cloudflare D1 (SQLite) by default**. Postgres is an opt-in +backend for installs that outgrow D1's storage ceiling. The application code is +written once against a provider-aware `db` layer (see `src/db/`), so the only +difference at runtime is the `DATABASE_PROVIDER` flag and a connection string. + +This guide sets up a throwaway Postgres in Docker so you can develop and test the +Postgres path locally. **You do not need this for normal development** — D1 is the +default and the path most contributors should use. + +## Prerequisites + +- Docker Desktop (or Docker Engine) +- The normal local dev setup from [`LOCAL_DEVELOPMENT.md`](./LOCAL_DEVELOPMENT.md) + +## 1. Start a Postgres container + +Port `5433` is used on the host to avoid clashing with a system Postgres on the +default `5432`. + +```sh +docker run --name openseo-postgres \ + -e POSTGRES_USER=openseo \ + -e POSTGRES_PASSWORD=openseo \ + -e POSTGRES_DB=openseo \ + -p 5433:5432 \ + -d postgres:16 +``` + +Wait until it accepts connections: + +```sh +docker exec openseo-postgres pg_isready -U openseo -d openseo +``` + +The connection string is: + +``` +postgres://openseo:openseo@localhost:5433/openseo +``` + +## 2. Apply the Postgres migrations + +The Postgres schema is hand-written (it is the one structural artifact +`db:generate` does not regenerate) and migrations live in `drizzle-pg/`. Apply +them with `POSTGRES_DATABASE_URL` set — `drizzle-kit` reads it from the shell +environment: + +```sh +POSTGRES_DATABASE_URL=postgres://openseo:openseo@localhost:5433/openseo \ + pnpm db:migrate:pg +``` + +## 3. Point the app at Postgres + +The Cloudflare Vite runtime reads Worker vars from `.env.local`, so set both +values there (not just in your shell): + +```sh +# .env.local +DATABASE_PROVIDER=postgres +POSTGRES_DATABASE_URL=postgres://openseo:openseo@localhost:5433/openseo +``` + +Then start the dev server as usual: + +```sh +pnpm dev +``` + +To switch back to D1, remove those two lines (or set `DATABASE_PROVIDER=d1`) and +restart. + +## 4. Verify + +```sh +# Tables created by the migrations +docker exec openseo-postgres psql -U openseo -d openseo -c "\dt" + +# Inspect rows the app writes (e.g. after creating a project / saving keywords) +docker exec openseo-postgres psql -U openseo -d openseo -c "select count(*) from projects;" +``` + +## Schema changes + +When you change a table, update **both** dialects: + +- SQLite: `src/db/*.schema.ts` (+ `pnpm db:generate:d1`) +- Postgres: `src/db/pg/*.schema.ts` (+ `pnpm db:generate:pg`) + +`src/db/schema-parity.test.ts` fails CI if the two dialects drift (mismatched +tables, columns, nullability, primary keys, unique/partial indexes, or FK +`onDelete`). It compares the schema definitions, **not** the generated +migrations — so after editing the Postgres schema, always run `pnpm db:generate:pg` +and commit the new `drizzle-pg/` migration, or a Postgres deploy will be missing +the change even though the parity test is green. + +## Teardown + +```sh +docker rm -f openseo-postgres +``` + +This deletes the container and all its data. Re-run from step 1 for a clean slate. diff --git a/drizzle-pg.config.ts b/drizzle-pg.config.ts new file mode 100644 index 0000000..2db2cd6 --- /dev/null +++ b/drizzle-pg.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/pg/schema.ts", + out: "./drizzle-pg", + dbCredentials: { + url: process.env.POSTGRES_DATABASE_URL!, + }, +}); diff --git a/drizzle-pg/0000_fixed_nico_minoru.sql b/drizzle-pg/0000_fixed_nico_minoru.sql new file mode 100644 index 0000000..d3ae107 --- /dev/null +++ b/drizzle-pg/0000_fixed_nico_minoru.sql @@ -0,0 +1,352 @@ +CREATE TABLE "audit_lighthouse_results" ( + "id" text PRIMARY KEY NOT NULL, + "audit_id" text NOT NULL, + "page_id" text NOT NULL, + "strategy" text NOT NULL, + "performance_score" integer, + "accessibility_score" integer, + "best_practices_score" integer, + "seo_score" integer, + "lcp_ms" real, + "cls" real, + "inp_ms" real, + "ttfb_ms" real, + "error_message" text, + "r2_key" text, + "payload_size_bytes" integer +); +--> statement-breakpoint +CREATE TABLE "audit_pages" ( + "id" text PRIMARY KEY NOT NULL, + "audit_id" text NOT NULL, + "url" text NOT NULL, + "status_code" integer, + "redirect_url" text, + "title" text, + "meta_description" text, + "canonical_url" text, + "robots_meta" text, + "og_title" text, + "og_description" text, + "og_image" text, + "h1_count" integer DEFAULT 0 NOT NULL, + "h2_count" integer DEFAULT 0 NOT NULL, + "h3_count" integer DEFAULT 0 NOT NULL, + "h4_count" integer DEFAULT 0 NOT NULL, + "h5_count" integer DEFAULT 0 NOT NULL, + "h6_count" integer DEFAULT 0 NOT NULL, + "heading_order_json" text, + "word_count" integer DEFAULT 0 NOT NULL, + "images_total" integer DEFAULT 0 NOT NULL, + "images_missing_alt" integer DEFAULT 0 NOT NULL, + "images_json" text, + "internal_link_count" integer DEFAULT 0 NOT NULL, + "external_link_count" integer DEFAULT 0 NOT NULL, + "has_structured_data" boolean DEFAULT false NOT NULL, + "hreflang_tags_json" text, + "is_indexable" boolean DEFAULT true NOT NULL, + "response_time_ms" integer +); +--> statement-breakpoint +CREATE TABLE "audits" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "started_by_user_id" text NOT NULL, + "start_url" text NOT NULL, + "status" text DEFAULT 'running' NOT NULL, + "workflow_instance_id" text, + "config" text DEFAULT '{}' NOT NULL, + "pages_crawled" integer DEFAULT 0 NOT NULL, + "pages_total" integer DEFAULT 0 NOT NULL, + "lighthouse_total" integer DEFAULT 0 NOT NULL, + "lighthouse_completed" integer DEFAULT 0 NOT NULL, + "lighthouse_failed" integer DEFAULT 0 NOT NULL, + "current_phase" text DEFAULT 'discovery', + "started_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "completed_at" text +); +--> statement-breakpoint +CREATE TABLE "keyword_metrics" ( + "id" serial PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "keyword" text NOT NULL, + "location_code" integer NOT NULL, + "language_code" text DEFAULT 'en' NOT NULL, + "search_volume" integer, + "cpc" real, + "competition" real, + "keyword_difficulty" integer, + "intent" text, + "monthly_searches" text, + "fetched_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "domain" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rank_check_runs" ( + "id" text PRIMARY KEY NOT NULL, + "config_id" text NOT NULL, + "project_id" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "keywords_total" integer DEFAULT 0 NOT NULL, + "keywords_checked" integer DEFAULT 0 NOT NULL, + "is_subset_run" boolean DEFAULT false NOT NULL, + "error_message" text, + "started_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "completed_at" text +); +--> statement-breakpoint +CREATE TABLE "rank_snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "tracking_keyword_id" text NOT NULL, + "keyword" text NOT NULL, + "device" text NOT NULL, + "position" integer, + "url" text, + "serp_features" text, + "checked_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rank_tracking_configs" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "domain" text NOT NULL, + "location_code" integer DEFAULT 2840 NOT NULL, + "language_code" text DEFAULT 'en' NOT NULL, + "devices" text DEFAULT 'both' NOT NULL, + "serp_depth" integer NOT NULL, + "schedule_interval" text DEFAULT 'weekly' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "last_checked_at" text, + "next_check_at" text, + "last_skip_reason" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rank_tracking_keywords" ( + "id" text PRIMARY KEY NOT NULL, + "config_id" text NOT NULL, + "keyword" text NOT NULL, + "search_volume" integer, + "keyword_difficulty" integer, + "cpc" real, + "metrics_fetched_at" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "saved_keyword_tag_assignments" ( + "saved_keyword_id" text NOT NULL, + "tag_id" text NOT NULL, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "saved_keyword_tags" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "name" text NOT NULL, + "normalized_name" text NOT NULL, + "color" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "saved_keywords" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "keyword" text NOT NULL, + "location_code" integer DEFAULT 2840 NOT NULL, + "language_code" text DEFAULT 'en' NOT NULL, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_onboarding_answers" ( + "user_id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "interested_features" text DEFAULT '[]' NOT NULL, + "work_for" text, + "client_website_count" text, + "found_via" text, + "mcp_setup_intent" text, + "completed_at" text, + "gsc_nudge_dismissed_at" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp with time zone NOT NULL, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_organization_id" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "analytics_opted_out" boolean, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "gsc_connections" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "organization_id" text NOT NULL, + "site_url" text NOT NULL, + "connected_by_user_id" text NOT NULL, + "connected_account_email" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +CREATE TABLE "reddit_attributions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "organization_id" text NOT NULL, + "click_id" text, + "uuid" text, + "landing_page" text, + "referrer" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_term" text, + "utm_content" text, + "signup_sent_at" text, + "purchase_sent_at" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_lighthouse_results" ADD CONSTRAINT "audit_lighthouse_results_audit_id_audits_id_fk" FOREIGN KEY ("audit_id") REFERENCES "public"."audits"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_lighthouse_results" ADD CONSTRAINT "audit_lighthouse_results_page_id_audit_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."audit_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_pages" ADD CONSTRAINT "audit_pages_audit_id_audits_id_fk" FOREIGN KEY ("audit_id") REFERENCES "public"."audits"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audits" ADD CONSTRAINT "audits_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "keyword_metrics" ADD CONSTRAINT "keyword_metrics_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rank_check_runs" ADD CONSTRAINT "rank_check_runs_config_id_rank_tracking_configs_id_fk" FOREIGN KEY ("config_id") REFERENCES "public"."rank_tracking_configs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rank_check_runs" ADD CONSTRAINT "rank_check_runs_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rank_snapshots" ADD CONSTRAINT "rank_snapshots_run_id_rank_check_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."rank_check_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rank_tracking_configs" ADD CONSTRAINT "rank_tracking_configs_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rank_tracking_keywords" ADD CONSTRAINT "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk" FOREIGN KEY ("config_id") REFERENCES "public"."rank_tracking_configs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_keyword_tag_assignments" ADD CONSTRAINT "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk" FOREIGN KEY ("saved_keyword_id") REFERENCES "public"."saved_keywords"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_keyword_tag_assignments" ADD CONSTRAINT "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."saved_keyword_tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_keyword_tags" ADD CONSTRAINT "saved_keyword_tags_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_keywords" ADD CONSTRAINT "saved_keywords_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_onboarding_answers" ADD CONSTRAINT "user_onboarding_answers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_onboarding_answers" ADD CONSTRAINT "user_onboarding_answers_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "gsc_connections" ADD CONSTRAINT "gsc_connections_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "gsc_connections" ADD CONSTRAINT "gsc_connections_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reddit_attributions" ADD CONSTRAINT "reddit_attributions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reddit_attributions" ADD CONSTRAINT "reddit_attributions_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_lighthouse_results_audit_id_idx" ON "audit_lighthouse_results" USING btree ("audit_id");--> statement-breakpoint +CREATE INDEX "audit_pages_audit_id_idx" ON "audit_pages" USING btree ("audit_id");--> statement-breakpoint +CREATE INDEX "audits_project_id_idx" ON "audits" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "audits_started_by_user_id_idx" ON "audits" USING btree ("started_by_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "keyword_metrics_unique_project_keyword_location_language" ON "keyword_metrics" USING btree ("project_id","keyword","location_code","language_code");--> statement-breakpoint +CREATE INDEX "keyword_metrics_lookup_idx" ON "keyword_metrics" USING btree ("project_id","keyword","location_code","language_code","fetched_at");--> statement-breakpoint +CREATE UNIQUE INDEX "projects_one_default_per_organization_idx" ON "projects" USING btree ("organization_id") WHERE "projects"."name" = 'Default' AND "projects"."domain" IS NULL;--> statement-breakpoint +CREATE INDEX "rank_check_runs_config_idx" ON "rank_check_runs" USING btree ("config_id","started_at");--> statement-breakpoint +CREATE INDEX "rank_check_runs_project_idx" ON "rank_check_runs" USING btree ("project_id","started_at");--> statement-breakpoint +CREATE UNIQUE INDEX "rank_check_runs_one_active_per_config_idx" ON "rank_check_runs" USING btree ("config_id") WHERE "rank_check_runs"."status" IN ('pending', 'running');--> statement-breakpoint +CREATE INDEX "rank_snapshots_run_idx" ON "rank_snapshots" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "rank_snapshots_keyword_device_idx" ON "rank_snapshots" USING btree ("tracking_keyword_id","device","checked_at");--> statement-breakpoint +CREATE UNIQUE INDEX "rank_snapshots_run_keyword_device_idx" ON "rank_snapshots" USING btree ("run_id","tracking_keyword_id","device");--> statement-breakpoint +CREATE UNIQUE INDEX "rank_tracking_configs_project_domain_location_idx" ON "rank_tracking_configs" USING btree ("project_id","domain","location_code");--> statement-breakpoint +CREATE UNIQUE INDEX "rank_tracking_keywords_config_keyword_idx" ON "rank_tracking_keywords" USING btree ("config_id","keyword");--> statement-breakpoint +CREATE UNIQUE INDEX "saved_keyword_tag_assignments_unique_idx" ON "saved_keyword_tag_assignments" USING btree ("saved_keyword_id","tag_id");--> statement-breakpoint +CREATE INDEX "saved_keyword_tag_assignments_keyword_idx" ON "saved_keyword_tag_assignments" USING btree ("saved_keyword_id");--> statement-breakpoint +CREATE INDEX "saved_keyword_tag_assignments_tag_idx" ON "saved_keyword_tag_assignments" USING btree ("tag_id");--> statement-breakpoint +CREATE UNIQUE INDEX "saved_keyword_tags_project_normalized_name_idx" ON "saved_keyword_tags" USING btree ("project_id","normalized_name");--> statement-breakpoint +CREATE INDEX "saved_keyword_tags_project_name_idx" ON "saved_keyword_tags" USING btree ("project_id","name");--> statement-breakpoint +CREATE UNIQUE INDEX "saved_keywords_unique_project_keyword_location_language" ON "saved_keywords" USING btree ("project_id","keyword","location_code","language_code");--> statement-breakpoint +CREATE INDEX "saved_keywords_project_created_idx" ON "saved_keywords" USING btree ("project_id","created_at");--> statement-breakpoint +CREATE INDEX "user_onboarding_answers_organization_idx" ON "user_onboarding_answers" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "organization_slug_uidx" ON "organization" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint +CREATE UNIQUE INDEX "gsc_connections_project_idx" ON "gsc_connections" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "gsc_connections_organization_idx" ON "gsc_connections" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "reddit_attributions_user_idx" ON "reddit_attributions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "reddit_attributions_organization_idx" ON "reddit_attributions" USING btree ("organization_id"); \ No newline at end of file diff --git a/drizzle-pg/0001_striped_bulldozer.sql b/drizzle-pg/0001_striped_bulldozer.sql new file mode 100644 index 0000000..dbacfa3 --- /dev/null +++ b/drizzle-pg/0001_striped_bulldozer.sql @@ -0,0 +1,15 @@ +CREATE TABLE "billing_customer_status" ( + "organization_id" text PRIMARY KEY NOT NULL, + "is_paying" boolean DEFAULT false NOT NULL, + "paid_plan_id" text, + "paid_plan_status" text, + "customer_json" text NOT NULL, + "synced_at" text NOT NULL, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL +); +--> statement-breakpoint +DROP INDEX "projects_one_default_per_organization_idx";--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "archived_at" text;--> statement-breakpoint +ALTER TABLE "billing_customer_status" ADD CONSTRAINT "billing_customer_status_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "projects_one_default_per_organization_idx" ON "projects" USING btree ("organization_id") WHERE "projects"."name" = 'Default' AND "projects"."domain" IS NULL AND "projects"."archived_at" IS NULL; \ No newline at end of file diff --git a/drizzle-pg/0002_clean_moira_mactaggert.sql b/drizzle-pg/0002_clean_moira_mactaggert.sql new file mode 100644 index 0000000..dd5ed84 --- /dev/null +++ b/drizzle-pg/0002_clean_moira_mactaggert.sql @@ -0,0 +1,2 @@ +ALTER TABLE "projects" ADD COLUMN "location_code" integer DEFAULT 2840 NOT NULL;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "language_code" text DEFAULT 'en' NOT NULL; \ No newline at end of file diff --git a/drizzle-pg/meta/0000_snapshot.json b/drizzle-pg/meta/0000_snapshot.json new file mode 100644 index 0000000..deada4a --- /dev/null +++ b/drizzle-pg/meta/0000_snapshot.json @@ -0,0 +1,2771 @@ +{ + "id": "f881405c-ad4c-4d47-8b21-9ff8b239a760", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_pages": { + "name": "audit_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audits": { + "name": "audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_metrics": { + "name": "keyword_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_check_runs": { + "name": "rank_check_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_snapshots": { + "name": "rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_configs": { + "name": "rank_tracking_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "schema": "", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tags": { + "name": "saved_keyword_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keywords": { + "name": "saved_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding_answers": { + "name": "user_onboarding_answers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_attributions": { + "name": "reddit_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/0001_snapshot.json b/drizzle-pg/meta/0001_snapshot.json new file mode 100644 index 0000000..c48fcc8 --- /dev/null +++ b/drizzle-pg/meta/0001_snapshot.json @@ -0,0 +1,2855 @@ +{ + "id": "11566eae-6403-4a34-a1c5-36c4eb00cb0b", + "prevId": "f881405c-ad4c-4d47-8b21-9ff8b239a760", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_pages": { + "name": "audit_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audits": { + "name": "audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_metrics": { + "name": "keyword_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_check_runs": { + "name": "rank_check_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_snapshots": { + "name": "rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_configs": { + "name": "rank_tracking_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "schema": "", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tags": { + "name": "saved_keyword_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keywords": { + "name": "saved_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding_answers": { + "name": "user_onboarding_answers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customer_status": { + "name": "billing_customer_status", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_paying": { + "name": "is_paying", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_attributions": { + "name": "reddit_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/0002_snapshot.json b/drizzle-pg/meta/0002_snapshot.json new file mode 100644 index 0000000..e83eb9f --- /dev/null +++ b/drizzle-pg/meta/0002_snapshot.json @@ -0,0 +1,2869 @@ +{ + "id": "8076c5e0-2476-448f-9cb4-5c201a2933a8", + "prevId": "11566eae-6403-4a34-a1c5-36c4eb00cb0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_pages": { + "name": "audit_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audits": { + "name": "audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_metrics": { + "name": "keyword_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_check_runs": { + "name": "rank_check_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_snapshots": { + "name": "rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_configs": { + "name": "rank_tracking_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "schema": "", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tags": { + "name": "saved_keyword_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keywords": { + "name": "saved_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding_answers": { + "name": "user_onboarding_answers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customer_status": { + "name": "billing_customer_status", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_paying": { + "name": "is_paying", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_attributions": { + "name": "reddit_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json new file mode 100644 index 0000000..ebf926f --- /dev/null +++ b/drizzle-pg/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780611624042, + "tag": "0000_fixed_nico_minoru", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781809009342, + "tag": "0001_striped_bulldozer", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781902417326, + "tag": "0002_clean_moira_mactaggert", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index eaf38dc..75fafd5 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -5,7 +5,9 @@ const localUrl = getLocalD1Url(); export default defineConfig({ dialect: "sqlite", - schema: "./src/db/schema.ts", + // The raw SQLite barrel (not ../schema, the provider-aware one, which imports + // cloudflare:workers and can't load under drizzle-kit's node runtime). + schema: "./src/db/d1/schema.ts", out: "./drizzle", dbCredentials: { url: localUrl || "", // Empty fallback for CI/non-dev environments diff --git a/knip.jsonc b/knip.jsonc index 8c3257e..e9df798 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,10 +9,13 @@ "src/routes/**/*.tsx", // Drizzle config (plugin disabled due to cloudflare:workers import issues) "drizzle.config.ts", + "drizzle-pg.config.ts", // DB schema — exports consumed via `import * as schema` / drizzle() "src/db/index.ts", + "src/db/schema.ts", "src/db/app.schema.ts", "src/db/better-auth-schema.ts", + "src/db/pg/schema.ts", // Standalone CLI/dev scripts, invoked via package.json scripts "scripts/**", ], diff --git a/package.json b/package.json index 36478d2..97e9341 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,16 @@ "types:check": "tsc --noEmit", "format:check": "prettier --check .", "format:write": "prettier . --write", - "auth:generate": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts", - "db:generate": "drizzle-kit generate", + "auth:generate": "npm run auth:generate:d1 && npm run auth:generate:pg", + "auth:generate:d1": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts && prettier --write ./src/db/better-auth-schema.ts", + "auth:generate:pg": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect pg --output ./src/db/pg/better-auth-schema.ts && prettier --write ./src/db/pg/better-auth-schema.ts", + "db:generate": "npm run db:generate:d1 && npm run db:generate:pg", + "db:generate:d1": "drizzle-kit generate", + "db:generate:pg": "drizzle-kit generate --config drizzle-pg.config.ts", "db:migrate:local": "wrangler d1 migrations apply DB --local", "db:migrate:prod": "wrangler d1 migrations apply DB --remote", + "db:migrate:pg": "drizzle-kit migrate --config drizzle-pg.config.ts", + "deploy:postgres": "npm run db:migrate:pg && npm run build && wrangler deploy", "knip": "knip", "release:notes": "node scripts/release-notes.mjs", "test": "vitest run", @@ -85,6 +91,7 @@ "jose": "^6.0.12", "lucide-react": "^0.542.0", "papaparse": "^5.5.3", + "postgres": "^3.4.9", "posthog-js": "^1.363.5", "posthog-node": "^5.28.5", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb8562a..9576650 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,10 +55,10 @@ importers: version: 6.0.199(zod@4.3.6) autumn-js: specifier: ^1.1.7 - version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4) + version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4) better-auth: specifier: ^1.5.5 - version: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) cheerio: specifier: ^1.2.0 version: 1.2.0 @@ -73,7 +73,7 @@ importers: version: 2.0.19 drizzle-orm: specifier: ^0.44.4 - version: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1) + version: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1) fast-xml-parser: specifier: ^5.4.1 version: 5.8.0 @@ -86,6 +86,9 @@ importers: papaparse: specifier: ^5.5.3 version: 5.5.3 + postgres: + specifier: ^3.4.9 + version: 3.4.9 posthog-js: specifier: ^1.363.5 version: 1.363.6 @@ -4279,6 +4282,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + posthog-js@1.363.6: resolution: {integrity: sha512-eQ+Ypml3JOEMQWt21XEea6J8vD77TNyoz4Yv/xxjlTRja+ilmJtQw/SuVAB3BobjgHKYUomXX7Fc4gH/zTVpbg==} @@ -5316,12 +5323,12 @@ snapshots: optionalDependencies: '@cloudflare/workers-types': 4.20260611.1 - '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))': + '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1) '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12)': dependencies: @@ -7162,13 +7169,13 @@ snapshots: asynckit@0.4.0: {} - autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4): + autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + better-auth: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) better-call: 1.3.2(zod@4.3.6) express: 5.2.1 hono: 4.12.18 @@ -7192,10 +7199,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} - better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)): + better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1)) + '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1)) '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12) '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1) '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.2.0(@mongodb-js/zstd@7.0.0)) @@ -7214,7 +7221,7 @@ snapshots: optionalDependencies: '@tanstack/react-start': 1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) drizzle-kit: 0.31.9 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1) + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1) mongodb: 7.2.0(@mongodb-js/zstd@7.0.0) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -7549,12 +7556,13 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1): + drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1): optionalDependencies: '@cloudflare/workers-types': 4.20260611.1 '@libsql/client': 0.15.15 '@opentelemetry/api': 1.9.1 kysely: 0.28.12 + postgres: 3.4.9 sql.js: 1.14.1 dunder-proto@1.0.1: @@ -8857,6 +8865,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + posthog-js@1.363.6: dependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/db/d1/client.ts b/src/db/d1/client.ts new file mode 100644 index 0000000..891e3ff --- /dev/null +++ b/src/db/d1/client.ts @@ -0,0 +1,5 @@ +import { env } from "cloudflare:workers"; +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "./schema"; + +export const d1Db = drizzle(env.DB, { schema }); diff --git a/src/db/d1/schema.ts b/src/db/d1/schema.ts new file mode 100644 index 0000000..e37931e --- /dev/null +++ b/src/db/d1/schema.ts @@ -0,0 +1,8 @@ +// Raw SQLite schema for the D1 client. Imported directly (not via ../schema, +// which is the provider-aware barrel) so the D1 client always binds to the +// SQLite tables regardless of DATABASE_PROVIDER. +export * from "../app.schema"; +export * from "../better-auth-schema"; +export * from "../billing.schema"; +export * from "../gsc.schema"; +export * from "../reddit-attribution.schema"; diff --git a/src/db/index.ts b/src/db/index.ts index 16c7bb1..d0c8487 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,6 +1,19 @@ -import { drizzle } from "drizzle-orm/d1"; -import { env } from "cloudflare:workers"; -import * as schema from "./schema"; +import { getDatabaseProvider } from "./provider"; +import { d1Db } from "./d1/client"; +import { pgDb } from "./pg/client"; -// Helper function to get the database instance from D1 binding -export const db = drizzle(env.DB, { schema }); +// Per-request Postgres client scope (no-op in D1 mode). Re-exported here so +// entrypoints import it from "@/db" rather than the dialect-specific client. +export { withPgClient } from "./pg/client"; + +// Provider-aware database handle. D1 is the default (free, zero-config self-host +// on the Cloudflare free plan); Postgres is opt-in via DATABASE_PROVIDER=postgres. +// +// Typed as the D1 client so repositories get full Drizzle inference (the +// parity test guarantees the Postgres schema is structurally identical). The +// Postgres driver has no `.batch`, so atomic multi-statement writes must go +// through `runBatch` (./runBatch) rather than `db.batch`. +// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts +export const db = (getDatabaseProvider() === "postgres" + ? pgDb + : d1Db) as unknown as typeof d1Db; diff --git a/src/db/pg/app.schema.ts b/src/db/pg/app.schema.ts new file mode 100644 index 0000000..29aacc7 --- /dev/null +++ b/src/db/pg/app.schema.ts @@ -0,0 +1,440 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgTable, + real, + serial, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { organization, user } from "./better-auth-schema"; + +// Timestamps are stored as *text* (same column shape as the SQLite schema). +// Postgres `timestamptz` would be parsed back into a JS Date by postgres-js +// (even with drizzle `mode:"string"`), silently breaking the lexicographic +// string comparisons the app does on timestamps. `isoNow` matches the format of +// `new Date().toISOString()`, so within a Postgres deployment DB-defaulted and +// app-written values sort together. +// +// NOTE: this is NOT byte-for-byte equal to the SQLite default, which uses +// `current_timestamp` (`YYYY-MM-DD HH:MM:SS`, space-separated, no millis/Z). +// Each backend is internally consistent, but a one-time D1→Postgres data +// migration MUST rewrite legacy timestamp text into this ISO format (tracked as +// the deferred timestamp backfill). +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); + +export const userOnboardingAnswers = pgTable( + "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: timestampColumn("completed_at"), + // Set when the user dismisses (or acts on) the one-time "connect Search + // Console" nudge shown to people who finished onboarding before the GSC + // step existed. Null = never shown/dismissed. + gscNudgeDismissedAt: timestampColumn("gsc_nudge_dismissed_at"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + }, + (table) => [ + index("user_onboarding_answers_organization_idx").on(table.organizationId), + ], +); + +// Projects for keyword research +export const projects = pgTable( + "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: timestampColumn("created_at").notNull().default(isoNow), + // Soft delete: archived projects are hidden everywhere but their data + // (keywords, rank tracking, audits) is preserved. + archivedAt: timestampColumn("archived_at"), + }, + (table) => [ + // Only the auto-created Default/null-domain project is a singleton. This + // guards the get-or-create race when several requests enter a new + // organization at once (mirrors the SQLite schema). `tryCreateDefaultProject` + // relies on this partial unique index for its onConflictDoNothing(). + 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`, + ), + ], +); + +// User-saved keywords within a project. This is the canonical saved list. +export const savedKeywords = pgTable( + "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: timestampColumn("created_at").notNull().default(isoNow), + }, + (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 = pgTable( + "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: timestampColumn("created_at").notNull().default(isoNow), + }, + (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 = pgTable( + "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: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("saved_keyword_tag_assignments_unique_idx").on( + table.savedKeywordId, + table.tagId, + ), + index("saved_keyword_tag_assignments_keyword_idx").on(table.savedKeywordId), + 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 = pgTable( + "keyword_metrics", + { + id: serial("id").primaryKey(), + 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: timestampColumn("fetched_at").notNull().default(isoNow), + }, + (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 = pgTable( + "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: boolean("is_active").notNull().default(true), + lastCheckedAt: timestampColumn("last_checked_at"), + nextCheckAt: timestampColumn("next_check_at"), + lastSkipReason: text("last_skip_reason"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("rank_tracking_configs_project_domain_location_idx").on( + table.projectId, + table.domain, + table.locationCode, + ), + ], +); + +// Keywords tracked per domain config +export const rankTrackingKeywords = pgTable( + "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: timestampColumn("metrics_fetched_at"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (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 = pgTable( + "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: boolean("is_subset_run").notNull().default(false), + errorMessage: text("error_message"), + startedAt: timestampColumn("started_at").notNull().default(isoNow), + completedAt: timestampColumn("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 = pgTable( + "rank_snapshots", + { + id: serial("id").primaryKey(), + 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: timestampColumn("checked_at").notNull().default(isoNow), + }, + (table) => [ + index("rank_snapshots_run_idx").on(table.runId), + 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 = 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"), + 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"), + internalLinkCount: integer("internal_link_count").notNull().default(0), + externalLinkCount: integer("external_link_count").notNull().default(0), + hasStructuredData: boolean("has_structured_data").notNull().default(false), + hreflangTagsJson: text("hreflang_tags_json"), + isIndexable: boolean("is_indexable").notNull().default(true), + 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 = 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)], +); diff --git a/src/db/pg/better-auth-schema.ts b/src/db/pg/better-auth-schema.ts new file mode 100644 index 0000000..d4a5125 --- /dev/null +++ b/src/db/pg/better-auth-schema.ts @@ -0,0 +1,188 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +const timestampColumn = (name: string) => + timestamp(name, { mode: "date", withTimezone: true }); + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestampColumn("created_at").defaultNow().notNull(), + updatedAt: timestampColumn("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + analyticsOptedOut: boolean("analytics_opted_out"), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestampColumn("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestampColumn("created_at").defaultNow().notNull(), + updatedAt: timestampColumn("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestampColumn("access_token_expires_at"), + refreshTokenExpiresAt: timestampColumn("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestampColumn("created_at").defaultNow().notNull(), + updatedAt: timestampColumn("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestampColumn("expires_at").notNull(), + createdAt: timestampColumn("created_at").defaultNow().notNull(), + updatedAt: timestampColumn("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const organization = pgTable( + "organization", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestampColumn("created_at").notNull(), + metadata: text("metadata"), + }, + (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)], +); + +export const member = pgTable( + "member", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestampColumn("created_at").notNull(), + }, + (table) => [ + index("member_organizationId_idx").on(table.organizationId), + index("member_userId_idx").on(table.userId), + ], +); + +export const invitation = pgTable( + "invitation", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestampColumn("expires_at").notNull(), + createdAt: timestampColumn("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitation_organizationId_idx").on(table.organizationId), + index("invitation_email_idx").on(table.email), + ], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + members: many(member), + invitations: many(invitation), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [member.userId], + references: [user.id], + }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})); diff --git a/src/db/pg/billing.schema.ts b/src/db/pg/billing.schema.ts new file mode 100644 index 0000000..c5b5d8e --- /dev/null +++ b/src/db/pg/billing.schema.ts @@ -0,0 +1,21 @@ +import { sql } from "drizzle-orm"; +import { boolean, pgTable, text } from "drizzle-orm/pg-core"; +import { organization } from "./better-auth-schema"; + +// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text. +const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; + +export const billingCustomerStatus = pgTable("billing_customer_status", { + organizationId: text("organization_id") + .primaryKey() + .references(() => organization.id, { onDelete: "cascade" }), + isPaying: boolean("is_paying").notNull().default(false), + paidPlanId: text("paid_plan_id"), + paidPlanStatus: text("paid_plan_status"), + // Full Autumn customer payload — escape hatch for any field we don't flatten, + // queryable via json_extract so we never have to widen this table. + customerJson: text("customer_json").notNull(), + syncedAt: text("synced_at").notNull(), + createdAt: text("created_at").notNull().default(isoNow), + updatedAt: text("updated_at").notNull().default(isoNow), +}); diff --git a/src/db/pg/client.ts b/src/db/pg/client.ts new file mode 100644 index 0000000..836ce5d --- /dev/null +++ b/src/db/pg/client.ts @@ -0,0 +1,73 @@ +/* oxlint-disable typescript/no-unsafe-return, typescript/no-unsafe-type-assertion -- The proxy resolves the request-scoped Postgres client from AsyncLocalStorage. */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { + getDatabaseProvider, + getPostgresConnectionString, +} from "@/db/provider"; +import * as schema from "./schema"; + +// Postgres on Cloudflare Workers requires a PER-REQUEST client: the runtime +// forbids using a socket created by one request from a different request +// ("Cannot perform I/O on behalf of a different request"), and Hyperdrive does +// not lift that — its docs are explicit that the client must be created inside +// the handler, never in global scope. So we keep the active client in +// AsyncLocalStorage, seeded by `withPgClient` at each entrypoint. In D1 mode +// (the default) none of this runs. +type Sql = ReturnType; + +function createPgDb(sql: Sql) { + return drizzle(sql, { schema }); +} + +const pgClientStore = new AsyncLocalStorage<{ + sql: Sql; + db: ReturnType; +}>(); + +export const pgDb = new Proxy( + {}, + { + get(_target, prop, receiver) { + const store = pgClientStore.getStore(); + if (!store) { + throw new Error( + "Postgres database accessed outside a request scope. Entrypoints " + + "(fetch, scheduled, workflow run) must wrap DB usage in withPgClient().", + ); + } + return Reflect.get(store.db, prop, receiver); + }, + }, +) as ReturnType; + +/** + * Run `fn` with a request-scoped Postgres client in scope. + * + * - D1 mode (default): a no-op — just runs `fn` (no Postgres client created). + * - Postgres mode: creates a fresh per-request client and makes it the active + * `pgDb` for the duration of `fn`. + * + * Wrap every entrypoint that touches the DB: the `fetch` handler, the + * `scheduled` cron, and each WorkflowEntrypoint `run`. + * + * Per Hyperdrive's guidance we use a single connection (`max: 1` — Hyperdrive + * pools the origin connections at the edge, so a client-side pool only adds + * stale-connection risk) and do NOT call `sql.end()`: the Workers↔Hyperdrive + * socket is torn down automatically when the invocation ends, and the pooled + * origin connection stays warm for reuse. Not ending it also means a streamed + * response can keep querying after the handler returns. (Without a Hyperdrive + * binding, `POSTGRES_DATABASE_URL` opens a direct connection that the Workers + * runtime still reclaims at invocation end.) + */ +export async function withPgClient(fn: () => Promise): Promise { + if (getDatabaseProvider() !== "postgres") { + return fn(); + } + const sql = postgres(getPostgresConnectionString(), { + max: 1, + fetch_types: false, + }); + return pgClientStore.run({ sql, db: createPgDb(sql) }, fn); +} diff --git a/src/db/pg/gsc.schema.ts b/src/db/pg/gsc.schema.ts new file mode 100644 index 0000000..c59f02d --- /dev/null +++ b/src/db/pg/gsc.schema.ts @@ -0,0 +1,37 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; +import { organization } from "./better-auth-schema"; +import { projects } from "./app.schema"; + +// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text. +const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; + +// Connected Google Search Console property per project. +// OAuth tokens live in the better-auth `account` table under providerId +// "google-search-console"; this row only records which verified property maps +// to a project and whose grant to use when calling the GSC API. +export const gscConnections = pgTable( + "gsc_connections", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + // Stored verbatim from sites.list — "sc-domain:example.com" or + // "https://example.com/". Never normalize; GSC matches it byte-for-byte. + siteUrl: text("site_url").notNull(), + // Whose google-search-console grant getAccessToken should use. + connectedByUserId: text("connected_by_user_id").notNull(), + connectedAccountEmail: text("connected_account_email"), + createdAt: text("created_at").notNull().default(isoNow), + updatedAt: text("updated_at").notNull().default(isoNow), + }, + (table) => [ + // One selected property per project in v1; switching replaces the row. + uniqueIndex("gsc_connections_project_idx").on(table.projectId), + index("gsc_connections_organization_idx").on(table.organizationId), + ], +); diff --git a/src/db/pg/reddit-attribution.schema.ts b/src/db/pg/reddit-attribution.schema.ts new file mode 100644 index 0000000..6127b2e --- /dev/null +++ b/src/db/pg/reddit-attribution.schema.ts @@ -0,0 +1,36 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; +import { organization, user } from "./better-auth-schema"; + +// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text. +const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; + +export const redditAttributions = pgTable( + "reddit_attributions", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + clickId: text("click_id"), + uuid: text("uuid"), + landingPage: text("landing_page"), + referrer: text("referrer"), + utmSource: text("utm_source"), + utmMedium: text("utm_medium"), + utmCampaign: text("utm_campaign"), + utmTerm: text("utm_term"), + utmContent: text("utm_content"), + signupSentAt: text("signup_sent_at"), + purchaseSentAt: text("purchase_sent_at"), + createdAt: text("created_at").notNull().default(isoNow), + updatedAt: text("updated_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("reddit_attributions_user_idx").on(table.userId), + index("reddit_attributions_organization_idx").on(table.organizationId), + ], +); diff --git a/src/db/pg/schema.ts b/src/db/pg/schema.ts new file mode 100644 index 0000000..07fd7e5 --- /dev/null +++ b/src/db/pg/schema.ts @@ -0,0 +1,5 @@ +export * from "./app.schema"; +export * from "./better-auth-schema"; +export * from "./billing.schema"; +export * from "./gsc.schema"; +export * from "./reddit-attribution.schema"; diff --git a/src/db/provider.ts b/src/db/provider.ts new file mode 100644 index 0000000..d8eb186 --- /dev/null +++ b/src/db/provider.ts @@ -0,0 +1,38 @@ +import { env } from "cloudflare:workers"; + +type DatabaseProvider = "d1" | "postgres"; + +export function getDatabaseProvider(): DatabaseProvider { + const provider = Reflect.get(env, "DATABASE_PROVIDER"); + + if (provider === "postgres") { + return "postgres"; + } + + if (provider === "d1" || provider === undefined || provider === "") { + return "d1"; + } + + throw new Error( + `Unsupported DATABASE_PROVIDER "${String(provider)}". Expected "d1" or "postgres".`, + ); +} + +export function getPostgresConnectionString() { + const hyperdrive = Reflect.get(env, "HYPERDRIVE") as + | { connectionString?: string } + | undefined; + const hyperdriveUrl = hyperdrive?.connectionString?.trim(); + if (hyperdriveUrl) { + return hyperdriveUrl; + } + + const directUrl = Reflect.get(env, "POSTGRES_DATABASE_URL"); + if (typeof directUrl === "string" && directUrl.trim()) { + return directUrl.trim(); + } + + throw new Error( + "DATABASE_PROVIDER=postgres requires a HYPERDRIVE binding or POSTGRES_DATABASE_URL.", + ); +} diff --git a/src/db/runBatch.ts b/src/db/runBatch.ts new file mode 100644 index 0000000..da9e069 --- /dev/null +++ b/src/db/runBatch.ts @@ -0,0 +1,66 @@ +import { getDatabaseProvider } from "./provider"; +import { d1Db } from "./d1/client"; +import { pgDb } from "./pg/client"; + +// The executor handed to the `build` callback. Typed as the D1 client so call +// sites get full Drizzle inference; at runtime it is either `d1Db` or a Postgres +// transaction handle. +type BatchExecutor = typeof d1Db; +type BatchStatement = Parameters[0][number]; + +// D1 caps bound parameters at ~100 per statement; keep batches bounded so each +// runBatch call stays under that limit. (Postgres allows far more, but the same +// chunk size is harmless there.) +const DB_BATCH_SIZE = 100; + +/** + * Run a set of write statements atomically on either backend. + * + * - D1: collected statements run via `db.batch([...])` (one atomic, ordered call). + * - Postgres: statements run sequentially inside `db.transaction(tx => ...)` + * (atomic, and in array order to match D1's batch semantics). + * + * IMPORTANT: build statements from the `tx` handle the callback receives, NOT + * the module-level `db`. On Postgres, statements built from the outer `db` would + * execute outside the transaction. Returning the (unawaited) Drizzle query + * builders is enough; they are thenables on both dialects. + */ +export async function runBatch( + build: (tx: BatchExecutor) => readonly Promise[], +): Promise { + if (getDatabaseProvider() === "postgres") { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- pg tx exposes the same query builder surface as d1Db + const pg = pgDb as unknown as { + transaction: ( + fn: (tx: BatchExecutor) => Promise, + ) => Promise; + }; + await pg.transaction(async (tx) => { + // Sequential to mirror D1's ordered batch contract. + for (const statement of build(tx)) await statement; + }); + return; + } + + const statements = build(d1Db); + if (statements.length === 0) return; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- d1 query builders are BatchItems; length checked above + const batch = statements as unknown as [BatchStatement, ...BatchStatement[]]; + await d1Db.batch(batch); +} + +/** + * Chunk `items` into D1-safe batches and run each chunk atomically via + * `runBatch`. Shared by repositories that bulk-insert/update (audit pages, + * rank snapshots, etc.). + */ +export async function executeInBatches( + items: T[], + buildStatement: (tx: BatchExecutor, item: T) => Promise, +): Promise { + for (let i = 0; i < items.length; i += DB_BATCH_SIZE) { + const chunk = items.slice(i, i + DB_BATCH_SIZE); + if (chunk.length === 0) continue; + await runBatch((tx) => chunk.map((item) => buildStatement(tx, item))); + } +} diff --git a/src/db/schema-parity.test.ts b/src/db/schema-parity.test.ts new file mode 100644 index 0000000..4251129 --- /dev/null +++ b/src/db/schema-parity.test.ts @@ -0,0 +1,285 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getTableColumns, getTableName, is, Table } from "drizzle-orm"; +import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"; +import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import * as sqliteApp from "./app.schema"; +import * as sqliteAuth from "./better-auth-schema"; +import * as sqliteBilling from "./billing.schema"; +import * as sqliteGsc from "./gsc.schema"; +import * as sqliteReddit from "./reddit-attribution.schema"; +import * as pgApp from "./pg/app.schema"; +import * as pgAuth from "./pg/better-auth-schema"; +import * as pgBilling from "./pg/billing.schema"; +import * as pgGsc from "./pg/gsc.schema"; +import * as pgReddit from "./pg/reddit-attribution.schema"; + +// Guards the ONE structural artifact `db:generate` does not regenerate: the +// hand-written Postgres schema. The provider-aware `db`/`@/db/schema` barrel +// types Postgres as the SQLite schema via a cast, so these two schemas MUST stay +// structurally interchangeable or that cast lies. This test fails loudly the +// moment they drift (e.g. a table added to one dialect but not the other). + +type Dialect = "sqlite" | "pg"; + +const sortStrings = (values: string[]) => + values.toSorted((a, b) => a.localeCompare(b)); + +function asStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return sortStrings(value.filter((v): v is string => typeof v === "string")); +} + +function tablesFrom(...modules: Record[]) { + const out = new Map(); + for (const mod of modules) { + for (const value of Object.values(mod)) { + if (is(value, Table)) out.set(getTableName(value), value); + } + } + return out; +} + +const getConfig = (table: Table, dialect: Dialect) => + dialect === "pg" ? getPgTableConfig(table) : getSqliteTableConfig(table); + +type ColumnInfo = { + name: string; + notNull: boolean; + dataType: string; + hasDefault: boolean; + enumValues: string[] | null; +}; + +function columnsOf(table: Table): ColumnInfo[] { + return Object.values(getTableColumns(table)).map((col) => ({ + name: col.name, + notNull: col.notNull, + // `dataType` resolves to `any` via Drizzle's column config generic; narrow it. + dataType: typeof col.dataType === "string" ? col.dataType : "unknown", + hasDefault: col.hasDefault, + enumValues: asStringArray(col.enumValues), + })); +} + +function columnName(candidate: unknown): string | null { + if ( + candidate && + typeof candidate === "object" && + "name" in candidate && + typeof candidate.name === "string" + ) { + return candidate.name; + } + return null; +} + +// Unique constraints reduced to "sortedCols[|partial]" — including whether the +// index carries a WHERE predicate so a partial→full change (which alters the +// onConflict invariant) is caught even though the predicate text is dialect- +// specific. +function uniqueColumnTuples(table: Table, dialect: Dialect): string[] { + const config = getConfig(table, dialect); + const tuples = new Set(); + for (const index of config.indexes) { + if (!index.config.unique) continue; + const cols = index.config.columns + .map(columnName) + .filter((name): name is string => name !== null); + tuples.add( + sortStrings(cols).join(",") + (index.config.where ? "|partial" : ""), + ); + } + for (const constraint of config.uniqueConstraints) { + tuples.add(sortStrings(constraint.columns.map((c) => c.name)).join(",")); + } + for (const col of Object.values(getTableColumns(table))) { + if (col.isUnique) tuples.add(col.name); + } + return sortStrings([...tuples]); +} + +function primaryKeyColumns(table: Table, dialect: Dialect): string[] { + const config = getConfig(table, dialect); + const pk = new Set(); + for (const col of Object.values(getTableColumns(table))) { + if (col.primary) pk.add(col.name); + } + for (const composite of config.primaryKeys) { + for (const col of composite.columns) pk.add(col.name); + } + return sortStrings([...pk]); +} + +// FK as "cols->refTable.refCols onDelete=action" so a dropped/changed cascade is +// caught (the parity property repositories rely on for cascading deletes). +function foreignKeys(table: Table, dialect: Dialect): string[] { + const config = getConfig(table, dialect); + return sortStrings( + config.foreignKeys.map((fk) => { + const ref = fk.reference(); + const cols = sortStrings(ref.columns.map((c) => c.name)).join(","); + const refTable = getTableName(ref.foreignTable); + const refCols = sortStrings(ref.foreignColumns.map((c) => c.name)).join( + ",", + ); + return `${cols}->${refTable}.${refCols} onDelete=${fk.onDelete ?? "none"}`; + }), + ); +} + +const sqliteAppTables = tablesFrom( + sqliteApp, + sqliteBilling, + sqliteGsc, + sqliteReddit, +); +const pgAppTables = tablesFrom(pgApp, pgBilling, pgGsc, pgReddit); +const sqliteAuthTables = tablesFrom(sqliteAuth); +const pgAuthTables = tablesFrom(pgAuth); + +describe("schema parity: application tables", () => { + it("define the same set of tables on both backends", () => { + expect(sortStrings([...pgAppTables.keys()])).toEqual( + sortStrings([...sqliteAppTables.keys()]), + ); + }); + + for (const [name, sqliteTable] of sqliteAppTables) { + const pgTable = pgAppTables.get(name); + if (!pgTable) continue; // reported by the table-set assertion above + + describe(`table "${name}"`, () => { + it("has matching columns (name, nullability, type, default, enum)", () => { + // dataType is dialect-agnostic ("string"/"number"/"boolean"/"date") so + // text/text, boolean/boolean, serial/autoincrement match; a real type + // mismatch is caught. + expect(columnsOf(pgTable)).toEqual(columnsOf(sqliteTable)); + }); + it("has matching primary key", () => { + expect(primaryKeyColumns(pgTable, "pg")).toEqual( + primaryKeyColumns(sqliteTable, "sqlite"), + ); + }); + it("has matching unique constraints (onConflict targets)", () => { + expect(uniqueColumnTuples(pgTable, "pg")).toEqual( + uniqueColumnTuples(sqliteTable, "sqlite"), + ); + }); + it("has matching foreign keys (incl. onDelete)", () => { + expect(foreignKeys(pgTable, "pg")).toEqual( + foreignKeys(sqliteTable, "sqlite"), + ); + }); + }); + } +}); + +describe("schema parity: better-auth tables", () => { + // better-auth schemas are generated per-dialect (auth:generate) and are + // intentionally dialect-native in column TYPE (SQLite integer-timestamp_ms / + // text-json vs Postgres timestamptz / jsonb). So we assert structure that + // MUST match — table set, column names, nullability, PK, unique constraints — + // but not dataType. This catches a column/table added or removed on one + // dialect but not the other (e.g. a stale-oauth-tables drift, or a + // better-auth upgrade applied to only one schema). + it("define the same set of tables on both backends", () => { + expect(sortStrings([...pgAuthTables.keys()])).toEqual( + sortStrings([...sqliteAuthTables.keys()]), + ); + }); + + for (const [name, sqliteTable] of sqliteAuthTables) { + const pgTable = pgAuthTables.get(name); + if (!pgTable) continue; + + describe(`table "${name}"`, () => { + it("has matching column names + nullability", () => { + const shape = (table: Table) => + Object.fromEntries(columnsOf(table).map((c) => [c.name, c.notNull])); + expect(shape(pgTable)).toEqual(shape(sqliteTable)); + }); + it("has matching primary key", () => { + expect(primaryKeyColumns(pgTable, "pg")).toEqual( + primaryKeyColumns(sqliteTable, "sqlite"), + ); + }); + it("has matching unique constraints", () => { + expect(uniqueColumnTuples(pgTable, "pg")).toEqual( + uniqueColumnTuples(sqliteTable, "sqlite"), + ); + }); + }); + } +}); + +// Secondary indexes that better-auth's `generate` CLI does NOT emit — they are +// hand-added to both schema files for query performance. Running `auth:generate` +// overwrites the files and drops them, so this guard fails loudly (on either +// dialect) if a regen forgets to re-apply them. Columns are SQL column names. +const REQUIRED_BETTER_AUTH_INDEXES: { + table: string; + columns: string[]; + unique: boolean; +}[] = [ + { table: "session", columns: ["user_id"], unique: false }, + { table: "account", columns: ["user_id"], unique: false }, + { table: "verification", columns: ["identifier"], unique: false }, + { table: "organization", columns: ["slug"], unique: true }, + { table: "member", columns: ["organization_id"], unique: false }, + { table: "member", columns: ["user_id"], unique: false }, + { table: "invitation", columns: ["organization_id"], unique: false }, + { table: "invitation", columns: ["email"], unique: false }, +]; + +function indexKeys(table: Table, dialect: Dialect): string[] { + const config = getConfig(table, dialect); + return config.indexes.map((index) => { + const cols = index.config.columns + .map(columnName) + .filter((name): name is string => name !== null); + return `${sortStrings(cols).join(",")}|${index.config.unique ? "unique" : "index"}`; + }); +} + +describe("better-auth required indexes (CLI omits them; re-apply after auth:generate)", () => { + for (const dialect of ["sqlite", "pg"] as const) { + const tables = dialect === "pg" ? pgAuthTables : sqliteAuthTables; + describe(dialect, () => { + for (const req of REQUIRED_BETTER_AUTH_INDEXES) { + const label = `${req.table}(${req.columns.join(",")})${req.unique ? " unique" : ""}`; + it(`has index ${label}`, () => { + const table = tables.get(req.table); + expect(table, `missing table "${req.table}"`).toBeDefined(); + if (!table) return; + const key = `${sortStrings(req.columns).join(",")}|${req.unique ? "unique" : "index"}`; + expect(indexKeys(table, dialect)).toContain(key); + }); + } + }); + } +}); + +describe("no direct db.batch (must use runBatch)", () => { + // `db.batch` only exists on the D1 driver; on Postgres it throws. All atomic + // multi-statement writes must go through `runBatch`, which is the only file + // allowed to call `.batch`. + function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(path)); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) + out.push(path); + } + return out; + } + + it("is not called outside src/db/runBatch.ts", () => { + const offenders = walk("src") + .filter((path) => !path.endsWith(join("db", "runBatch.ts"))) + .filter((path) => /\.batch\(/.test(readFileSync(path, "utf8"))); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 07fd7e5..e8c5429 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,67 @@ -export * from "./app.schema"; -export * from "./better-auth-schema"; -export * from "./billing.schema"; -export * from "./gsc.schema"; -export * from "./reddit-attribution.schema"; +import { getDatabaseProvider } from "./provider"; +import * as sqliteApp from "./app.schema"; +import * as sqliteAuth from "./better-auth-schema"; +import * as sqliteBilling from "./billing.schema"; +import * as sqliteGsc from "./gsc.schema"; +import * as sqliteReddit from "./reddit-attribution.schema"; +import * as pgApp from "./pg/app.schema"; +import * as pgAuth from "./pg/better-auth-schema"; +import * as pgBilling from "./pg/billing.schema"; +import * as pgGsc from "./pg/gsc.schema"; +import * as pgReddit from "./pg/reddit-attribution.schema"; + +// Canonical schema barrel. Repositories import their tables from here and the +// provider-aware `db` from "@/db", so each repository is written ONCE for both +// backends. +// +// The TYPE identity is the SQLite definitions; the runtime VALUES are whichever +// provider is active. `schema-parity.test.ts` asserts the two dialect schemas +// are structurally interchangeable (same tables/columns/nullability/PKs/unique +// indexes), which is what makes the single cast below sound. The Postgres +// schema is the one structural artifact NOT regenerated by `db:generate`, so the +// parity test is its drift guard. +type AppSchema = typeof sqliteApp & + typeof sqliteAuth & + typeof sqliteBilling & + typeof sqliteGsc & + typeof sqliteReddit; + +const runtimeSchema = + getDatabaseProvider() === "postgres" + ? { ...pgApp, ...pgAuth, ...pgBilling, ...pgGsc, ...pgReddit } + : { + ...sqliteApp, + ...sqliteAuth, + ...sqliteBilling, + ...sqliteGsc, + ...sqliteReddit, + }; + +// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts +const schema = runtimeSchema as unknown as AppSchema; + +export const { + userOnboardingAnswers, + projects, + savedKeywords, + savedKeywordTags, + savedKeywordTagAssignments, + keywordMetrics, + rankTrackingConfigs, + rankTrackingKeywords, + rankCheckRuns, + rankSnapshots, + audits, + auditPages, + auditLighthouseResults, + user, + session, + account, + verification, + organization, + member, + invitation, + billingCustomerStatus, + gscConnections, + redditAttributions, +} = schema; diff --git a/src/env.d.ts b/src/env.d.ts index 1b2205d..65a0059 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -17,6 +17,11 @@ declare namespace Cloudflare { POSTHOG_HOST?: string; BETTER_AUTH_SECRET?: string; BETTER_AUTH_URL?: string; + DATABASE_PROVIDER?: "d1" | "postgres"; + POSTGRES_DATABASE_URL?: string; + HYPERDRIVE?: { + connectionString: string; + }; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; LOOPS_API_KEY?: string; @@ -37,6 +42,7 @@ declare namespace Cloudflare { interface ImportMetaEnv { readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; + readonly DATABASE_PROVIDER?: "d1" | "postgres"; readonly BYPASS_EMAIL_VERIFICATION?: string; readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_HOST?: string; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 2ed3d8a..4493439 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,7 +2,11 @@ import { env } from "cloudflare:workers"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; -import { db } from "@/db"; +import * as d1Schema from "@/db/d1/schema"; +import { d1Db } from "@/db/d1/client"; +import { pgDb } from "@/db/pg/client"; +import * as pgSchema from "@/db/pg/schema"; +import { getDatabaseProvider } from "@/db/provider"; import { z } from "zod"; import { isHostedAuthMode } from "@/lib/auth-mode"; import { createBaseAuthConfig } from "@/lib/auth-config"; @@ -34,6 +38,17 @@ function createAuth() { const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const baseAuthConfig = createBaseAuthConfig(); + const database = + getDatabaseProvider() === "postgres" + ? drizzleAdapter(pgDb, { + provider: "pg", + schema: pgSchema, + }) + : drizzleAdapter(d1Db, { + provider: "sqlite", + schema: d1Schema, + }); + const auth = betterAuth({ baseURL: baseUrl, secret: getHostedSecret(), @@ -64,9 +79,7 @@ function createAuth() { }, socialProviders: getSocialProviders(), trustedOrigins: getTrustedOrigins(baseUrl), - database: drizzleAdapter(db, { - provider: "sqlite", - }), + database, plugins: [...baseAuthConfig.plugins, tanstackStartCookies()], databaseHooks: { user: { diff --git a/src/server.ts b/src/server.ts index ebc93f6..f7270bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,12 +5,8 @@ import { import { routeAgentRequest } from "agents"; import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; -import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; -import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; -import { - customerHasPaidPlan, - getOrCreateOrganizationCustomer, -} from "@/server/billing/subscription"; +import { runScheduledRankChecks } from "@/server/features/rank-tracking/services/scheduledRankChecks"; +import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { @@ -20,10 +16,7 @@ import { import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; import { MCP_ROUTE } from "@/server/mcp/context"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; -import { - computeNextCheckAt, - isScheduledRankTrackingInterval, -} from "@/shared/rank-tracking"; +import { withPgClient } from "@/db"; import { AUTUMN_WEBHOOK_PATH, handleAutumnWebhookRequest, @@ -81,6 +74,16 @@ function fetch( request: Request, env: Env, ctx: ExecutionContext, +): Promise { + // Scope a per-request Postgres client (no-op in D1 mode). The client isn't + // closed here — the Workers↔Hyperdrive socket is reclaimed at invocation end. + return withPgClient(() => Promise.resolve(handleFetch(request, env, ctx))); +} + +function handleFetch( + request: Request, + env: Env, + ctx: ExecutionContext, ): Response | Promise { const authMode = getAuthMode(env.AUTH_MODE); const publicRequest = requestWithPublicOrigin(request); @@ -125,97 +128,7 @@ export default { env: Env, _ctx: ExecutionContext, ) { - const nowIso = new Date().toISOString(); - const dueConfigs = - await RankTrackingRepository.getDueConfigsWithOrganization(nowIso); - - const isHosted = await isHostedServerAuthMode(); - - for (const config of dueConfigs) { - try { - // Skip configs whose org doesn't have a paid plan - if (isHosted && !(await customerHasPaidPlan(config.organizationId))) { - console.log( - `[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`, - ); - continue; - } - - // Skip configs with no keywords before advancing the schedule - const kwCount = await RankTrackingRepository.getKeywordCountForConfig( - config.id, - ); - if (kwCount === 0) { - console.log( - `[cron] Skipping config ${config.id} (${config.domain}) — no keywords`, - ); - // Still advance schedule so this config doesn't stay due forever - const skipInterval = isScheduledRankTrackingInterval( - config.scheduleInterval, - ) - ? config.scheduleInterval - : null; - if (skipInterval) { - await RankTrackingRepository.updateConfig( - config.id, - config.projectId, - { - nextCheckAt: computeNextCheckAt( - skipInterval, - config.nextCheckAt, - ), - }, - ); - } - continue; - } - - // Advance nextCheckAt immediately to prevent retry storms if the run fails - const interval = isScheduledRankTrackingInterval( - config.scheduleInterval, - ) - ? config.scheduleInterval - : null; - if (interval) { - await RankTrackingRepository.updateConfig( - config.id, - config.projectId, - { - nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt), - }, - ); - } - - const result = await beginRankCheckRun({ - workflow: env.RANK_CHECK_WORKFLOW, - config, - projectId: config.projectId, - billingCustomer: { - userId: "system", - userEmail: "system@openseo.so", - organizationId: config.organizationId, - projectId: config.projectId, - }, - keywordsTotal: kwCount, - trigger: "scheduled", - workflowStartErrorMessage: "Failed to start scheduled workflow", - }); - - if (!result.ok) { - console.log( - `[cron] Skipping config ${config.id} (${config.domain}) — run already active`, - ); - } else { - console.log( - `[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`, - ); - } - } catch (err) { - console.error( - `[cron] Error processing config ${config.id} (${config.domain}):`, - err, - ); - } - } + // Scope a per-request Postgres client for the cron run (no-op in D1 mode). + await withPgClient(() => runScheduledRankChecks(env)); }, }; diff --git a/src/server/auth/default-hosted-organization.ts b/src/server/auth/default-hosted-organization.ts index 036395c..1818b51 100644 --- a/src/server/auth/default-hosted-organization.ts +++ b/src/server/auth/default-hosted-organization.ts @@ -1,6 +1,4 @@ -import { asc, eq } from "drizzle-orm"; -import { db } from "@/db"; -import { member, user as authUser } from "@/db/better-auth-schema"; +import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { slugify, toHex } from "./org-slug"; type HostedUser = { @@ -31,26 +29,8 @@ function getDefaultHostedOrganizationSlug(user: HostedUser) { return `${slugify(slugSource)}-${suffix}`; } -async function findFirstOrganizationIdForUser(userId: string) { - const [existingMembership] = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - .orderBy(asc(member.createdAt)) - .limit(1); - - return existingMembership?.organizationId ?? null; -} - async function getHostedUser(userId: string) { - const hostedUser = await db.query.user.findFirst({ - columns: { - id: true, - email: true, - name: true, - }, - where: eq(authUser.id, userId), - }); + const hostedUser = await AuthRepository.getHostedUser(userId); if (!hostedUser?.email) { throw new Error("Failed to resolve hosted user for session setup"); @@ -72,7 +52,9 @@ async function createDefaultHostedOrganization( return createdOrganization.id; } catch (error) { - const organizationId = await findFirstOrganizationIdForUser(user.id); + const organizationId = await AuthRepository.findFirstOrganizationIdForUser( + user.id, + ); if (organizationId) { return organizationId; @@ -86,7 +68,8 @@ export async function getOrCreateDefaultHostedOrganization( userId: string, createOrganization: HostedOrganizationCreator, ) { - const existingOrganizationId = await findFirstOrganizationIdForUser(userId); + const existingOrganizationId = + await AuthRepository.findFirstOrganizationIdForUser(userId); if (existingOrganizationId) { return existingOrganizationId; diff --git a/src/server/auth/delegated-organization.ts b/src/server/auth/delegated-organization.ts index 9aa0daf..12211ae 100644 --- a/src/server/auth/delegated-organization.ts +++ b/src/server/auth/delegated-organization.ts @@ -1,5 +1,4 @@ -import { db } from "@/db"; -import { organization } from "@/db/better-auth-schema"; +import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { slugify, toHex } from "./org-slug"; function getDelegatedOrganizationId(userId: string) { @@ -23,23 +22,11 @@ export async function ensureDelegatedOrganizationForUser( const name = getDelegatedOrganizationName(email, userId); const slug = getDelegatedOrganizationSlug(email, userId); - await db - .insert(organization) - .values({ - id: organizationId, - name, - slug, - logo: null, - createdAt: new Date(), - metadata: null, - }) - .onConflictDoUpdate({ - target: organization.id, - set: { - name, - slug, - }, - }); + await AuthRepository.upsertDelegatedOrganization({ + id: organizationId, + name, + slug, + }); return organizationId; } diff --git a/src/server/auth/repositories/AuthRepository.ts b/src/server/auth/repositories/AuthRepository.ts new file mode 100644 index 0000000..d2158da --- /dev/null +++ b/src/server/auth/repositories/AuthRepository.ts @@ -0,0 +1,57 @@ +import { asc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { member, organization, user as authUser } from "@/db/schema"; + +type DelegatedOrganizationInput = { + id: string; + name: string; + slug: string; +}; + +async function upsertDelegatedOrganization(input: DelegatedOrganizationInput) { + await db + .insert(organization) + .values({ + id: input.id, + name: input.name, + slug: input.slug, + logo: null, + createdAt: new Date(), + metadata: null, + }) + .onConflictDoUpdate({ + target: organization.id, + set: { + name: input.name, + slug: input.slug, + }, + }); +} + +async function findFirstOrganizationIdForUser(userId: string) { + const [existingMembership] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .orderBy(asc(member.createdAt)) + .limit(1); + + return existingMembership?.organizationId ?? null; +} + +async function getHostedUser(userId: string) { + return db.query.user.findFirst({ + columns: { + id: true, + email: true, + name: true, + }, + where: eq(authUser.id, userId), + }); +} + +export const AuthRepository = { + upsertDelegatedOrganization, + findFirstOrganizationIdForUser, + getHostedUser, +} as const; diff --git a/src/server/features/audit/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts index 0cf048f..4760352 100644 --- a/src/server/features/audit/repositories/AuditRepository.ts +++ b/src/server/features/audit/repositories/AuditRepository.ts @@ -1,31 +1,17 @@ /** * Data access layer for site audit tables. - * All D1 interactions for audits, audit_pages, and stored Lighthouse results. + * Provider-aware (D1 or Postgres) via the `@/db` handle. */ import { and, desc, eq } from "drizzle-orm"; import { db } from "@/db"; import { audits, auditLighthouseResults, auditPages } from "@/db/schema"; +import { executeInBatches } from "@/db/runBatch"; import type { AuditConfig, LighthouseResult, StepPageResult, } from "@/server/lib/audit/types"; -const DB_BATCH_SIZE = 100; -type BatchStatement = Parameters[0][number]; - -async function executeInBatches( - items: T[], - buildStatement: (item: T) => BatchStatement, -) { - for (let i = 0; i < items.length; i += DB_BATCH_SIZE) { - const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement); - const [first, ...rest] = chunk; - if (!first) continue; - await db.batch([first, ...rest]); - } -} - async function createAudit(data: { id: string; projectId: string; @@ -130,8 +116,8 @@ async function batchWriteResults( pages: StepPageResult[], lighthouseResults: LighthouseResult[], ) { - await executeInBatches(pages, (page) => - db.insert(auditPages).values({ + await executeInBatches(pages, (tx, page) => + tx.insert(auditPages).values({ id: page.id, auditId, url: page.url, @@ -168,8 +154,8 @@ async function batchWriteResults( return; } - await executeInBatches(lighthouseResults, (result) => - db.insert(auditLighthouseResults).values({ + await executeInBatches(lighthouseResults, (tx, result) => + tx.insert(auditLighthouseResults).values({ id: crypto.randomUUID(), auditId, pageId: result.pageId, diff --git a/src/server/features/keywords/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts index e769000..0028944 100644 --- a/src/server/features/keywords/repositories/KeywordResearchRepository.ts +++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts @@ -11,6 +11,7 @@ import { type SQL, } from "drizzle-orm"; import { db } from "@/db"; +import { runBatch } from "@/db/runBatch"; import { keywordMetrics, savedKeywordTagAssignments, @@ -121,21 +122,21 @@ async function saveKeywordsToProject(params: { }): Promise { if (params.keywords.length === 0) return []; - const [first, ...rest] = params.keywords.map((keyword) => - db - .insert(savedKeywords) - .values({ - id: crypto.randomUUID(), - projectId: params.projectId, - keyword, - locationCode: params.locationCode, - languageCode: params.languageCode, - }) - .onConflictDoNothing(), + await runBatch((tx) => + params.keywords.map((keyword) => + tx + .insert(savedKeywords) + .values({ + id: crypto.randomUUID(), + projectId: params.projectId, + keyword, + locationCode: params.locationCode, + languageCode: params.languageCode, + }) + .onConflictDoNothing(), + ), ); - await db.batch([first, ...rest]); - return listSavedKeywordRowsByKeywords(params); } diff --git a/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts index 8e8455d..23d045f 100644 --- a/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts +++ b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts @@ -1,5 +1,6 @@ import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm"; import { db } from "@/db"; +import { runBatch } from "@/db/runBatch"; import { savedKeywordTagAssignments, savedKeywordTags, @@ -257,18 +258,19 @@ async function upsertSavedKeywordTags( const normalizedTags = normalizeSavedKeywordTags(tagNames); if (normalizedTags.length === 0) return []; - const [first, ...rest] = normalizedTags.map((tag) => - db - .insert(savedKeywordTags) - .values({ - id: crypto.randomUUID(), - projectId, - name: tag.name, - normalizedName: tag.normalizedName, - }) - .onConflictDoNothing(), + await runBatch((tx) => + normalizedTags.map((tag) => + tx + .insert(savedKeywordTags) + .values({ + id: crypto.randomUUID(), + projectId, + name: tag.name, + normalizedName: tag.normalizedName, + }) + .onConflictDoNothing(), + ), ); - await db.batch([first, ...rest]); return db .select() diff --git a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts index 3f56022..254c674 100644 --- a/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts +++ b/src/server/features/rank-tracking/repositories/RankTrackingRepository.ts @@ -8,6 +8,7 @@ import { rankTrackingKeywords, projects, } from "@/db/schema"; +import { executeInBatches } from "@/db/runBatch"; import { getLatestSnapshotsForKeywords, getSnapshotsBeforeDate, @@ -17,21 +18,6 @@ import { getPositionMatrix, } from "./snapshotQueries"; -const DB_BATCH_SIZE = 100; -type BatchStatement = Parameters[0][number]; - -async function executeInBatches( - items: T[], - buildStatement: (item: T) => BatchStatement, -) { - for (let i = 0; i < items.length; i += DB_BATCH_SIZE) { - const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement); - const [first, ...rest] = chunk; - if (!first) continue; - await db.batch([first, ...rest]); - } -} - // --------------------------------------------------------------------------- // Config CRUD // --------------------------------------------------------------------------- @@ -216,8 +202,8 @@ async function insertSnapshots( Omit, "id" | "checkedAt"> >, ) { - await executeInBatches(snapshots, (snapshot) => - db.insert(rankSnapshots).values(snapshot).onConflictDoNothing(), + await executeInBatches(snapshots, (tx, snapshot) => + tx.insert(rankSnapshots).values(snapshot).onConflictDoNothing(), ); } @@ -240,8 +226,8 @@ async function getKeywordsForConfig(configId: string) { async function addKeywordsToConfig( keywords: Array<{ id: string; configId: string; keyword: string }>, ) { - await executeInBatches(keywords, (kw) => - db.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(), + await executeInBatches(keywords, (tx, kw) => + tx.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(), ); } @@ -340,8 +326,8 @@ async function updateKeywordMetrics( metricsFetchedAt: string; }>, ) { - await executeInBatches(updates, (u) => - db + await executeInBatches(updates, (tx, u) => + tx .update(rankTrackingKeywords) .set({ searchVolume: u.searchVolume, diff --git a/src/server/features/rank-tracking/repositories/snapshotQueries.ts b/src/server/features/rank-tracking/repositories/snapshotQueries.ts index 402d724..28ef52a 100644 --- a/src/server/features/rank-tracking/repositories/snapshotQueries.ts +++ b/src/server/features/rank-tracking/repositories/snapshotQueries.ts @@ -229,6 +229,7 @@ export async function getEarliestSnapshotsForKeywords( // D1 caps bound parameters at 100 per statement. The query binds N keyword // IDs plus 4 params from the completedRunIds subquery (referenced twice). + // (Postgres allows far more, but the chunking is harmless there.) const CHUNK_SIZE = 90; const allResults: Awaited> = []; diff --git a/src/server/features/rank-tracking/services/scheduledRankChecks.ts b/src/server/features/rank-tracking/services/scheduledRankChecks.ts new file mode 100644 index 0000000..54a0238 --- /dev/null +++ b/src/server/features/rank-tracking/services/scheduledRankChecks.ts @@ -0,0 +1,96 @@ +import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; +import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; +import { customerHasPaidPlan } from "@/server/billing/subscription"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; +import { + computeNextCheckAt, + isScheduledRankTrackingInterval, +} from "@/shared/rank-tracking"; + +// Cron body for the `scheduled` Worker handler: start a rank-check run for every +// config that's due. Wrapped in `withPgClient` at the entrypoint (server.ts). +export async function runScheduledRankChecks(env: Env) { + const nowIso = new Date().toISOString(); + const dueConfigs = + await RankTrackingRepository.getDueConfigsWithOrganization(nowIso); + + const isHosted = await isHostedServerAuthMode(); + + for (const config of dueConfigs) { + try { + // Skip configs whose org doesn't have a paid plan + if (isHosted && !(await customerHasPaidPlan(config.organizationId))) { + console.log( + `[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`, + ); + continue; + } + + // Skip configs with no keywords before advancing the schedule + const kwCount = await RankTrackingRepository.getKeywordCountForConfig( + config.id, + ); + if (kwCount === 0) { + console.log( + `[cron] Skipping config ${config.id} (${config.domain}) — no keywords`, + ); + // Still advance schedule so this config doesn't stay due forever + const skipInterval = isScheduledRankTrackingInterval( + config.scheduleInterval, + ) + ? config.scheduleInterval + : null; + if (skipInterval) { + await RankTrackingRepository.updateConfig( + config.id, + config.projectId, + { + nextCheckAt: computeNextCheckAt(skipInterval, config.nextCheckAt), + }, + ); + } + continue; + } + + // Advance nextCheckAt immediately to prevent retry storms if the run fails + const interval = isScheduledRankTrackingInterval(config.scheduleInterval) + ? config.scheduleInterval + : null; + if (interval) { + await RankTrackingRepository.updateConfig(config.id, config.projectId, { + nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt), + }); + } + + const result = await beginRankCheckRun({ + workflow: env.RANK_CHECK_WORKFLOW, + config, + projectId: config.projectId, + billingCustomer: { + userId: "system", + userEmail: "system@openseo.so", + organizationId: config.organizationId, + projectId: config.projectId, + }, + keywordsTotal: kwCount, + trigger: "scheduled", + workflowStartErrorMessage: "Failed to start scheduled workflow", + }); + + if (!result.ok) { + console.log( + `[cron] Skipping config ${config.id} (${config.domain}) — run already active`, + ); + } else { + console.log( + `[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`, + ); + } + } catch (err) { + console.error( + `[cron] Error processing config ${config.id} (${config.domain}):`, + err, + ); + } + } +} diff --git a/src/server/workflows/RankCheckWorkflow.ts b/src/server/workflows/RankCheckWorkflow.ts index 681c289..4108dd8 100644 --- a/src/server/workflows/RankCheckWorkflow.ts +++ b/src/server/workflows/RankCheckWorkflow.ts @@ -4,6 +4,7 @@ import { type WorkflowStep, } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; +import { withPgClient } from "@/db"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; @@ -249,6 +250,16 @@ export class RankCheckWorkflow extends WorkflowEntrypoint< RankCheckParams > { async run(event: WorkflowEvent, step: WorkflowStep) { + // Scope a per-request Postgres client for this workflow invocation (no-op in + // D1 mode). The socket is reclaimed when the invocation ends, so there is + // nothing to tear down here. + return withPgClient(() => this.runScoped(event, step)); + } + + private async runScoped( + event: WorkflowEvent, + step: WorkflowStep, + ) { const { runId, configId, diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts index 2b1c9fc..44c3f44 100644 --- a/src/server/workflows/SiteAuditWorkflow.ts +++ b/src/server/workflows/SiteAuditWorkflow.ts @@ -9,6 +9,7 @@ import { type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; +import { withPgClient } from "@/db"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import type { AuditConfig } from "@/server/lib/audit/types"; @@ -25,6 +26,16 @@ interface AuditParams { export class SiteAuditWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep) { + // Scope a per-request Postgres client for this workflow invocation (no-op in + // D1 mode). The socket is reclaimed when the invocation ends, so there is + // nothing to tear down here. + return withPgClient(() => this.runScoped(event, step)); + } + + private async runScoped( + event: WorkflowEvent, + step: WorkflowStep, + ) { const { auditId, billingCustomer, projectId, startUrl, config } = event.payload; diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts index 8451b7d..e2eb3ee 100644 --- a/src/types/schemas/rank-tracking.ts +++ b/src/types/schemas/rank-tracking.ts @@ -1,6 +1,6 @@ import type { InferSelectModel } from "drizzle-orm"; import { z } from "zod"; -import { rankTrackingConfigs } from "@/db/app.schema"; +import { rankTrackingConfigs } from "@/db/schema"; import { domainField } from "@/types/schemas/domain"; // --------------------------------------------------------------------------- diff --git a/wrangler.jsonc b/wrangler.jsonc index 22e84da..f6973f1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -64,6 +64,14 @@ "migrations_dir": "drizzle", }, ], + // Postgres scale path (opt-in). D1 remains the default; leave this commented + // for free self-hosting. To run on Postgres set DATABASE_PROVIDER=postgres and + // uncomment a Hyperdrive binding pointing at your Postgres pooler. Without + // Hyperdrive, POSTGRES_DATABASE_URL is used as a direct-connection fallback + // (no edge pooling/caching — not recommended for the scaled hosted path). + // "hyperdrive": [ + // { "binding": "HYPERDRIVE", "id": "" }, + // ], "r2_buckets": [ { "bucket_name": "open-seo",