Unify dual-backend DB layer (D1 default + Postgres opt-in) (#238)

This commit is contained in:
Ben Senescu 2026-06-29 20:02:01 -04:00 committed by Ben Senescu
parent 3a2dad3a96
commit c5cbe84ce6
46 changed files with 10633 additions and 241 deletions

View File

@ -45,6 +45,25 @@
], ],
"eslint/max-depth": ["error", 4], "eslint/max-depth": ["error", 4],
"eslint/max-params": ["error", 5], "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"
} }
} }
]
}

View File

@ -6,6 +6,7 @@ routeTree.gen.ts
dist/ dist/
drizzle/ drizzle/
drizzle-pg/
planning/ planning/
worker-configuration.d.ts worker-configuration.d.ts
web/ web/

View File

@ -55,6 +55,12 @@ Migrate local DB:
pnpm run db:migrate:local 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 Modes
- `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`. - `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`.

105
docs/LOCAL_POSTGRES.md Normal file
View File

@ -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.

10
drizzle-pg.config.ts Normal file
View File

@ -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!,
},
});

View File

@ -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");

View File

@ -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;

View File

@ -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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -5,7 +5,9 @@ const localUrl = getLocalD1Url();
export default defineConfig({ export default defineConfig({
dialect: "sqlite", 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", out: "./drizzle",
dbCredentials: { dbCredentials: {
url: localUrl || "", // Empty fallback for CI/non-dev environments url: localUrl || "", // Empty fallback for CI/non-dev environments

View File

@ -9,10 +9,13 @@
"src/routes/**/*.tsx", "src/routes/**/*.tsx",
// Drizzle config (plugin disabled due to cloudflare:workers import issues) // Drizzle config (plugin disabled due to cloudflare:workers import issues)
"drizzle.config.ts", "drizzle.config.ts",
"drizzle-pg.config.ts",
// DB schema exports consumed via `import * as schema` / drizzle() // DB schema exports consumed via `import * as schema` / drizzle()
"src/db/index.ts", "src/db/index.ts",
"src/db/schema.ts",
"src/db/app.schema.ts", "src/db/app.schema.ts",
"src/db/better-auth-schema.ts", "src/db/better-auth-schema.ts",
"src/db/pg/schema.ts",
// Standalone CLI/dev scripts, invoked via package.json scripts // Standalone CLI/dev scripts, invoked via package.json scripts
"scripts/**", "scripts/**",
], ],

View File

@ -20,10 +20,16 @@
"types:check": "tsc --noEmit", "types:check": "tsc --noEmit",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"format:write": "prettier . --write", "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", "auth:generate": "npm run auth:generate:d1 && npm run auth:generate:pg",
"db:generate": "drizzle-kit generate", "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:local": "wrangler d1 migrations apply DB --local",
"db:migrate:prod": "wrangler d1 migrations apply DB --remote", "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", "knip": "knip",
"release:notes": "node scripts/release-notes.mjs", "release:notes": "node scripts/release-notes.mjs",
"test": "vitest run", "test": "vitest run",
@ -85,6 +91,7 @@
"jose": "^6.0.12", "jose": "^6.0.12",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"postgres": "^3.4.9",
"posthog-js": "^1.363.5", "posthog-js": "^1.363.5",
"posthog-node": "^5.28.5", "posthog-node": "^5.28.5",
"react": "^19.0.0", "react": "^19.0.0",

32
pnpm-lock.yaml generated
View File

@ -55,10 +55,10 @@ importers:
version: 6.0.199(zod@4.3.6) version: 6.0.199(zod@4.3.6)
autumn-js: autumn-js:
specifier: ^1.1.7 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: better-auth:
specifier: ^1.5.5 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: cheerio:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
@ -73,7 +73,7 @@ importers:
version: 2.0.19 version: 2.0.19
drizzle-orm: drizzle-orm:
specifier: ^0.44.4 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: fast-xml-parser:
specifier: ^5.4.1 specifier: ^5.4.1
version: 5.8.0 version: 5.8.0
@ -86,6 +86,9 @@ importers:
papaparse: papaparse:
specifier: ^5.5.3 specifier: ^5.5.3
version: 5.5.3 version: 5.5.3
postgres:
specifier: ^3.4.9
version: 3.4.9
posthog-js: posthog-js:
specifier: ^1.363.5 specifier: ^1.363.5
version: 1.363.6 version: 1.363.6
@ -4279,6 +4282,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postgres@3.4.9:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
posthog-js@1.363.6: posthog-js@1.363.6:
resolution: {integrity: sha512-eQ+Ypml3JOEMQWt21XEea6J8vD77TNyoz4Yv/xxjlTRja+ilmJtQw/SuVAB3BobjgHKYUomXX7Fc4gH/zTVpbg==} resolution: {integrity: sha512-eQ+Ypml3JOEMQWt21XEea6J8vD77TNyoz4Yv/xxjlTRja+ilmJtQw/SuVAB3BobjgHKYUomXX7Fc4gH/zTVpbg==}
@ -5316,12 +5323,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@cloudflare/workers-types': 4.20260611.1 '@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: 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/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/utils': 0.3.1
optionalDependencies: 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)': '@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: dependencies:
@ -7162,13 +7169,13 @@ snapshots:
asynckit@0.4.0: {} 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: dependencies:
query-string: 9.3.1 query-string: 9.3.1
rou3: 0.6.3 rou3: 0.6.3
zod: 4.3.6 zod: 4.3.6
optionalDependencies: 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) better-call: 1.3.2(zod@4.3.6)
express: 5.2.1 express: 5.2.1
hono: 4.12.18 hono: 4.12.18
@ -7192,10 +7199,10 @@ snapshots:
baseline-browser-mapping@2.10.0: {} 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: 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/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/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/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)) '@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: 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)) '@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-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) mongodb: 7.2.0(@mongodb-js/zstd@7.0.0)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@ -7549,12 +7556,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: optionalDependencies:
'@cloudflare/workers-types': 4.20260611.1 '@cloudflare/workers-types': 4.20260611.1
'@libsql/client': 0.15.15 '@libsql/client': 0.15.15
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
kysely: 0.28.12 kysely: 0.28.12
postgres: 3.4.9
sql.js: 1.14.1 sql.js: 1.14.1
dunder-proto@1.0.1: dunder-proto@1.0.1:
@ -8857,6 +8865,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postgres@3.4.9: {}
posthog-js@1.363.6: posthog-js@1.363.6:
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1

