Unify dual-backend DB layer (D1 default + Postgres opt-in) (#238)
This commit is contained in:
parent
3a2dad3a96
commit
c5cbe84ce6
@ -45,6 +45,25 @@
|
||||
],
|
||||
"eslint/max-depth": ["error", 4],
|
||||
"eslint/max-params": ["error", 5],
|
||||
"import/no-cycle": "error"
|
||||
"import/no-cycle": "error",
|
||||
"eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@/db/d1", "@/db/d1/*", "@/db/pg", "@/db/pg/*"],
|
||||
"message": "Import the provider-aware `db` from \"@/db\", tables from \"@/db/schema\", and batched writes via \"@/db/runBatch\". Dialect-specific clients/schemas may only be imported by the seam files in src/db and src/lib/auth.ts, otherwise the code silently bypasses DATABASE_PROVIDER."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/db/**", "src/lib/auth.ts"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ routeTree.gen.ts
|
||||
|
||||
dist/
|
||||
drizzle/
|
||||
drizzle-pg/
|
||||
planning/
|
||||
worker-configuration.d.ts
|
||||
web/
|
||||
|
||||
@ -55,6 +55,12 @@ Migrate local DB:
|
||||
pnpm run db:migrate:local
|
||||
```
|
||||
|
||||
## Postgres backend (optional)
|
||||
|
||||
D1 (SQLite) is the default. To run against Postgres locally instead — the opt-in
|
||||
backend for installs that outgrow D1 — see
|
||||
[`LOCAL_POSTGRES.md`](./LOCAL_POSTGRES.md).
|
||||
|
||||
## Auth Modes
|
||||
|
||||
- `AUTH_MODE=cloudflare_access` (default): validates Cloudflare Access JWTs (`cf-access-jwt-assertion`) using `TEAM_DOMAIN` + `POLICY_AUD`.
|
||||
|
||||
105
docs/LOCAL_POSTGRES.md
Normal file
105
docs/LOCAL_POSTGRES.md
Normal 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
10
drizzle-pg.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
352
drizzle-pg/0000_fixed_nico_minoru.sql
Normal file
352
drizzle-pg/0000_fixed_nico_minoru.sql
Normal 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");
|
||||
15
drizzle-pg/0001_striped_bulldozer.sql
Normal file
15
drizzle-pg/0001_striped_bulldozer.sql
Normal 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;
|
||||
2
drizzle-pg/0002_clean_moira_mactaggert.sql
Normal file
2
drizzle-pg/0002_clean_moira_mactaggert.sql
Normal 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;
|
||||
2771
drizzle-pg/meta/0000_snapshot.json
Normal file
2771
drizzle-pg/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2855
drizzle-pg/meta/0001_snapshot.json
Normal file
2855
drizzle-pg/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2869
drizzle-pg/meta/0002_snapshot.json
Normal file
2869
drizzle-pg/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
drizzle-pg/meta/_journal.json
Normal file
27
drizzle-pg/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -5,7 +5,9 @@ const localUrl = getLocalD1Url();
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
// The raw SQLite barrel (not ../schema, the provider-aware one, which imports
|
||||
// cloudflare:workers and can't load under drizzle-kit's node runtime).
|
||||
schema: "./src/db/d1/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: localUrl || "", // Empty fallback for CI/non-dev environments
|
||||
|
||||
@ -9,10 +9,13 @@
|
||||
"src/routes/**/*.tsx",
|
||||
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
|
||||
"drizzle.config.ts",
|
||||
"drizzle-pg.config.ts",
|
||||
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||
"src/db/index.ts",
|
||||
"src/db/schema.ts",
|
||||
"src/db/app.schema.ts",
|
||||
"src/db/better-auth-schema.ts",
|
||||
"src/db/pg/schema.ts",
|
||||
// Standalone CLI/dev scripts, invoked via package.json scripts
|
||||
"scripts/**",
|
||||
],
|
||||
|
||||
11
package.json
11
package.json
@ -20,10 +20,16 @@
|
||||
"types:check": "tsc --noEmit",
|
||||
"format:check": "prettier --check .",
|
||||
"format:write": "prettier . --write",
|
||||
"auth:generate": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"auth:generate": "npm run auth:generate:d1 && npm run auth:generate:pg",
|
||||
"auth:generate:d1": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect sqlite --output ./src/db/better-auth-schema.ts && prettier --write ./src/db/better-auth-schema.ts",
|
||||
"auth:generate:pg": "pnpm dlx auth@latest generate --config ./cli-auth.ts --adapter drizzle --dialect pg --output ./src/db/pg/better-auth-schema.ts && prettier --write ./src/db/pg/better-auth-schema.ts",
|
||||
"db:generate": "npm run db:generate:d1 && npm run db:generate:pg",
|
||||
"db:generate:d1": "drizzle-kit generate",
|
||||
"db:generate:pg": "drizzle-kit generate --config drizzle-pg.config.ts",
|
||||
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply DB --remote",
|
||||
"db:migrate:pg": "drizzle-kit migrate --config drizzle-pg.config.ts",
|
||||
"deploy:postgres": "npm run db:migrate:pg && npm run build && wrangler deploy",
|
||||
"knip": "knip",
|
||||
"release:notes": "node scripts/release-notes.mjs",
|
||||
"test": "vitest run",
|
||||
@ -85,6 +91,7 @@
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.542.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"postgres": "^3.4.9",
|
||||
"posthog-js": "^1.363.5",
|
||||
"posthog-node": "^5.28.5",
|
||||
"react": "^19.0.0",
|
||||
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -55,10 +55,10 @@ importers:
|
||||
version: 6.0.199(zod@4.3.6)
|
||||
autumn-js:
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
|
||||
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
version: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
cheerio:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
@ -73,7 +73,7 @@ importers:
|
||||
version: 2.0.19
|
||||
drizzle-orm:
|
||||
specifier: ^0.44.4
|
||||
version: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1)
|
||||
version: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1)
|
||||
fast-xml-parser:
|
||||
specifier: ^5.4.1
|
||||
version: 5.8.0
|
||||
@ -86,6 +86,9 @@ importers:
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
postgres:
|
||||
specifier: ^3.4.9
|
||||
version: 3.4.9
|
||||
posthog-js:
|
||||
specifier: ^1.363.5
|
||||
version: 1.363.6
|
||||
@ -4279,6 +4282,10 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres@3.4.9:
|
||||
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
posthog-js@1.363.6:
|
||||
resolution: {integrity: sha512-eQ+Ypml3JOEMQWt21XEea6J8vD77TNyoz4Yv/xxjlTRja+ilmJtQw/SuVAB3BobjgHKYUomXX7Fc4gH/zTVpbg==}
|
||||
|
||||
@ -5316,12 +5323,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260611.1
|
||||
|
||||
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))':
|
||||
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1)
|
||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1)
|
||||
|
||||
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12)':
|
||||
dependencies:
|
||||
@ -7162,13 +7169,13 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4):
|
||||
autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4):
|
||||
dependencies:
|
||||
query-string: 9.3.1
|
||||
rou3: 0.6.3
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
better-auth: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
better-auth: 1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
better-call: 1.3.2(zod@4.3.6)
|
||||
express: 5.2.1
|
||||
hono: 4.12.18
|
||||
@ -7192,10 +7199,10 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
|
||||
better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
better-auth@1.5.5(@cloudflare/workers-types@4.20260611.1)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
|
||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1))
|
||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1))
|
||||
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12)
|
||||
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260611.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.2.0(@mongodb-js/zstd@7.0.0))
|
||||
@ -7214,7 +7221,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@tanstack/react-start': 1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1)
|
||||
drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1)
|
||||
mongodb: 7.2.0(@mongodb-js/zstd@7.0.0)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
@ -7549,12 +7556,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(sql.js@1.14.1):
|
||||
drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)(postgres@3.4.9)(sql.js@1.14.1):
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260611.1
|
||||
'@libsql/client': 0.15.15
|
||||
'@opentelemetry/api': 1.9.1
|
||||
kysely: 0.28.12
|
||||
postgres: 3.4.9
|
||||
sql.js: 1.14.1
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@ -8857,6 +8865,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres@3.4.9: {}
|
||||
|
||||
posthog-js@1.363.6:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
5
src/db/d1/client.ts
Normal file
5
src/db/d1/client.ts
Normal 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
8
src/db/d1/schema.ts
Normal 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";
|
||||
@ -1,6 +1,19 @@
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import { env } from "cloudflare:workers";
|
||||
import * as schema from "./schema";
|
||||
import { getDatabaseProvider } from "./provider";
|
||||
import { d1Db } from "./d1/client";
|
||||
import { pgDb } from "./pg/client";
|
||||
|
||||
// Helper function to get the database instance from D1 binding
|
||||
export const db = drizzle(env.DB, { schema });
|
||||
// Per-request Postgres client scope (no-op in D1 mode). Re-exported here so
|
||||
// entrypoints import it from "@/db" rather than the dialect-specific client.
|
||||
export { withPgClient } from "./pg/client";
|
||||
|
||||
// Provider-aware database handle. D1 is the default (free, zero-config self-host
|
||||
// on the Cloudflare free plan); Postgres is opt-in via DATABASE_PROVIDER=postgres.
|
||||
//
|
||||
// Typed as the D1 client so repositories get full Drizzle inference (the
|
||||
// parity test guarantees the Postgres schema is structurally identical). The
|
||||
// Postgres driver has no `.batch`, so atomic multi-statement writes must go
|
||||
// through `runBatch` (./runBatch) rather than `db.batch`.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts
|
||||
export const db = (getDatabaseProvider() === "postgres"
|
||||
? pgDb
|
||||
: d1Db) as unknown as typeof d1Db;
|
||||
|
||||
440
src/db/pg/app.schema.ts
Normal file
440
src/db/pg/app.schema.ts
Normal 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)],
|
||||
);
|
||||
188
src/db/pg/better-auth-schema.ts
Normal file
188
src/db/pg/better-auth-schema.ts
Normal 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],
|
||||
}),
|
||||
}));
|
||||
21
src/db/pg/billing.schema.ts
Normal file
21
src/db/pg/billing.schema.ts
Normal 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
73
src/db/pg/client.ts
Normal 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 Workers↔Hyperdrive
|
||||
* socket is torn down automatically when the invocation ends, and the pooled
|
||||
* origin connection stays warm for reuse. Not ending it also means a streamed
|
||||
* response can keep querying after the handler returns. (Without a Hyperdrive
|
||||
* binding, `POSTGRES_DATABASE_URL` opens a direct connection that the Workers
|
||||
* runtime still reclaims at invocation end.)
|
||||
*/
|
||||
export async function withPgClient<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
37
src/db/pg/gsc.schema.ts
Normal 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),
|
||||
],
|
||||
);
|
||||
36
src/db/pg/reddit-attribution.schema.ts
Normal file
36
src/db/pg/reddit-attribution.schema.ts
Normal 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
5
src/db/pg/schema.ts
Normal 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
38
src/db/provider.ts
Normal 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
66
src/db/runBatch.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
285
src/db/schema-parity.test.ts
Normal file
285
src/db/schema-parity.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,67 @@
|
||||
export * from "./app.schema";
|
||||
export * from "./better-auth-schema";
|
||||
export * from "./billing.schema";
|
||||
export * from "./gsc.schema";
|
||||
export * from "./reddit-attribution.schema";
|
||||
import { getDatabaseProvider } from "./provider";
|
||||
import * as sqliteApp from "./app.schema";
|
||||
import * as sqliteAuth from "./better-auth-schema";
|
||||
import * as sqliteBilling from "./billing.schema";
|
||||
import * as sqliteGsc from "./gsc.schema";
|
||||
import * as sqliteReddit from "./reddit-attribution.schema";
|
||||
import * as pgApp from "./pg/app.schema";
|
||||
import * as pgAuth from "./pg/better-auth-schema";
|
||||
import * as pgBilling from "./pg/billing.schema";
|
||||
import * as pgGsc from "./pg/gsc.schema";
|
||||
import * as pgReddit from "./pg/reddit-attribution.schema";
|
||||
|
||||
// Canonical schema barrel. Repositories import their tables from here and the
|
||||
// provider-aware `db` from "@/db", so each repository is written ONCE for both
|
||||
// backends.
|
||||
//
|
||||
// The TYPE identity is the SQLite definitions; the runtime VALUES are whichever
|
||||
// provider is active. `schema-parity.test.ts` asserts the two dialect schemas
|
||||
// are structurally interchangeable (same tables/columns/nullability/PKs/unique
|
||||
// indexes), which is what makes the single cast below sound. The Postgres
|
||||
// schema is the one structural artifact NOT regenerated by `db:generate`, so the
|
||||
// parity test is its drift guard.
|
||||
type AppSchema = typeof sqliteApp &
|
||||
typeof sqliteAuth &
|
||||
typeof sqliteBilling &
|
||||
typeof sqliteGsc &
|
||||
typeof sqliteReddit;
|
||||
|
||||
const runtimeSchema =
|
||||
getDatabaseProvider() === "postgres"
|
||||
? { ...pgApp, ...pgAuth, ...pgBilling, ...pgGsc, ...pgReddit }
|
||||
: {
|
||||
...sqliteApp,
|
||||
...sqliteAuth,
|
||||
...sqliteBilling,
|
||||
...sqliteGsc,
|
||||
...sqliteReddit,
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts
|
||||
const schema = runtimeSchema as unknown as AppSchema;
|
||||
|
||||
export const {
|
||||
userOnboardingAnswers,
|
||||
projects,
|
||||
savedKeywords,
|
||||
savedKeywordTags,
|
||||
savedKeywordTagAssignments,
|
||||
keywordMetrics,
|
||||
rankTrackingConfigs,
|
||||
rankTrackingKeywords,
|
||||
rankCheckRuns,
|
||||
rankSnapshots,
|
||||
audits,
|
||||
auditPages,
|
||||
auditLighthouseResults,
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
verification,
|
||||
organization,
|
||||
member,
|
||||
invitation,
|
||||
billingCustomerStatus,
|
||||
gscConnections,
|
||||
redditAttributions,
|
||||
} = schema;
|
||||
|
||||
6
src/env.d.ts
vendored
6
src/env.d.ts
vendored
@ -17,6 +17,11 @@ declare namespace Cloudflare {
|
||||
POSTHOG_HOST?: string;
|
||||
BETTER_AUTH_SECRET?: string;
|
||||
BETTER_AUTH_URL?: string;
|
||||
DATABASE_PROVIDER?: "d1" | "postgres";
|
||||
POSTGRES_DATABASE_URL?: string;
|
||||
HYPERDRIVE?: {
|
||||
connectionString: string;
|
||||
};
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_SECRET?: string;
|
||||
LOOPS_API_KEY?: string;
|
||||
@ -37,6 +42,7 @@ declare namespace Cloudflare {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
readonly DATABASE_PROVIDER?: "d1" | "postgres";
|
||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||
readonly POSTHOG_HOST?: string;
|
||||
|
||||
@ -2,7 +2,11 @@ import { env } from "cloudflare:workers";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { db } from "@/db";
|
||||
import * as d1Schema from "@/db/d1/schema";
|
||||
import { d1Db } from "@/db/d1/client";
|
||||
import { pgDb } from "@/db/pg/client";
|
||||
import * as pgSchema from "@/db/pg/schema";
|
||||
import { getDatabaseProvider } from "@/db/provider";
|
||||
import { z } from "zod";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||
@ -34,6 +38,17 @@ function createAuth() {
|
||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||
const baseAuthConfig = createBaseAuthConfig();
|
||||
|
||||
const database =
|
||||
getDatabaseProvider() === "postgres"
|
||||
? drizzleAdapter(pgDb, {
|
||||
provider: "pg",
|
||||
schema: pgSchema,
|
||||
})
|
||||
: drizzleAdapter(d1Db, {
|
||||
provider: "sqlite",
|
||||
schema: d1Schema,
|
||||
});
|
||||
|
||||
const auth = betterAuth({
|
||||
baseURL: baseUrl,
|
||||
secret: getHostedSecret(),
|
||||
@ -64,9 +79,7 @@ function createAuth() {
|
||||
},
|
||||
socialProviders: getSocialProviders(),
|
||||
trustedOrigins: getTrustedOrigins(baseUrl),
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
database,
|
||||
plugins: [...baseAuthConfig.plugins, tanstackStartCookies()],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
|
||||
117
src/server.ts
117
src/server.ts
@ -5,12 +5,8 @@ import {
|
||||
import { routeAgentRequest } from "agents";
|
||||
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||
import {
|
||||
customerHasPaidPlan,
|
||||
getOrCreateOrganizationCustomer,
|
||||
} from "@/server/billing/subscription";
|
||||
import { runScheduledRankChecks } from "@/server/features/rank-tracking/services/scheduledRankChecks";
|
||||
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import {
|
||||
@ -20,10 +16,7 @@ import {
|
||||
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
||||
import { MCP_ROUTE } from "@/server/mcp/context";
|
||||
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||
import {
|
||||
computeNextCheckAt,
|
||||
isScheduledRankTrackingInterval,
|
||||
} from "@/shared/rank-tracking";
|
||||
import { withPgClient } from "@/db";
|
||||
import {
|
||||
AUTUMN_WEBHOOK_PATH,
|
||||
handleAutumnWebhookRequest,
|
||||
@ -81,6 +74,16 @@ function fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<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> {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
const publicRequest = requestWithPublicOrigin(request);
|
||||
@ -125,97 +128,7 @@ export default {
|
||||
env: Env,
|
||||
_ctx: ExecutionContext,
|
||||
) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const dueConfigs =
|
||||
await RankTrackingRepository.getDueConfigsWithOrganization(nowIso);
|
||||
|
||||
const isHosted = await isHostedServerAuthMode();
|
||||
|
||||
for (const config of dueConfigs) {
|
||||
try {
|
||||
// Skip configs whose org doesn't have a paid plan
|
||||
if (isHosted && !(await customerHasPaidPlan(config.organizationId))) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip configs with no keywords before advancing the schedule
|
||||
const kwCount = await RankTrackingRepository.getKeywordCountForConfig(
|
||||
config.id,
|
||||
);
|
||||
if (kwCount === 0) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
|
||||
);
|
||||
// Still advance schedule so this config doesn't stay due forever
|
||||
const skipInterval = isScheduledRankTrackingInterval(
|
||||
config.scheduleInterval,
|
||||
)
|
||||
? config.scheduleInterval
|
||||
: null;
|
||||
if (skipInterval) {
|
||||
await RankTrackingRepository.updateConfig(
|
||||
config.id,
|
||||
config.projectId,
|
||||
{
|
||||
nextCheckAt: computeNextCheckAt(
|
||||
skipInterval,
|
||||
config.nextCheckAt,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Advance nextCheckAt immediately to prevent retry storms if the run fails
|
||||
const interval = isScheduledRankTrackingInterval(
|
||||
config.scheduleInterval,
|
||||
)
|
||||
? config.scheduleInterval
|
||||
: null;
|
||||
if (interval) {
|
||||
await RankTrackingRepository.updateConfig(
|
||||
config.id,
|
||||
config.projectId,
|
||||
{
|
||||
nextCheckAt: computeNextCheckAt(interval, config.nextCheckAt),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = await beginRankCheckRun({
|
||||
workflow: env.RANK_CHECK_WORKFLOW,
|
||||
config,
|
||||
projectId: config.projectId,
|
||||
billingCustomer: {
|
||||
userId: "system",
|
||||
userEmail: "system@openseo.so",
|
||||
organizationId: config.organizationId,
|
||||
projectId: config.projectId,
|
||||
},
|
||||
keywordsTotal: kwCount,
|
||||
trigger: "scheduled",
|
||||
workflowStartErrorMessage: "Failed to start scheduled workflow",
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — run already active`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[cron] Started scheduled rank check ${result.runId} for config ${config.id} (${config.domain})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[cron] Error processing config ${config.id} (${config.domain}):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Scope a per-request Postgres client for the cron run (no-op in D1 mode).
|
||||
await withPgClient(() => runScheduledRankChecks(env));
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { member, user as authUser } from "@/db/better-auth-schema";
|
||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||
import { slugify, toHex } from "./org-slug";
|
||||
|
||||
type HostedUser = {
|
||||
@ -31,26 +29,8 @@ function getDefaultHostedOrganizationSlug(user: HostedUser) {
|
||||
return `${slugify(slugSource)}-${suffix}`;
|
||||
}
|
||||
|
||||
async function findFirstOrganizationIdForUser(userId: string) {
|
||||
const [existingMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.orderBy(asc(member.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return existingMembership?.organizationId ?? null;
|
||||
}
|
||||
|
||||
async function getHostedUser(userId: string) {
|
||||
const hostedUser = await db.query.user.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
where: eq(authUser.id, userId),
|
||||
});
|
||||
const hostedUser = await AuthRepository.getHostedUser(userId);
|
||||
|
||||
if (!hostedUser?.email) {
|
||||
throw new Error("Failed to resolve hosted user for session setup");
|
||||
@ -72,7 +52,9 @@ async function createDefaultHostedOrganization(
|
||||
|
||||
return createdOrganization.id;
|
||||
} catch (error) {
|
||||
const organizationId = await findFirstOrganizationIdForUser(user.id);
|
||||
const organizationId = await AuthRepository.findFirstOrganizationIdForUser(
|
||||
user.id,
|
||||
);
|
||||
|
||||
if (organizationId) {
|
||||
return organizationId;
|
||||
@ -86,7 +68,8 @@ export async function getOrCreateDefaultHostedOrganization(
|
||||
userId: string,
|
||||
createOrganization: HostedOrganizationCreator,
|
||||
) {
|
||||
const existingOrganizationId = await findFirstOrganizationIdForUser(userId);
|
||||
const existingOrganizationId =
|
||||
await AuthRepository.findFirstOrganizationIdForUser(userId);
|
||||
|
||||
if (existingOrganizationId) {
|
||||
return existingOrganizationId;
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { db } from "@/db";
|
||||
import { organization } from "@/db/better-auth-schema";
|
||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||
import { slugify, toHex } from "./org-slug";
|
||||
|
||||
function getDelegatedOrganizationId(userId: string) {
|
||||
@ -23,22 +22,10 @@ export async function ensureDelegatedOrganizationForUser(
|
||||
const name = getDelegatedOrganizationName(email, userId);
|
||||
const slug = getDelegatedOrganizationSlug(email, userId);
|
||||
|
||||
await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
await AuthRepository.upsertDelegatedOrganization({
|
||||
id: organizationId,
|
||||
name,
|
||||
slug,
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
metadata: null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: organization.id,
|
||||
set: {
|
||||
name,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return organizationId;
|
||||
|
||||
57
src/server/auth/repositories/AuthRepository.ts
Normal file
57
src/server/auth/repositories/AuthRepository.ts
Normal 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;
|
||||
@ -1,31 +1,17 @@
|
||||
/**
|
||||
* Data access layer for site audit tables.
|
||||
* All D1 interactions for audits, audit_pages, and stored Lighthouse results.
|
||||
* Provider-aware (D1 or Postgres) via the `@/db` handle.
|
||||
*/
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { audits, auditLighthouseResults, auditPages } from "@/db/schema";
|
||||
import { executeInBatches } from "@/db/runBatch";
|
||||
import type {
|
||||
AuditConfig,
|
||||
LighthouseResult,
|
||||
StepPageResult,
|
||||
} from "@/server/lib/audit/types";
|
||||
|
||||
const DB_BATCH_SIZE = 100;
|
||||
type BatchStatement = Parameters<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: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@ -130,8 +116,8 @@ async function batchWriteResults(
|
||||
pages: StepPageResult[],
|
||||
lighthouseResults: LighthouseResult[],
|
||||
) {
|
||||
await executeInBatches(pages, (page) =>
|
||||
db.insert(auditPages).values({
|
||||
await executeInBatches(pages, (tx, page) =>
|
||||
tx.insert(auditPages).values({
|
||||
id: page.id,
|
||||
auditId,
|
||||
url: page.url,
|
||||
@ -168,8 +154,8 @@ async function batchWriteResults(
|
||||
return;
|
||||
}
|
||||
|
||||
await executeInBatches(lighthouseResults, (result) =>
|
||||
db.insert(auditLighthouseResults).values({
|
||||
await executeInBatches(lighthouseResults, (tx, result) =>
|
||||
tx.insert(auditLighthouseResults).values({
|
||||
id: crypto.randomUUID(),
|
||||
auditId,
|
||||
pageId: result.pageId,
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { runBatch } from "@/db/runBatch";
|
||||
import {
|
||||
keywordMetrics,
|
||||
savedKeywordTagAssignments,
|
||||
@ -121,8 +122,9 @@ async function saveKeywordsToProject(params: {
|
||||
}): Promise<SavedKeywordRecord[]> {
|
||||
if (params.keywords.length === 0) return [];
|
||||
|
||||
const [first, ...rest] = params.keywords.map((keyword) =>
|
||||
db
|
||||
await runBatch((tx) =>
|
||||
params.keywords.map((keyword) =>
|
||||
tx
|
||||
.insert(savedKeywords)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
@ -132,10 +134,9 @@ async function saveKeywordsToProject(params: {
|
||||
languageCode: params.languageCode,
|
||||
})
|
||||
.onConflictDoNothing(),
|
||||
),
|
||||
);
|
||||
|
||||
await db.batch([first, ...rest]);
|
||||
|
||||
return listSavedKeywordRowsByKeywords(params);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { runBatch } from "@/db/runBatch";
|
||||
import {
|
||||
savedKeywordTagAssignments,
|
||||
savedKeywordTags,
|
||||
@ -257,8 +258,9 @@ async function upsertSavedKeywordTags(
|
||||
const normalizedTags = normalizeSavedKeywordTags(tagNames);
|
||||
if (normalizedTags.length === 0) return [];
|
||||
|
||||
const [first, ...rest] = normalizedTags.map((tag) =>
|
||||
db
|
||||
await runBatch((tx) =>
|
||||
normalizedTags.map((tag) =>
|
||||
tx
|
||||
.insert(savedKeywordTags)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
@ -267,8 +269,8 @@ async function upsertSavedKeywordTags(
|
||||
normalizedName: tag.normalizedName,
|
||||
})
|
||||
.onConflictDoNothing(),
|
||||
),
|
||||
);
|
||||
await db.batch([first, ...rest]);
|
||||
|
||||
return db
|
||||
.select()
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
rankTrackingKeywords,
|
||||
projects,
|
||||
} from "@/db/schema";
|
||||
import { executeInBatches } from "@/db/runBatch";
|
||||
import {
|
||||
getLatestSnapshotsForKeywords,
|
||||
getSnapshotsBeforeDate,
|
||||
@ -17,21 +18,6 @@ import {
|
||||
getPositionMatrix,
|
||||
} from "./snapshotQueries";
|
||||
|
||||
const DB_BATCH_SIZE = 100;
|
||||
type BatchStatement = Parameters<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -216,8 +202,8 @@ async function insertSnapshots(
|
||||
Omit<InferInsertModel<typeof rankSnapshots>, "id" | "checkedAt">
|
||||
>,
|
||||
) {
|
||||
await executeInBatches(snapshots, (snapshot) =>
|
||||
db.insert(rankSnapshots).values(snapshot).onConflictDoNothing(),
|
||||
await executeInBatches(snapshots, (tx, snapshot) =>
|
||||
tx.insert(rankSnapshots).values(snapshot).onConflictDoNothing(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -240,8 +226,8 @@ async function getKeywordsForConfig(configId: string) {
|
||||
async function addKeywordsToConfig(
|
||||
keywords: Array<{ id: string; configId: string; keyword: string }>,
|
||||
) {
|
||||
await executeInBatches(keywords, (kw) =>
|
||||
db.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
|
||||
await executeInBatches(keywords, (tx, kw) =>
|
||||
tx.insert(rankTrackingKeywords).values(kw).onConflictDoNothing(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -340,8 +326,8 @@ async function updateKeywordMetrics(
|
||||
metricsFetchedAt: string;
|
||||
}>,
|
||||
) {
|
||||
await executeInBatches(updates, (u) =>
|
||||
db
|
||||
await executeInBatches(updates, (tx, u) =>
|
||||
tx
|
||||
.update(rankTrackingKeywords)
|
||||
.set({
|
||||
searchVolume: u.searchVolume,
|
||||
|
||||
@ -229,6 +229,7 @@ export async function getEarliestSnapshotsForKeywords(
|
||||
|
||||
// D1 caps bound parameters at 100 per statement. The query binds N keyword
|
||||
// IDs plus 4 params from the completedRunIds subquery (referenced twice).
|
||||
// (Postgres allows far more, but the chunking is harmless there.)
|
||||
const CHUNK_SIZE = 90;
|
||||
const allResults: Awaited<ReturnType<typeof getSnapshotsForConfig>> = [];
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import {
|
||||
type WorkflowStep,
|
||||
} from "cloudflare:workers";
|
||||
import { NonRetryableError } from "cloudflare:workflows";
|
||||
import { withPgClient } from "@/db";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { failRunIfActive } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||
@ -249,6 +250,16 @@ export class RankCheckWorkflow extends WorkflowEntrypoint<
|
||||
RankCheckParams
|
||||
> {
|
||||
async run(event: WorkflowEvent<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 {
|
||||
runId,
|
||||
configId,
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
type WorkflowEvent,
|
||||
type WorkflowStep,
|
||||
} from "cloudflare:workers";
|
||||
import { withPgClient } from "@/db";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import type { AuditConfig } from "@/server/lib/audit/types";
|
||||
@ -25,6 +26,16 @@ interface AuditParams {
|
||||
|
||||
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||
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 } =
|
||||
event.payload;
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { rankTrackingConfigs } from "@/db/app.schema";
|
||||
import { rankTrackingConfigs } from "@/db/schema";
|
||||
import { domainField } from "@/types/schemas/domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -64,6 +64,14 @@
|
||||
"migrations_dir": "drizzle",
|
||||
},
|
||||
],
|
||||
// Postgres scale path (opt-in). D1 remains the default; leave this commented
|
||||
// for free self-hosting. To run on Postgres set DATABASE_PROVIDER=postgres and
|
||||
// uncomment a Hyperdrive binding pointing at your Postgres pooler. Without
|
||||
// Hyperdrive, POSTGRES_DATABASE_URL is used as a direct-connection fallback
|
||||
// (no edge pooling/caching — not recommended for the scaled hosted path).
|
||||
// "hyperdrive": [
|
||||
// { "binding": "HYPERDRIVE", "id": "<your-hyperdrive-config-id>" },
|
||||
// ],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"bucket_name": "open-seo",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user