5
src/db/d1/client.ts Normal file
View File

@ -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 });

8
src/db/d1/schema.ts Normal file
View File

@ -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";

View File

@ -1,6 +1,19 @@
import { drizzle } from "drizzle-orm/d1"; import { getDatabaseProvider } from "./provider";
import { env } from "cloudflare:workers"; import { d1Db } from "./d1/client";
import * as schema from "./schema"; import { pgDb } from "./pg/client";
// Helper function to get the database instance from D1 binding // Per-request Postgres client scope (no-op in D1 mode). Re-exported here so
export const db = drizzle(env.DB, { schema }); // 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;

440
src/db/pg/app.schema.ts Normal file
View File

@ -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)],
);

View File

@ -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],
}),
}));

View File

@ -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),
});

73
src/db/pg/client.ts Normal file
View File

@ -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<typeof postgres>;
function createPgDb(sql: Sql) {
return drizzle(sql, { schema });
}
const pgClientStore = new AsyncLocalStorage<{
sql: Sql;
db: ReturnType<typeof createPgDb>;
}>();
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<typeof createPgDb>;
/**
* 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 WorkersHyperdrive
* 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<T>(fn: () => Promise<T>): Promise<T> {
if (getDatabaseProvider() !== "postgres") {
return fn();
}
const sql = postgres(getPostgresConnectionString(), {
max: 1,
fetch_types: false,
});
return pgClientStore.run({ sql, db: createPgDb(sql) }, fn);
}

37
src/db/pg/gsc.schema.ts Normal file
View File

@ -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),
],
);

View File

@ -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),
],
);

5
src/db/pg/schema.ts Normal file
View File

@ -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";

38
src/db/provider.ts Normal file
View File

@ -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.",
);
}

66
src/db/runBatch.ts Normal file
View File

@ -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<typeof d1Db.batch>[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<unknown>[],
): Promise<void> {
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<unknown>,
) => Promise<unknown>;
};
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<T>(
items: T[],
buildStatement: (tx: BatchExecutor, item: T) => Promise<unknown>,
): Promise<void> {
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)));
}
}

View File

@ -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<string, unknown>[]) {
const out = new Map<string, Table>();
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<string>();
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<string>();
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([]);
});
});

View File

@ -1,5 +1,67 @@
export * from "./app.schema"; import { getDatabaseProvider } from "./provider";
export * from "./better-auth-schema"; import * as sqliteApp from "./app.schema";
export * from "./billing.schema"; import * as sqliteAuth from "./better-auth-schema";
export * from "./gsc.schema"; import * as sqliteBilling from "./billing.schema";
export * from "./reddit-attribution.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;

6
src/env.d.ts vendored
View File

@ -17,6 +17,11 @@ declare namespace Cloudflare {
POSTHOG_HOST?: string; POSTHOG_HOST?: string;
BETTER_AUTH_SECRET?: string; BETTER_AUTH_SECRET?: string;
BETTER_AUTH_URL?: string; BETTER_AUTH_URL?: string;
DATABASE_PROVIDER?: "d1" | "postgres";
POSTGRES_DATABASE_URL?: string;
HYPERDRIVE?: {
connectionString: string;
};
GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string; GOOGLE_CLIENT_SECRET?: string;
LOOPS_API_KEY?: string; LOOPS_API_KEY?: string;
@ -37,6 +42,7 @@ declare namespace Cloudflare {
interface ImportMetaEnv { interface ImportMetaEnv {
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
readonly DATABASE_PROVIDER?: "d1" | "postgres";
readonly BYPASS_EMAIL_VERIFICATION?: string; readonly BYPASS_EMAIL_VERIFICATION?: string;
readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_PUBLIC_KEY?: string;
readonly POSTHOG_HOST?: string; readonly POSTHOG_HOST?: string;

View File

@ -2,7 +2,11 @@ import { env } from "cloudflare:workers";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start"; 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 { z } from "zod";
import { isHostedAuthMode } from "@/lib/auth-mode"; import { isHostedAuthMode } from "@/lib/auth-mode";
import { createBaseAuthConfig } from "@/lib/auth-config"; import { createBaseAuthConfig } from "@/lib/auth-config";
@ -34,6 +38,17 @@ function createAuth() {
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(); const baseAuthConfig = createBaseAuthConfig();
const database =
getDatabaseProvider() === "postgres"
? drizzleAdapter(pgDb, {
provider: "pg",
schema: pgSchema,
})
: drizzleAdapter(d1Db, {
provider: "sqlite",
schema: d1Schema,
});
const auth = betterAuth({ const auth = betterAuth({
baseURL: baseUrl, baseURL: baseUrl,
secret: getHostedSecret(), secret: getHostedSecret(),
@ -64,9 +79,7 @@ function createAuth() {
}, },
socialProviders: getSocialProviders(), socialProviders: getSocialProviders(),
trustedOrigins: getTrustedOrigins(baseUrl), trustedOrigins: getTrustedOrigins(baseUrl),
database: drizzleAdapter(db, { database,
provider: "sqlite",
}),
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()], plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
databaseHooks: { databaseHooks: {
user: { user: {

View File

@ -5,12 +5,8 @@ import {
import { routeAgentRequest } from "agents"; import { routeAgentRequest } from "agents";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve"; import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { runScheduledRankChecks } from "@/server/features/rank-tracking/services/scheduledRankChecks";
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import {
customerHasPaidPlan,
getOrCreateOrganizationCustomer,
} from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import { import {
@ -20,10 +16,7 @@ import {
import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_ROUTE } from "@/server/mcp/context"; import { MCP_ROUTE } from "@/server/mcp/context";
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { import { withPgClient } from "@/db";
computeNextCheckAt,
isScheduledRankTrackingInterval,
} from "@/shared/rank-tracking";
import { import {
AUTUMN_WEBHOOK_PATH, AUTUMN_WEBHOOK_PATH,
handleAutumnWebhookRequest, handleAutumnWebhookRequest,
@ -81,6 +74,16 @@ function fetch(
request: Request, request: Request,
env: Env, env: Env,
ctx: ExecutionContext, ctx: ExecutionContext,
): Promise<Response> {
// 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<Response> { ): Response | Promise<Response> {
const authMode = getAuthMode(env.AUTH_MODE); const authMode = getAuthMode(env.AUTH_MODE);
const publicRequest = requestWithPublicOrigin(request); const publicRequest = requestWithPublicOrigin(request);
@ -125,97 +128,7 @@ export default {
env: Env, env: Env,
_ctx: ExecutionContext, _ctx: ExecutionContext,
) { ) {
const nowIso = new Date().toISOString(); // Scope a per-request Postgres client for the cron run (no-op in D1 mode).
const dueConfigs = await withPgClient(() => runScheduledRankChecks(env));
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,
);
}
}
}, },
}; };

View File

@ -1,6 +1,4 @@
import { asc, eq } from "drizzle-orm"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { db } from "@/db";
import { member, user as authUser } from "@/db/better-auth-schema";
import { slugify, toHex } from "./org-slug"; import { slugify, toHex } from "./org-slug";
type HostedUser = { type HostedUser = {
@ -31,26 +29,8 @@ function getDefaultHostedOrganizationSlug(user: HostedUser) {
return `${slugify(slugSource)}-${suffix}`; 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) { async function getHostedUser(userId: string) {
const hostedUser = await db.query.user.findFirst({ const hostedUser = await AuthRepository.getHostedUser(userId);
columns: {
id: true,
email: true,
name: true,
},
where: eq(authUser.id, userId),
});
if (!hostedUser?.email) { if (!hostedUser?.email) {
throw new Error("Failed to resolve hosted user for session setup"); throw new Error("Failed to resolve hosted user for session setup");
@ -72,7 +52,9 @@ async function createDefaultHostedOrganization(
return createdOrganization.id; return createdOrganization.id;
} catch (error) { } catch (error) {
const organizationId = await findFirstOrganizationIdForUser(user.id); const organizationId = await AuthRepository.findFirstOrganizationIdForUser(
user.id,
);
if (organizationId) { if (organizationId) {
return organizationId; return organizationId;
@ -86,7 +68,8 @@ export async function getOrCreateDefaultHostedOrganization(
userId: string, userId: string,
createOrganization: HostedOrganizationCreator, createOrganization: HostedOrganizationCreator,
) { ) {
const existingOrganizationId = await findFirstOrganizationIdForUser(userId); const existingOrganizationId =
await AuthRepository.findFirstOrganizationIdForUser(userId);
if (existingOrganizationId) { if (existingOrganizationId) {
return existingOrganizationId; return existingOrganizationId;

View File

@ -1,5 +1,4 @@
import { db } from "@/db"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { organization } from "@/db/better-auth-schema";
import { slugify, toHex } from "./org-slug"; import { slugify, toHex } from "./org-slug";
function getDelegatedOrganizationId(userId: string) { function getDelegatedOrganizationId(userId: string) {
@ -23,22 +22,10 @@ export async function ensureDelegatedOrganizationForUser(
const name = getDelegatedOrganizationName(email, userId); const name = getDelegatedOrganizationName(email, userId);
const slug = getDelegatedOrganizationSlug(email, userId); const slug = getDelegatedOrganizationSlug(email, userId);
await db await AuthRepository.upsertDelegatedOrganization({
.insert(organization)
.values({
id: organizationId, id: organizationId,
name, name,
slug, slug,
logo: null,
createdAt: new Date(),
metadata: null,
})
.onConflictDoUpdate({
target: organization.id,
set: {
name,
slug,
},
}); });
return organizationId; return organizationId;

View File

@ -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;

View File

@ -1,31 +1,17 @@
/** /**
* Data access layer for site audit tables. * 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 { and, desc, eq } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { audits, auditLighthouseResults, auditPages } from "@/db/schema"; import { audits, auditLighthouseResults, auditPages } from "@/db/schema";
import { executeInBatches } from "@/db/runBatch";
import type { import type {
AuditConfig, AuditConfig,
LighthouseResult, LighthouseResult,
StepPageResult, StepPageResult,
} from "@/server/lib/audit/types"; } from "@/server/lib/audit/types";
const DB_BATCH_SIZE = 100;
type BatchStatement = Parameters<typeof db.batch>[0][number];
async function executeInBatches<T>(
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: { async function createAudit(data: {
id: string; id: string;
projectId: string; projectId: string;
@ -130,8 +116,8 @@ async function batchWriteResults(
pages: StepPageResult[], pages: StepPageResult[],
lighthouseResults: LighthouseResult[], lighthouseResults: LighthouseResult[],
) { ) {
await executeInBatches(pages, (page) => await executeInBatches(pages, (tx, page) =>
db.insert(auditPages).values({ tx.insert(auditPages).values({
id: page.id, id: page.id,
auditId, auditId,
url: page.url, url: page.url,
@ -168,8 +154,8 @@ async function batchWriteResults(
return; return;
} }
await executeInBatches(lighthouseResults, (result) => await executeInBatches(lighthouseResults, (tx, result) =>
db.insert(auditLighthouseResults).values({ tx.insert(auditLighthouseResults).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
auditId, auditId,
pageId: result.pageId, pageId: result.pageId,

View File

@ -11,6 +11,7 @@ import {
type SQL, type SQL,
} from "drizzle-orm"; } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { runBatch } from "@/db/runBatch";
import { import {
keywordMetrics, keywordMetrics,
savedKeywordTagAssignments, savedKeywordTagAssignments,
@ -121,8 +122,9 @@ async function saveKeywordsToProject(params: {
}): Promise<SavedKeywordRecord[]> { }): Promise<SavedKeywordRecord[]> {
if (params.keywords.length === 0) return []; if (params.keywords.length === 0) return [];
const [first, ...rest] = params.keywords.map((keyword) => await runBatch((tx) =>
db params.keywords.map((keyword) =>
tx
.insert(savedKeywords) .insert(savedKeywords)
.values({ .values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -132,10 +134,9 @@ async function saveKeywordsToProject(params: {
languageCode: params.languageCode, languageCode: params.languageCode,
}) })
.onConflictDoNothing(), .onConflictDoNothing(),
),
); );
await db.batch([first, ...rest]);
return listSavedKeywordRowsByKeywords(params); return listSavedKeywordRowsByKeywords(params);
} }

View File

@ -1,5 +1,6 @@
import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm"; import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { runBatch } from "@/db/runBatch";
import { import {
savedKeywordTagAssignments, savedKeywordTagAssignments,
savedKeywordTags, savedKeywordTags,
@ -257,8 +258,9 @@ async function upsertSavedKeywordTags(
const normalizedTags = normalizeSavedKeywordTags(tagNames); const normalizedTags = normalizeSavedKeywordTags(tagNames);
if (normalizedTags.length === 0) return []; if (normalizedTags.length === 0) return [];
const [first, ...rest] = normalizedTags.map((tag) => await runBatch((tx) =>
db normalizedTags.map((tag) =>
tx
.insert(savedKeywordTags) .insert(savedKeywordTags)
.values({ .values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -267,8 +269,8 @@ async function upsertSavedKeywordTags(
normalizedName: tag.normalizedName, normalizedName: tag.normalizedName,
}) })
.onConflictDoNothing(), .onConflictDoNothing(),
),
); );
await db.batch([first, ...rest]);
return db return db
.select() .select()

View File

@ -8,6 +8,7 @@ import {
rankTrackingKeywords, rankTrackingKeywords,
projects, projects,
} from "@/db/schema"; } from "@/db/schema";
import { executeInBatches } from "@/db/runBatch";
import { import {
getLatestSnapshotsForKeywords, getLatestSnapshotsForKeywords,
getSnapshotsBeforeDate, getSnapshotsBeforeDate,
@ -17,21 +18,6 @@ import {
getPositionMatrix, getPositionMatrix,
} from "./snapshotQueries"; } from "./snapshotQueries";
const DB_BATCH_SIZE = 100;
type BatchStatement = Parameters<typeof db.batch>[0][number];
async function executeInBatches<T>(
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 // Config CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -216,8 +202,8 @@ async function insertSnapshots(
Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt"> Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt">
>, >,
) { ) {
await executeInBatches(snapshots, (snapshot) => await executeInBatches(snapshots, (tx, snapshot) =>
db.insert(rankSnapshots).values(snapshot).onConflictDoNothing(), tx.insert(rankSnapshots).values(snapshot).onConflictDoNothing(),
); );
} }
@ -240,8 +226,8 @@ async function getKeywordsForConfig(configId: string) {
async function addKeywordsToConfig( async function addKeywordsToConfig(
keywords: Array<{ id: string; configId: string; keyword: string }>, keywords: Array<{ id: string; configId: string; keyword: string }>,
) { ) {
await executeInBatches(keywords, (kw) => await executeInBatches(keywords, (tx, kw) =>
db.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(), tx.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
); );
} }
@ -340,8 +326,8 @@ async function updateKeywordMetrics(
metricsFetchedAt: string; metricsFetchedAt: string;
}>, }>,
) { ) {
await executeInBatches(updates, (u) => await executeInBatches(updates, (tx, u) =>
db tx
.update(rankTrackingKeywords) .update(rankTrackingKeywords)
.set({ .set({
searchVolume: u.searchVolume, searchVolume: u.searchVolume,

View File

@ -229,6 +229,7 @@ export async function getEarliestSnapshotsForKeywords(
// D1 caps bound parameters at 100 per statement. The query binds N keyword // D1 caps bound parameters at 100 per statement. The query binds N keyword
// IDs plus 4 params from the completedRunIds subquery (referenced twice). // 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 CHUNK_SIZE = 90;
const allResults: Awaited<ReturnType<typeof getSnapshotsForConfig>> = []; const allResults: Awaited<ReturnType<typeof getSnapshotsForConfig>> = [];

View File

@ -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,
);
}
}
}

View File

@ -4,6 +4,7 @@ import {
type WorkflowStep, type WorkflowStep,
} from "cloudflare:workers"; } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows"; import { NonRetryableError } from "cloudflare:workflows";
import { withPgClient } from "@/db";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
@ -249,6 +250,16 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
RankCheckParams RankCheckParams
> { > {
async run(event: WorkflowEvent<RankCheckParams>, step: WorkflowStep) { async run(event: WorkflowEvent<RankCheckParams>, 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<RankCheckParams>,
step: WorkflowStep,
) {
const { const {
runId, runId,
configId, configId,

View File

@ -9,6 +9,7 @@ import {
type WorkflowEvent, type WorkflowEvent,
type WorkflowStep, type WorkflowStep,
} from "cloudflare:workers"; } from "cloudflare:workers";
import { withPgClient } from "@/db";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import type { AuditConfig } from "@/server/lib/audit/types"; import type { AuditConfig } from "@/server/lib/audit/types";
@ -25,6 +26,16 @@ interface AuditParams {
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> { export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) { async run(event: WorkflowEvent<AuditParams>, 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<AuditParams>,
step: WorkflowStep,
) {
const { auditId, billingCustomer, projectId, startUrl, config } = const { auditId, billingCustomer, projectId, startUrl, config } =
event.payload; event.payload;

View File

@ -1,6 +1,6 @@
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { rankTrackingConfigs } from "@/db/app.schema"; import { rankTrackingConfigs } from "@/db/schema";
import { domainField } from "@/types/schemas/domain"; import { domainField } from "@/types/schemas/domain";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -64,6 +64,14 @@
"migrations_dir": "drizzle", "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": "<your-hyperdrive-config-id>" },
// ],
"r2_buckets": [ "r2_buckets": [
{ {
"bucket_name": "open-seo", "bucket_name": "open-seo",