diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..37704252 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,60 @@ +# Third-party notices + +This repository contains, and is derived from, third-party open-source software. +The original licence and copyright are preserved in full. + +## OpenSEO + +- **Project**: OpenSEO — "Open source alternative to Semrush and Ahrefs" +- **Upstream repository**: https://github.com/every-app/open-seo +- **Homepage**: https://openseo.so +- **Licence**: MIT +- **Copyright**: Copyright (c) 2026 Ben Senescu +- **Integrated release**: `v0.1.3` +- **Integrated commit**: `9d19e439905a9a954ccdefe22d9270d7c389695d` +- **Integration date**: 2026-08-05 + +The complete MIT licence text is retained unmodified in [`LICENSE`](./LICENSE) +at the root of this repository, and applies to all substantial portions of +OpenSEO reused here — which is to say, essentially the whole of this tree apart +from the Morgana modifications listed below. + +### Modifications by CheckSig + +This fork is operated internally as the **Morgana Search Intelligence Engine**. +Modifications are limited to the following, and are documented in detail in +[`UPSTREAM.md`](./UPSTREAM.md): + +1. An isolated staging deployment configuration (`wrangler.staging.jsonc`) and a + one-line build hook allowing it to be selected (`vite.config.ts`). +2. Three additive operational endpoints — `/healthz`, `/readyz`, + `/internal/status` — under `src/server/morgana/`, plus a small dispatch hook + in `src/server.ts`. +3. A DataForSEO credential alias in `src/server/lib/runtime-env.ts`, so this + deployment uses its own dedicated credential rather than a shared one. +4. Zero-spend guards, structured logging with redaction, a separate cost centre, + an MCP kill-switch, a scheduled-run refusal, and a resource isolation guard. +5. Provenance and attribution documentation (this file, `UPSTREAM.md`, + `scripts/check-upstream.mjs`). + +No upstream file was renamed, restructured, rebranded or deleted. No upstream +attribution was removed. + +### Trademarks + +"OpenSEO" and any associated logos or marks remain the property of their +respective owners. CheckSig uses the name here solely to identify the upstream +project from which this software is derived, as required by the attribution +terms of the MIT licence. Nothing in this repository is intended to suggest +sponsorship or endorsement by the OpenSEO project or its authors. + +## Dependencies + +This project's runtime and build dependencies carry their own licences, +recorded in `pnpm-lock.yaml` and installed under `node_modules/`. The dependency +tree is installed reproducibly with `pnpm install --frozen-lockfile`. + +Note that upstream's `pnpm-workspace.yaml` pins a number of `overrides` to raise +transitive dependencies above known security advisories, and sets a +`minimumReleaseAge` delay as a supply-chain control. Both are retained +unmodified. diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 00000000..b674a836 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,137 @@ +# Upstream pin — OpenSEO + +This repository is a fork of an upstream open-source project, operated by CheckSig +as the **Morgana Search Intelligence Engine**. It is not a rewrite and not a +vendored copy: upstream history is intact and upstream remains the source of +truth for everything except the small, enumerated patch series below. + +## Pin + +| Field | Value | +| --------------------- | --------------------------------------------------------- | +| `upstream_repository` | https://github.com/every-app/open-seo | +| `upstream_release` | `v0.1.3` | +| `upstream_commit` | `9d19e439905a9a954ccdefe22d9270d7c389695d` | +| `integration_date` | 2026-08-05 | +| `origin` | https://github.com/checksig-custody/open-seo | +| `working_branch` | `morgana/phase-0` | +| `licence` | MIT (unchanged — see `LICENSE`, `THIRD_PARTY_NOTICES.md`) | + +### Why this commit + +`v0.1.3` was the latest release at integration time (published 2026-07-30) and +`git compare v0.1.3...main` reported **identical** — zero commits ahead, zero +behind. Pinning the release therefore costs nothing relative to `main` while +giving a stable, human-announced reference point. All releases are `0.x`; there +is no `1.0` line, and the cadence is roughly weekly. + +The pin is a **commit SHA**, not a tag: a tag can be moved, a SHA cannot. + +## Remotes + +``` +origin https://github.com/checksig-custody/open-seo (fetch + push) +upstream https://github.com/every-app/open-seo (fetch) +upstream DISABLED_no_push_to_upstream (push) +``` + +The upstream push URL is deliberately set to an invalid value. Nothing in this +repository should ever be pushed to the public upstream project, and a typo in a +remote name should fail loudly rather than open a pull request by accident. + +`origin/main` is kept as a clean mirror of upstream for diffing. Morgana work +happens on `morgana/phase-0` and later branches. + +## Verification + +Confirm at any time that the tree still descends from the pinned commit and that +the patch series is the only divergence: + +```bash +git merge-base --is-ancestor 9d19e439905a9a954ccdefe22d9270d7c389695d HEAD && echo "pin intact" +git diff --stat 9d19e439905a9a954ccdefe22d9270d7c389695d..HEAD +node scripts/check-upstream.mjs # read-only: reports distance from upstream +node scripts/assert-isolation.mjs wrangler.staging.jsonc +``` + +## Local patch series + +The patch series is kept deliberately small, isolated and reviewable. No +upstream file is renamed, no directory is restructured, no component is +rebranded, and no upstream code is deleted. Capabilities are disabled through +configuration and guards, never by removing the code that implements them — +deletion would guarantee a conflict on every future upstream merge. + +| # | Files | Purpose | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **P1** | `wrangler.staging.jsonc` (new), `vite.config.ts` (1 line) | Isolated staging deploy config. The `vite.config.ts` change adds an env-driven `configPath` to the Cloudflare plugin; unset everywhere else, so upstream behaviour is byte-identical. | +| **P2** | `src/server/morgana/phase0-routes.ts` (new), `src/server.ts` (+8 lines) | `/healthz`, `/readyz`, `/internal/status`. Handled before upstream dispatch so a probe performs no other work. Returns `null` for every other path. | +| **P3** | `src/server/lib/runtime-env.ts` (+alias map) | Reads `DATAFORSEO_SEARCH_INTELLIGENCE_API_KEY` in preference to `DATAFORSEO_API_KEY`, so this engine can never use Morgana Brand Monitoring's DataForSEO credential or balance. Falls back to the upstream name, so unpatched behaviour is preserved. | +| **P4** | `src/server/morgana/phase0-{env,guard,cost,logging}.ts` (new), `src/server/morgana/phase0.test.ts` (new), `src/server.ts` (MCP + scheduled guards), `scripts/assert-isolation.mjs` (new) | Zero-spend enforcement, structured logging with redaction, cost centre, MCP kill-switch, scheduled-run refusal, resource isolation guard. | +| **P5** | `UPSTREAM.md`, `THIRD_PARTY_NOTICES.md`, `scripts/check-upstream.mjs` (new) | Provenance, attribution, upstream tracking. | + +Everything Morgana-specific lives under `src/server/morgana/` or in a file that +did not exist upstream, except for four small edits to three upstream files +(`src/server.ts`, `src/server/lib/runtime-env.ts`, `vite.config.ts`), each +marked in place with a `MORGANA LOCAL PATCH` comment referencing this document. + +### Deliberate non-changes + +- **Alchemy is not used or removed.** Upstream deploys via `alchemy.run.ts` + (pinned prerelease `2.0.0-beta.61`, own state store, `--adopt` used elsewhere + in the repo, auto-provisions Cloudflare Access). On an account that also holds + Morgana production, an IaC tool that can adopt existing resources is the wrong + risk. We deploy with a hand-authored wrangler config instead and leave the + Alchemy files untouched so upstream merges stay clean. +- **Upstream CI is untouched.** The GitHub token available to this integration + lacks the `workflow` scope, so `.github/workflows/**` cannot be modified. See + the Morgana repo's `docs/search-intelligence/OPERATIONS.md`. +- **Branding is unchanged.** The only Morgana identification is the staging + marker string in the status payload. + +## Update procedure + +Upstream is **never** merged automatically, and the pin is never advanced by a +script. The procedure is: + +```bash +node scripts/check-upstream.mjs # 1. how far behind are we? +git fetch upstream --tags # 2. fetch, do not merge +git checkout -b morgana/upstream- # 3. dedicated branch +git merge # 4. controlled merge, resolve by hand +pnpm install --frozen-lockfile # 5. reproducible install +pnpm run ci:check && pnpm run test:ci # 6. full gate +# 7. security review: re-run the checklist in the Morgana repo's SECURITY.md, +# paying attention to new external calls, new env vars, new telemetry, +# changes to auth, and anything that could spend money. +node scripts/assert-isolation.mjs wrangler.staging.jsonc +# 8. deploy to staging and verify /healthz, /readyz, /internal/status +# 9. record the new pin in this file, then release. +``` + +Step 7 is not optional. Upstream is a fast-moving commercial product with +billing, telemetry and AI paths that Phase 0 deliberately disables; a routine +version bump can re-enable any of them. + +### What to re-check on every upgrade + +- Does `wrangler.staging.jsonc` still declare every Durable Object and Workflow + class the Worker exports? A new class breaks the upload. +- Did upstream add a cron trigger, a new external host, or new telemetry? +- Did `AUTH_MODE` handling change? +- Did the DataForSEO credential resolution move away from `getEnvValueSync`? + Patch P3 depends on that single chokepoint. +- Do the migrations still apply cleanly to a **fresh** D1? + +## Rollback + +Rolling back the engine never deletes data. See +`docs/search-intelligence/OPERATIONS.md` in the Morgana repository for the full +procedure; in short: redeploy the previous Worker version, leave D1 and R2 +intact, and leave Morgana production untouched. + +## Known conflicts + +None at `v0.1.3`. The four edited upstream files are the expected conflict +surface on a future merge; each edit is small and marked with a +`MORGANA LOCAL PATCH` comment so a conflict is easy to resolve deliberately. diff --git a/drizzle-pg/0014_search_intelligence.sql b/drizzle-pg/0014_search_intelligence.sql new file mode 100644 index 00000000..a4f16019 --- /dev/null +++ b/drizzle-pg/0014_search_intelligence.sql @@ -0,0 +1,132 @@ +CREATE TABLE "domain_refresh_jobs" ( + "id" text PRIMARY KEY NOT NULL, + "entity_id" text NOT NULL, + "status" text NOT NULL, + "trigger" text NOT NULL, + "requested_by" text, + "dedupe_key" text NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "skip_reason" text, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "snapshot_id" text, + "created_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL, + "started_at" text, + "finished_at" text +); +--> statement-breakpoint +CREATE TABLE "domain_snapshot_keywords" ( + "id" text PRIMARY KEY NOT NULL, + "snapshot_id" text NOT NULL, + "keyword" text NOT NULL, + "rank_group" integer, + "rank_absolute" integer, + "search_volume" integer, + "estimated_traffic" real, + "cpc" real, + "keyword_difficulty" integer, + "search_intent" text, + "ranking_url" text, + "serp_updated_at" text, + "position" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "domain_snapshot_pages" ( + "id" text PRIMARY KEY NOT NULL, + "snapshot_id" text NOT NULL, + "url" text NOT NULL, + "normalized_url" text NOT NULL, + "estimated_traffic" real, + "keyword_count" integer, + "top_keyword" text, + "top_keyword_position" integer, + "page_title" text, + "last_seen_at" text, + "position" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "domain_snapshots" ( + "id" text PRIMARY KEY NOT NULL, + "entity_id" text NOT NULL, + "organic_traffic_estimate" real, + "organic_keyword_count" integer, + "backlink_count" integer, + "referring_domain_count" integer, + "rank_signal" integer, + "location_code" integer NOT NULL, + "language_code" text NOT NULL, + "source" text NOT NULL, + "provider_request_id" text, + "fetched_at" text NOT NULL, + "snapshot_date" text NOT NULL, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "dedupe_key" 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 "search_budget_state" ( + "month" text PRIMARY KEY NOT NULL, + "monthly_cost_micros" integer DEFAULT 0 NOT NULL, + "current_day" text, + "daily_cost_micros" integer DEFAULT 0 NOT NULL, + "consecutive_failures" integer DEFAULT 0 NOT NULL, + "circuit_opened_at" text, + "last_alert_threshold" integer, + "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 "search_entities" ( + "id" text PRIMARY KEY NOT NULL, + "display_name" text NOT NULL, + "canonical_domain" text NOT NULL, + "normalized_domain" text NOT NULL, + "entity_type" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "priority" text DEFAULT 'normal' NOT NULL, + "include_subdomains" boolean DEFAULT false NOT NULL, + "location_code" integer DEFAULT 2380 NOT NULL, + "language_code" text DEFAULT 'it' NOT NULL, + "refresh_interval_hours" integer DEFAULT 24 NOT NULL, + "backlink_interval_hours" integer DEFAULT 168 NOT NULL, + "last_refreshed_at" text, + "last_backlink_refreshed_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, + "disabled_at" text +); +--> statement-breakpoint +CREATE TABLE "search_usage_ledger" ( + "id" text PRIMARY KEY NOT NULL, + "day" text NOT NULL, + "entity_id" text, + "endpoint_path" text NOT NULL, + "metering_class" text NOT NULL, + "requests" integer DEFAULT 0 NOT NULL, + "metered_requests" integer DEFAULT 0 NOT NULL, + "failed_requests" integer DEFAULT 0 NOT NULL, + "retry_requests" integer DEFAULT 0 NOT NULL, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "cache_hits" integer DEFAULT 0 NOT NULL, + "cache_misses" integer DEFAULT 0 NOT NULL, + "blocked_by_budget" integer DEFAULT 0 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 "domain_refresh_jobs" ADD CONSTRAINT "domain_refresh_jobs_entity_id_search_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."search_entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "domain_snapshot_keywords" ADD CONSTRAINT "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."domain_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "domain_snapshot_pages" ADD CONSTRAINT "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."domain_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "domain_snapshots" ADD CONSTRAINT "domain_snapshots_entity_id_search_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."search_entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "domain_refresh_jobs_dedupe_idx" ON "domain_refresh_jobs" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "domain_refresh_jobs_status_idx" ON "domain_refresh_jobs" USING btree ("status","created_at");--> statement-breakpoint +CREATE INDEX "domain_refresh_jobs_entity_idx" ON "domain_refresh_jobs" USING btree ("entity_id","created_at");--> statement-breakpoint +CREATE INDEX "domain_snapshot_keywords_snapshot_idx" ON "domain_snapshot_keywords" USING btree ("snapshot_id","position");--> statement-breakpoint +CREATE INDEX "domain_snapshot_pages_snapshot_idx" ON "domain_snapshot_pages" USING btree ("snapshot_id","position");--> statement-breakpoint +CREATE UNIQUE INDEX "domain_snapshots_dedupe_idx" ON "domain_snapshots" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "domain_snapshots_entity_date_idx" ON "domain_snapshots" USING btree ("entity_id","snapshot_date");--> statement-breakpoint +CREATE UNIQUE INDEX "search_entities_domain_market_idx" ON "search_entities" USING btree ("normalized_domain","location_code","language_code");--> statement-breakpoint +CREATE INDEX "search_entities_enabled_idx" ON "search_entities" USING btree ("enabled","entity_type");--> statement-breakpoint +CREATE UNIQUE INDEX "search_usage_ledger_day_endpoint_idx" ON "search_usage_ledger" USING btree ("day","endpoint_path","metering_class");--> statement-breakpoint +CREATE INDEX "search_usage_ledger_day_idx" ON "search_usage_ledger" USING btree ("day"); \ No newline at end of file diff --git a/drizzle-pg/0015_search_intelligence_phase2.sql b/drizzle-pg/0015_search_intelligence_phase2.sql new file mode 100644 index 00000000..e4e0338a --- /dev/null +++ b/drizzle-pg/0015_search_intelligence_phase2.sql @@ -0,0 +1,149 @@ +CREATE TABLE "keyword_clusters" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "description" text, + "weight" real DEFAULT 1 NOT NULL, + "enabled" boolean DEFAULT true 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 +CREATE TABLE "keyword_gap_snapshots" ( + "id" text PRIMARY KEY NOT NULL, + "tracked_keyword_id" text NOT NULL, + "snapshot_date" text NOT NULL, + "category" text NOT NULL, + "primary_rank" integer, + "best_competitor_rank" integer, + "best_competitor_entity_id" text, + "opportunity_score" real, + "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 "phase2_usage_ledger" ( + "id" text PRIMARY KEY NOT NULL, + "day" text NOT NULL, + "job_type" text NOT NULL, + "http_requests" integer DEFAULT 0 NOT NULL, + "metered_requests" integer DEFAULT 0 NOT NULL, + "paid_tasks" integer DEFAULT 0 NOT NULL, + "keywords_checked" integer DEFAULT 0 NOT NULL, + "cache_hits" integer DEFAULT 0 NOT NULL, + "cache_misses" integer DEFAULT 0 NOT NULL, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "blocked_by_budget" integer DEFAULT 0 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 "ranking_events" ( + "id" text PRIMARY KEY NOT NULL, + "tracked_keyword_id" text NOT NULL, + "entity_id" text NOT NULL, + "event_type" text NOT NULL, + "previous_rank" integer, + "current_rank" integer, + "competitor_entity_id" text, + "ranking_url" text, + "detected_at" text NOT NULL, + "dedupe_key" text NOT NULL, + "notified_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 "ranking_jobs" ( + "id" text PRIMARY KEY NOT NULL, + "job_type" text NOT NULL, + "tracked_keyword_id" text, + "priority" text DEFAULT 'normal' NOT NULL, + "status" text NOT NULL, + "scheduled_at" text NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "skip_reason" text, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "dedupe_key" 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, + "started_at" text, + "finished_at" text +); +--> statement-breakpoint +CREATE TABLE "share_of_search_snapshots" ( + "id" text PRIMARY KEY NOT NULL, + "entity_id" text NOT NULL, + "cluster_id" text, + "snapshot_date" text NOT NULL, + "visibility_score" real, + "share" real, + "status" text NOT NULL, + "reason" text, + "keywords_considered" integer DEFAULT 0 NOT NULL, + "keywords_covered" integer DEFAULT 0 NOT NULL, + "ctr_model_version" 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 "si_rank_snapshots" ( + "id" text PRIMARY KEY NOT NULL, + "tracked_keyword_id" text NOT NULL, + "entity_id" text NOT NULL, + "snapshot_at" text NOT NULL, + "snapshot_date" text NOT NULL, + "location_code" integer NOT NULL, + "language_code" text NOT NULL, + "device" text NOT NULL, + "rank_group" integer, + "rank_absolute" integer, + "ranking_url" text, + "normalized_ranking_url" text, + "is_found" boolean NOT NULL, + "provider" text NOT NULL, + "estimated_cost_micros" integer DEFAULT 0 NOT NULL, + "actual_cost_micros" integer DEFAULT 0 NOT NULL, + "dedupe_key" 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 "tracked_keywords" ( + "id" text PRIMARY KEY NOT NULL, + "keyword" text NOT NULL, + "normalized_keyword" text NOT NULL, + "cluster_id" text, + "priority" text DEFAULT 'normal' NOT NULL, + "location_code" integer DEFAULT 2380 NOT NULL, + "language_code" text DEFAULT 'it' NOT NULL, + "device" text DEFAULT 'desktop' NOT NULL, + "tracking_frequency_hours" integer DEFAULT 168 NOT NULL, + "tracking_enabled" boolean DEFAULT true NOT NULL, + "alerting_enabled" boolean DEFAULT true NOT NULL, + "search_volume" integer, + "last_checked_at" text, + "next_check_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, + "disabled_at" text +); +--> statement-breakpoint +ALTER TABLE "keyword_gap_snapshots" ADD CONSTRAINT "keyword_gap_snapshots_tracked_keyword_id_tracked_keywords_id_fk" FOREIGN KEY ("tracked_keyword_id") REFERENCES "public"."tracked_keywords"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "share_of_search_snapshots" ADD CONSTRAINT "share_of_search_snapshots_entity_id_search_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."search_entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "si_rank_snapshots" ADD CONSTRAINT "si_rank_snapshots_tracked_keyword_id_tracked_keywords_id_fk" FOREIGN KEY ("tracked_keyword_id") REFERENCES "public"."tracked_keywords"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "si_rank_snapshots" ADD CONSTRAINT "si_rank_snapshots_entity_id_search_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."search_entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracked_keywords" ADD CONSTRAINT "tracked_keywords_cluster_id_keyword_clusters_id_fk" FOREIGN KEY ("cluster_id") REFERENCES "public"."keyword_clusters"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "keyword_clusters_slug_idx" ON "keyword_clusters" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX "keyword_gap_snapshots_dedupe_idx" ON "keyword_gap_snapshots" USING btree ("tracked_keyword_id","snapshot_date");--> statement-breakpoint +CREATE INDEX "keyword_gap_snapshots_date_idx" ON "keyword_gap_snapshots" USING btree ("snapshot_date","category");--> statement-breakpoint +CREATE UNIQUE INDEX "phase2_usage_ledger_day_job_idx" ON "phase2_usage_ledger" USING btree ("day","job_type");--> statement-breakpoint +CREATE UNIQUE INDEX "ranking_events_dedupe_idx" ON "ranking_events" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "ranking_events_detected_idx" ON "ranking_events" USING btree ("detected_at");--> statement-breakpoint +CREATE UNIQUE INDEX "ranking_jobs_dedupe_idx" ON "ranking_jobs" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "ranking_jobs_queue_idx" ON "ranking_jobs" USING btree ("status","priority","scheduled_at");--> statement-breakpoint +CREATE UNIQUE INDEX "share_of_search_dedupe_idx" ON "share_of_search_snapshots" USING btree ("entity_id","cluster_id","snapshot_date");--> statement-breakpoint +CREATE INDEX "share_of_search_date_idx" ON "share_of_search_snapshots" USING btree ("snapshot_date");--> statement-breakpoint +CREATE UNIQUE INDEX "si_rank_snapshots_dedupe_idx" ON "si_rank_snapshots" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "si_rank_snapshots_keyword_date_idx" ON "si_rank_snapshots" USING btree ("tracked_keyword_id","snapshot_date");--> statement-breakpoint +CREATE INDEX "si_rank_snapshots_entity_date_idx" ON "si_rank_snapshots" USING btree ("entity_id","snapshot_date");--> statement-breakpoint +CREATE UNIQUE INDEX "tracked_keywords_keyword_market_idx" ON "tracked_keywords" USING btree ("normalized_keyword","location_code","language_code","device");--> statement-breakpoint +CREATE INDEX "tracked_keywords_due_idx" ON "tracked_keywords" USING btree ("tracking_enabled","next_check_at");--> statement-breakpoint +CREATE INDEX "tracked_keywords_cluster_idx" ON "tracked_keywords" USING btree ("cluster_id"); \ No newline at end of file diff --git a/drizzle-pg/meta/0014_snapshot.json b/drizzle-pg/meta/0014_snapshot.json new file mode 100644 index 00000000..6ebb62fc --- /dev/null +++ b/drizzle-pg/meta/0014_snapshot.json @@ -0,0 +1,4698 @@ +{ + "id": "92210f48-4f0d-4921-bff9-baae25a73229", + "prevId": "d61539b1-c4dd-4438-8a8b-7840b9122a72", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.backlink_snapshots": { + "name": "backlink_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "backlinks": { + "name": "backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "referring_domains": { + "name": "referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "broken_backlinks": { + "name": "broken_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "new_backlinks": { + "name": "new_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lost_backlinks": { + "name": "lost_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "new_referring_domains": { + "name": "new_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lost_referring_domains": { + "name": "lost_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "backlink_snapshots_project_captured_idx": { + "name": "backlink_snapshots_project_captured_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backlink_snapshots_project_id_projects_id_fk": { + "name": "backlink_snapshots_project_id_projects_id_fk", + "tableFrom": "backlink_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_metrics": { + "name": "keyword_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_activation_state": { + "name": "organization_activation_state", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_mcp_authorized_at": { + "name": "first_mcp_authorized_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_mcp_tool_call_at": { + "name": "first_mcp_tool_call_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_activation_state_organization_id_organization_id_fk": { + "name": "organization_activation_state_organization_id_organization_id_fk", + "tableFrom": "organization_activation_state", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_activation_state": { + "name": "project_activation_state", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_step_clicked_at": { + "name": "competitor_step_clicked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_card_dismissed_at": { + "name": "mcp_card_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "project_activation_state_project_id_projects_id_fk": { + "name": "project_activation_state_project_id_projects_id_fk", + "tableFrom": "project_activation_state", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_check_runs": { + "name": "rank_check_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_snapshots": { + "name": "rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_configs": { + "name": "rank_tracking_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'weekly'" + }, + "location_name": { + "name": "location_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_configs_project_active_created_idx": { + "name": "rank_tracking_configs_project_active_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_tracking_configs_national_idx": { + "name": "rank_tracking_configs_national_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_tracking_configs_local_idx": { + "name": "rank_tracking_configs_local_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "schema": "", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tags": { + "name": "saved_keyword_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keywords": { + "name": "saved_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding_answers": { + "name": "user_onboarding_answers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_issues": { + "name": "audit_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "details_json": { + "name": "details_json", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_issues_audit_type_idx": { + "name": "audit_issues_audit_type_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_issues_page_id_idx": { + "name": "audit_issues_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_issues_audit_id_audits_id_fk": { + "name": "audit_issues_audit_id_audits_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_issues_page_id_audit_pages_id_fk": { + "name": "audit_issues_page_id_audit_pages_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_lighthouse_results_page_id_idx": { + "name": "audit_lighthouse_results_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_links": { + "name": "audit_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_page_id": { + "name": "source_page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_internal": { + "name": "is_internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_nofollow": { + "name": "is_nofollow", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "audit_links_audit_target_idx": { + "name": "audit_links_audit_target_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_links_source_page_id_idx": { + "name": "audit_links_source_page_id_idx", + "columns": [ + { + "expression": "source_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_links_audit_id_audits_id_fk": { + "name": "audit_links_audit_id_audits_id_fk", + "tableFrom": "audit_links", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_links_source_page_id_audit_pages_id_fk": { + "name": "audit_links_source_page_id_audit_pages_id_fk", + "tableFrom": "audit_links", + "tableTo": "audit_pages", + "columnsFrom": [ + "source_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_pages": { + "name": "audit_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "x_robots_tag": { + "name": "x_robots_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_canonical_url": { + "name": "header_canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crawl_depth": { + "name": "crawl_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "in_sitemap": { + "name": "in_sitemap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetch_class": { + "name": "fetch_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ok'" + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_pages_audit_url_idx": { + "name": "audit_pages_audit_url_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audits": { + "name": "audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sam_project_memory": { + "name": "sam_project_memory", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "sam_project_memory_project_id_projects_id_fk": { + "name": "sam_project_memory_project_id_projects_id_fk", + "tableFrom": "sam_project_memory", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sam_project_memory_project_id_label_pk": { + "name": "sam_project_memory_project_id_label_pk", + "columns": [ + "project_id", + "label" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sam_sessions": { + "name": "sam_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sam_sessions_project_updated_idx": { + "name": "sam_sessions_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sam_sessions_project_id_projects_id_fk": { + "name": "sam_sessions_project_id_projects_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sam_sessions_user_id_user_id_fk": { + "name": "sam_sessions_user_id_user_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_accountId_providerId_idx": { + "name": "account_accountId_providerId_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expiresAt_idx": { + "name": "verification_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customer_status": { + "name": "billing_customer_status", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_paying": { + "name": "is_paying", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gsc_account_id": { + "name": "gsc_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_attributions": { + "name": "reddit_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telemetry_state": { + "name": "telemetry_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_version": { + "name": "last_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tool_call_count": { + "name": "mcp_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_refresh_jobs": { + "name": "domain_refresh_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "domain_refresh_jobs_dedupe_idx": { + "name": "domain_refresh_jobs_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_refresh_jobs_status_idx": { + "name": "domain_refresh_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_refresh_jobs_entity_idx": { + "name": "domain_refresh_jobs_entity_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_refresh_jobs_entity_id_search_entities_id_fk": { + "name": "domain_refresh_jobs_entity_id_search_entities_id_fk", + "tableFrom": "domain_refresh_jobs", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshot_keywords": { + "name": "domain_snapshot_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_intent": { + "name": "search_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_updated_at": { + "name": "serp_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "domain_snapshot_keywords_snapshot_idx": { + "name": "domain_snapshot_keywords_snapshot_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_keywords", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshot_pages": { + "name": "domain_snapshot_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_count": { + "name": "keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "top_keyword": { + "name": "top_keyword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "top_keyword_position": { + "name": "top_keyword_position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "domain_snapshot_pages_snapshot_idx": { + "name": "domain_snapshot_pages_snapshot_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_pages", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshots": { + "name": "domain_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organic_traffic_estimate": { + "name": "organic_traffic_estimate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "organic_keyword_count": { + "name": "organic_keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "backlink_count": { + "name": "backlink_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "referring_domain_count": { + "name": "referring_domain_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank_signal": { + "name": "rank_signal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "domain_snapshots_dedupe_idx": { + "name": "domain_snapshots_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_snapshots_entity_date_idx": { + "name": "domain_snapshots_entity_date_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshots_entity_id_search_entities_id_fk": { + "name": "domain_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "domain_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_budget_state": { + "name": "search_budget_state", + "schema": "", + "columns": { + "month": { + "name": "month", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_cost_micros": { + "name": "monthly_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_day": { + "name": "current_day", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_cost_micros": { + "name": "daily_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "circuit_opened_at": { + "name": "circuit_opened_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_alert_threshold": { + "name": "last_alert_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_entities": { + "name": "search_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_domain": { + "name": "canonical_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_domain": { + "name": "normalized_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "include_subdomains": { + "name": "include_subdomains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'it'" + }, + "refresh_interval_hours": { + "name": "refresh_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "backlink_interval_hours": { + "name": "backlink_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 168 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_backlink_refreshed_at": { + "name": "last_backlink_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_entities_domain_market_idx": { + "name": "search_entities_domain_market_idx", + "columns": [ + { + "expression": "normalized_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_entities_enabled_idx": { + "name": "search_entities_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_usage_ledger": { + "name": "search_usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint_path": { + "name": "endpoint_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metering_class": { + "name": "metering_class", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_requests": { + "name": "failed_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_requests": { + "name": "retry_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "search_usage_ledger_day_endpoint_idx": { + "name": "search_usage_ledger_day_endpoint_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metering_class", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_usage_ledger_day_idx": { + "name": "search_usage_ledger_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/0015_snapshot.json b/drizzle-pg/meta/0015_snapshot.json new file mode 100644 index 00000000..32c0a8e9 --- /dev/null +++ b/drizzle-pg/meta/0015_snapshot.json @@ -0,0 +1,5857 @@ +{ + "id": "7ff76c75-a4e4-47b8-885a-31aaaaedbf55", + "prevId": "92210f48-4f0d-4921-bff9-baae25a73229", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.backlink_snapshots": { + "name": "backlink_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "backlinks": { + "name": "backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "referring_domains": { + "name": "referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "broken_backlinks": { + "name": "broken_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "new_backlinks": { + "name": "new_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lost_backlinks": { + "name": "lost_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "new_referring_domains": { + "name": "new_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lost_referring_domains": { + "name": "lost_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "backlink_snapshots_project_captured_idx": { + "name": "backlink_snapshots_project_captured_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backlink_snapshots_project_id_projects_id_fk": { + "name": "backlink_snapshots_project_id_projects_id_fk", + "tableFrom": "backlink_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_metrics": { + "name": "keyword_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_activation_state": { + "name": "organization_activation_state", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_mcp_authorized_at": { + "name": "first_mcp_authorized_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_mcp_tool_call_at": { + "name": "first_mcp_tool_call_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_activation_state_organization_id_organization_id_fk": { + "name": "organization_activation_state_organization_id_organization_id_fk", + "tableFrom": "organization_activation_state", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_activation_state": { + "name": "project_activation_state", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_step_clicked_at": { + "name": "competitor_step_clicked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_card_dismissed_at": { + "name": "mcp_card_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "project_activation_state_project_id_projects_id_fk": { + "name": "project_activation_state_project_id_projects_id_fk", + "tableFrom": "project_activation_state", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_check_runs": { + "name": "rank_check_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_snapshots": { + "name": "rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_configs": { + "name": "rank_tracking_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'weekly'" + }, + "location_name": { + "name": "location_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_configs_project_active_created_idx": { + "name": "rank_tracking_configs_project_active_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_tracking_configs_national_idx": { + "name": "rank_tracking_configs_national_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "rank_tracking_configs_local_idx": { + "name": "rank_tracking_configs_local_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "schema": "", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + { + "expression": "saved_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keyword_tags": { + "name": "saved_keyword_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_keywords": { + "name": "saved_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding_answers": { + "name": "user_onboarding_answers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_issues": { + "name": "audit_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "details_json": { + "name": "details_json", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_issues_audit_type_idx": { + "name": "audit_issues_audit_type_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_issues_page_id_idx": { + "name": "audit_issues_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_issues_audit_id_audits_id_fk": { + "name": "audit_issues_audit_id_audits_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_issues_page_id_audit_pages_id_fk": { + "name": "audit_issues_page_id_audit_pages_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_lighthouse_results_page_id_idx": { + "name": "audit_lighthouse_results_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_links": { + "name": "audit_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_page_id": { + "name": "source_page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_internal": { + "name": "is_internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_nofollow": { + "name": "is_nofollow", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "audit_links_audit_target_idx": { + "name": "audit_links_audit_target_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_links_source_page_id_idx": { + "name": "audit_links_source_page_id_idx", + "columns": [ + { + "expression": "source_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_links_audit_id_audits_id_fk": { + "name": "audit_links_audit_id_audits_id_fk", + "tableFrom": "audit_links", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_links_source_page_id_audit_pages_id_fk": { + "name": "audit_links_source_page_id_audit_pages_id_fk", + "tableFrom": "audit_links", + "tableTo": "audit_pages", + "columnsFrom": [ + "source_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_pages": { + "name": "audit_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "x_robots_tag": { + "name": "x_robots_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_canonical_url": { + "name": "header_canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crawl_depth": { + "name": "crawl_depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "in_sitemap": { + "name": "in_sitemap", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetch_class": { + "name": "fetch_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ok'" + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_pages_audit_url_idx": { + "name": "audit_pages_audit_url_idx", + "columns": [ + { + "expression": "audit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audits": { + "name": "audits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sam_project_memory": { + "name": "sam_project_memory", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "sam_project_memory_project_id_projects_id_fk": { + "name": "sam_project_memory_project_id_projects_id_fk", + "tableFrom": "sam_project_memory", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sam_project_memory_project_id_label_pk": { + "name": "sam_project_memory_project_id_label_pk", + "columns": [ + "project_id", + "label" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sam_sessions": { + "name": "sam_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sam_sessions_project_updated_idx": { + "name": "sam_sessions_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sam_sessions_project_id_projects_id_fk": { + "name": "sam_sessions_project_id_projects_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sam_sessions_user_id_user_id_fk": { + "name": "sam_sessions_user_id_user_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_accountId_providerId_idx": { + "name": "account_accountId_providerId_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expiresAt_idx": { + "name": "verification_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customer_status": { + "name": "billing_customer_status", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_paying": { + "name": "is_paying", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gsc_account_id": { + "name": "gsc_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_attributions": { + "name": "reddit_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telemetry_state": { + "name": "telemetry_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_version": { + "name": "last_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tool_call_count": { + "name": "mcp_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_refresh_jobs": { + "name": "domain_refresh_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "domain_refresh_jobs_dedupe_idx": { + "name": "domain_refresh_jobs_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_refresh_jobs_status_idx": { + "name": "domain_refresh_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_refresh_jobs_entity_idx": { + "name": "domain_refresh_jobs_entity_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_refresh_jobs_entity_id_search_entities_id_fk": { + "name": "domain_refresh_jobs_entity_id_search_entities_id_fk", + "tableFrom": "domain_refresh_jobs", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshot_keywords": { + "name": "domain_snapshot_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "search_intent": { + "name": "search_intent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serp_updated_at": { + "name": "serp_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "domain_snapshot_keywords_snapshot_idx": { + "name": "domain_snapshot_keywords_snapshot_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_keywords", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshot_pages": { + "name": "domain_snapshot_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "keyword_count": { + "name": "keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "top_keyword": { + "name": "top_keyword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "top_keyword_position": { + "name": "top_keyword_position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "domain_snapshot_pages_snapshot_idx": { + "name": "domain_snapshot_pages_snapshot_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_pages", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_snapshots": { + "name": "domain_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organic_traffic_estimate": { + "name": "organic_traffic_estimate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "organic_keyword_count": { + "name": "organic_keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "backlink_count": { + "name": "backlink_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "referring_domain_count": { + "name": "referring_domain_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank_signal": { + "name": "rank_signal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "domain_snapshots_dedupe_idx": { + "name": "domain_snapshots_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_snapshots_entity_date_idx": { + "name": "domain_snapshots_entity_date_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domain_snapshots_entity_id_search_entities_id_fk": { + "name": "domain_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "domain_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_budget_state": { + "name": "search_budget_state", + "schema": "", + "columns": { + "month": { + "name": "month", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_cost_micros": { + "name": "monthly_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_day": { + "name": "current_day", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_cost_micros": { + "name": "daily_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "circuit_opened_at": { + "name": "circuit_opened_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_alert_threshold": { + "name": "last_alert_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_entities": { + "name": "search_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_domain": { + "name": "canonical_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_domain": { + "name": "normalized_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "include_subdomains": { + "name": "include_subdomains", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'it'" + }, + "refresh_interval_hours": { + "name": "refresh_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "backlink_interval_hours": { + "name": "backlink_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 168 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_backlink_refreshed_at": { + "name": "last_backlink_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_entities_domain_market_idx": { + "name": "search_entities_domain_market_idx", + "columns": [ + { + "expression": "normalized_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_entities_enabled_idx": { + "name": "search_entities_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_usage_ledger": { + "name": "search_usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint_path": { + "name": "endpoint_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metering_class": { + "name": "metering_class", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_requests": { + "name": "failed_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_requests": { + "name": "retry_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "search_usage_ledger_day_endpoint_idx": { + "name": "search_usage_ledger_day_endpoint_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metering_class", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_usage_ledger_day_idx": { + "name": "search_usage_ledger_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_clusters": { + "name": "keyword_clusters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_clusters_slug_idx": { + "name": "keyword_clusters_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keyword_gap_snapshots": { + "name": "keyword_gap_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_rank": { + "name": "primary_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_competitor_rank": { + "name": "best_competitor_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "best_competitor_entity_id": { + "name": "best_competitor_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opportunity_score": { + "name": "opportunity_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "keyword_gap_snapshots_dedupe_idx": { + "name": "keyword_gap_snapshots_dedupe_idx", + "columns": [ + { + "expression": "tracked_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keyword_gap_snapshots_date_idx": { + "name": "keyword_gap_snapshots_date_idx", + "columns": [ + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keyword_gap_snapshots_tracked_keyword_id_tracked_keywords_id_fk": { + "name": "keyword_gap_snapshots_tracked_keyword_id_tracked_keywords_id_fk", + "tableFrom": "keyword_gap_snapshots", + "tableTo": "tracked_keywords", + "columnsFrom": [ + "tracked_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.phase2_usage_ledger": { + "name": "phase2_usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_requests": { + "name": "http_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "paid_tasks": { + "name": "paid_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "phase2_usage_ledger_day_job_idx": { + "name": "phase2_usage_ledger_day_job_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ranking_events": { + "name": "ranking_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_rank": { + "name": "previous_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_rank": { + "name": "current_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "competitor_entity_id": { + "name": "competitor_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notified_at": { + "name": "notified_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "ranking_events_dedupe_idx": { + "name": "ranking_events_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ranking_events_detected_idx": { + "name": "ranking_events_detected_idx", + "columns": [ + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ranking_jobs": { + "name": "ranking_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ranking_jobs_dedupe_idx": { + "name": "ranking_jobs_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ranking_jobs_queue_idx": { + "name": "ranking_jobs_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_of_search_snapshots": { + "name": "share_of_search_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cluster_id": { + "name": "cluster_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility_score": { + "name": "visibility_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "share": { + "name": "share", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keywords_considered": { + "name": "keywords_considered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "keywords_covered": { + "name": "keywords_covered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ctr_model_version": { + "name": "ctr_model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "share_of_search_dedupe_idx": { + "name": "share_of_search_dedupe_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cluster_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "share_of_search_date_idx": { + "name": "share_of_search_date_idx", + "columns": [ + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_of_search_snapshots_entity_id_search_entities_id_fk": { + "name": "share_of_search_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "share_of_search_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.si_rank_snapshots": { + "name": "si_rank_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_at": { + "name": "snapshot_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_ranking_url": { + "name": "normalized_ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_found": { + "name": "is_found", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + } + }, + "indexes": { + "si_rank_snapshots_dedupe_idx": { + "name": "si_rank_snapshots_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "si_rank_snapshots_keyword_date_idx": { + "name": "si_rank_snapshots_keyword_date_idx", + "columns": [ + { + "expression": "tracked_keyword_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "si_rank_snapshots_entity_date_idx": { + "name": "si_rank_snapshots_entity_date_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "si_rank_snapshots_tracked_keyword_id_tracked_keywords_id_fk": { + "name": "si_rank_snapshots_tracked_keyword_id_tracked_keywords_id_fk", + "tableFrom": "si_rank_snapshots", + "tableTo": "tracked_keywords", + "columnsFrom": [ + "tracked_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "si_rank_snapshots_entity_id_search_entities_id_fk": { + "name": "si_rank_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "si_rank_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_keywords": { + "name": "tracked_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_keyword": { + "name": "normalized_keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cluster_id": { + "name": "cluster_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'it'" + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'desktop'" + }, + "tracking_frequency_hours": { + "name": "tracking_frequency_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 168 + }, + "tracking_enabled": { + "name": "tracking_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "alerting_enabled": { + "name": "alerting_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tracked_keywords_keyword_market_idx": { + "name": "tracked_keywords_keyword_market_idx", + "columns": [ + { + "expression": "normalized_keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_keywords_due_idx": { + "name": "tracked_keywords_due_idx", + "columns": [ + { + "expression": "tracking_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_check_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_keywords_cluster_idx": { + "name": "tracked_keywords_cluster_idx", + "columns": [ + { + "expression": "cluster_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_keywords_cluster_id_keyword_clusters_id_fk": { + "name": "tracked_keywords_cluster_id_keyword_clusters_id_fk", + "tableFrom": "tracked_keywords", + "tableTo": "keyword_clusters", + "columnsFrom": [ + "cluster_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index d9b00147..4e01eaf4 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -99,6 +99,20 @@ "when": 1784426969390, "tag": "0013_sleepy_black_tarantula", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785968577883, + "tag": "0014_search_intelligence", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785976065757, + "tag": "0015_search_intelligence_phase2", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/0037_search_intelligence.sql b/drizzle/0037_search_intelligence.sql new file mode 100644 index 00000000..7191d87c --- /dev/null +++ b/drizzle/0037_search_intelligence.sql @@ -0,0 +1,132 @@ +CREATE TABLE `domain_refresh_jobs` ( + `id` text PRIMARY KEY NOT NULL, + `entity_id` text NOT NULL, + `status` text NOT NULL, + `trigger` text NOT NULL, + `requested_by` text, + `dedupe_key` text NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `last_error` text, + `skip_reason` text, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `snapshot_id` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `started_at` text, + `finished_at` text, + FOREIGN KEY (`entity_id`) REFERENCES `search_entities`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `domain_refresh_jobs_dedupe_idx` ON `domain_refresh_jobs` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `domain_refresh_jobs_status_idx` ON `domain_refresh_jobs` (`status`,`created_at`);--> statement-breakpoint +CREATE INDEX `domain_refresh_jobs_entity_idx` ON `domain_refresh_jobs` (`entity_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `domain_snapshot_keywords` ( + `id` text PRIMARY KEY NOT NULL, + `snapshot_id` text NOT NULL, + `keyword` text NOT NULL, + `rank_group` integer, + `rank_absolute` integer, + `search_volume` integer, + `estimated_traffic` real, + `cpc` real, + `keyword_difficulty` integer, + `search_intent` text, + `ranking_url` text, + `serp_updated_at` text, + `position` integer NOT NULL, + FOREIGN KEY (`snapshot_id`) REFERENCES `domain_snapshots`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `domain_snapshot_keywords_snapshot_idx` ON `domain_snapshot_keywords` (`snapshot_id`,`position`);--> statement-breakpoint +CREATE TABLE `domain_snapshot_pages` ( + `id` text PRIMARY KEY NOT NULL, + `snapshot_id` text NOT NULL, + `url` text NOT NULL, + `normalized_url` text NOT NULL, + `estimated_traffic` real, + `keyword_count` integer, + `top_keyword` text, + `top_keyword_position` integer, + `page_title` text, + `last_seen_at` text, + `position` integer NOT NULL, + FOREIGN KEY (`snapshot_id`) REFERENCES `domain_snapshots`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `domain_snapshot_pages_snapshot_idx` ON `domain_snapshot_pages` (`snapshot_id`,`position`);--> statement-breakpoint +CREATE TABLE `domain_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `entity_id` text NOT NULL, + `organic_traffic_estimate` real, + `organic_keyword_count` integer, + `backlink_count` integer, + `referring_domain_count` integer, + `rank_signal` integer, + `location_code` integer NOT NULL, + `language_code` text NOT NULL, + `source` text NOT NULL, + `provider_request_id` text, + `fetched_at` text NOT NULL, + `snapshot_date` text NOT NULL, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `dedupe_key` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`entity_id`) REFERENCES `search_entities`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `domain_snapshots_dedupe_idx` ON `domain_snapshots` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `domain_snapshots_entity_date_idx` ON `domain_snapshots` (`entity_id`,`snapshot_date`);--> statement-breakpoint +CREATE TABLE `search_budget_state` ( + `month` text PRIMARY KEY NOT NULL, + `monthly_cost_micros` integer DEFAULT 0 NOT NULL, + `current_day` text, + `daily_cost_micros` integer DEFAULT 0 NOT NULL, + `consecutive_failures` integer DEFAULT 0 NOT NULL, + `circuit_opened_at` text, + `last_alert_threshold` integer, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `search_entities` ( + `id` text PRIMARY KEY NOT NULL, + `display_name` text NOT NULL, + `canonical_domain` text NOT NULL, + `normalized_domain` text NOT NULL, + `entity_type` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `priority` text DEFAULT 'normal' NOT NULL, + `include_subdomains` integer DEFAULT false NOT NULL, + `location_code` integer DEFAULT 2380 NOT NULL, + `language_code` text DEFAULT 'it' NOT NULL, + `refresh_interval_hours` integer DEFAULT 24 NOT NULL, + `backlink_interval_hours` integer DEFAULT 168 NOT NULL, + `last_refreshed_at` text, + `last_backlink_refreshed_at` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL, + `disabled_at` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `search_entities_domain_market_idx` ON `search_entities` (`normalized_domain`,`location_code`,`language_code`);--> statement-breakpoint +CREATE INDEX `search_entities_enabled_idx` ON `search_entities` (`enabled`,`entity_type`);--> statement-breakpoint +CREATE TABLE `search_usage_ledger` ( + `id` text PRIMARY KEY NOT NULL, + `day` text NOT NULL, + `entity_id` text, + `endpoint_path` text NOT NULL, + `metering_class` text NOT NULL, + `requests` integer DEFAULT 0 NOT NULL, + `metered_requests` integer DEFAULT 0 NOT NULL, + `failed_requests` integer DEFAULT 0 NOT NULL, + `retry_requests` integer DEFAULT 0 NOT NULL, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `cache_hits` integer DEFAULT 0 NOT NULL, + `cache_misses` integer DEFAULT 0 NOT NULL, + `blocked_by_budget` integer DEFAULT 0 NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `search_usage_ledger_day_endpoint_idx` ON `search_usage_ledger` (`day`,`endpoint_path`,`metering_class`);--> statement-breakpoint +CREATE INDEX `search_usage_ledger_day_idx` ON `search_usage_ledger` (`day`); \ No newline at end of file diff --git a/drizzle/0038_search_intelligence_phase2.sql b/drizzle/0038_search_intelligence_phase2.sql new file mode 100644 index 00000000..dc91c92e --- /dev/null +++ b/drizzle/0038_search_intelligence_phase2.sql @@ -0,0 +1,149 @@ +CREATE TABLE `keyword_clusters` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `description` text, + `weight` real DEFAULT 1 NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `keyword_clusters_slug_idx` ON `keyword_clusters` (`slug`);--> statement-breakpoint +CREATE TABLE `keyword_gap_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `tracked_keyword_id` text NOT NULL, + `snapshot_date` text NOT NULL, + `category` text NOT NULL, + `primary_rank` integer, + `best_competitor_rank` integer, + `best_competitor_entity_id` text, + `opportunity_score` real, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`tracked_keyword_id`) REFERENCES `tracked_keywords`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `keyword_gap_snapshots_dedupe_idx` ON `keyword_gap_snapshots` (`tracked_keyword_id`,`snapshot_date`);--> statement-breakpoint +CREATE INDEX `keyword_gap_snapshots_date_idx` ON `keyword_gap_snapshots` (`snapshot_date`,`category`);--> statement-breakpoint +CREATE TABLE `phase2_usage_ledger` ( + `id` text PRIMARY KEY NOT NULL, + `day` text NOT NULL, + `job_type` text NOT NULL, + `http_requests` integer DEFAULT 0 NOT NULL, + `metered_requests` integer DEFAULT 0 NOT NULL, + `paid_tasks` integer DEFAULT 0 NOT NULL, + `keywords_checked` integer DEFAULT 0 NOT NULL, + `cache_hits` integer DEFAULT 0 NOT NULL, + `cache_misses` integer DEFAULT 0 NOT NULL, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `blocked_by_budget` integer DEFAULT 0 NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `phase2_usage_ledger_day_job_idx` ON `phase2_usage_ledger` (`day`,`job_type`);--> statement-breakpoint +CREATE TABLE `ranking_events` ( + `id` text PRIMARY KEY NOT NULL, + `tracked_keyword_id` text NOT NULL, + `entity_id` text NOT NULL, + `event_type` text NOT NULL, + `previous_rank` integer, + `current_rank` integer, + `competitor_entity_id` text, + `ranking_url` text, + `detected_at` text NOT NULL, + `dedupe_key` text NOT NULL, + `notified_at` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ranking_events_dedupe_idx` ON `ranking_events` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `ranking_events_detected_idx` ON `ranking_events` (`detected_at`);--> statement-breakpoint +CREATE TABLE `ranking_jobs` ( + `id` text PRIMARY KEY NOT NULL, + `job_type` text NOT NULL, + `tracked_keyword_id` text, + `priority` text DEFAULT 'normal' NOT NULL, + `status` text NOT NULL, + `scheduled_at` text NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `last_error` text, + `skip_reason` text, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `dedupe_key` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `started_at` text, + `finished_at` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ranking_jobs_dedupe_idx` ON `ranking_jobs` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `ranking_jobs_queue_idx` ON `ranking_jobs` (`status`,`priority`,`scheduled_at`);--> statement-breakpoint +CREATE TABLE `share_of_search_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `entity_id` text NOT NULL, + `cluster_id` text, + `snapshot_date` text NOT NULL, + `visibility_score` real, + `share` real, + `status` text NOT NULL, + `reason` text, + `keywords_considered` integer DEFAULT 0 NOT NULL, + `keywords_covered` integer DEFAULT 0 NOT NULL, + `ctr_model_version` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`entity_id`) REFERENCES `search_entities`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `share_of_search_dedupe_idx` ON `share_of_search_snapshots` (`entity_id`,`cluster_id`,`snapshot_date`);--> statement-breakpoint +CREATE INDEX `share_of_search_date_idx` ON `share_of_search_snapshots` (`snapshot_date`);--> statement-breakpoint +CREATE TABLE `si_rank_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `tracked_keyword_id` text NOT NULL, + `entity_id` text NOT NULL, + `snapshot_at` text NOT NULL, + `snapshot_date` text NOT NULL, + `location_code` integer NOT NULL, + `language_code` text NOT NULL, + `device` text NOT NULL, + `rank_group` integer, + `rank_absolute` integer, + `ranking_url` text, + `normalized_ranking_url` text, + `is_found` integer NOT NULL, + `provider` text NOT NULL, + `estimated_cost_micros` integer DEFAULT 0 NOT NULL, + `actual_cost_micros` integer DEFAULT 0 NOT NULL, + `dedupe_key` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`tracked_keyword_id`) REFERENCES `tracked_keywords`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`entity_id`) REFERENCES `search_entities`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `si_rank_snapshots_dedupe_idx` ON `si_rank_snapshots` (`dedupe_key`);--> statement-breakpoint +CREATE INDEX `si_rank_snapshots_keyword_date_idx` ON `si_rank_snapshots` (`tracked_keyword_id`,`snapshot_date`);--> statement-breakpoint +CREATE INDEX `si_rank_snapshots_entity_date_idx` ON `si_rank_snapshots` (`entity_id`,`snapshot_date`);--> statement-breakpoint +CREATE TABLE `tracked_keywords` ( + `id` text PRIMARY KEY NOT NULL, + `keyword` text NOT NULL, + `normalized_keyword` text NOT NULL, + `cluster_id` text, + `priority` text DEFAULT 'normal' NOT NULL, + `location_code` integer DEFAULT 2380 NOT NULL, + `language_code` text DEFAULT 'it' NOT NULL, + `device` text DEFAULT 'desktop' NOT NULL, + `tracking_frequency_hours` integer DEFAULT 168 NOT NULL, + `tracking_enabled` integer DEFAULT true NOT NULL, + `alerting_enabled` integer DEFAULT true NOT NULL, + `search_volume` integer, + `last_checked_at` text, + `next_check_at` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL, + `disabled_at` text, + FOREIGN KEY (`cluster_id`) REFERENCES `keyword_clusters`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tracked_keywords_keyword_market_idx` ON `tracked_keywords` (`normalized_keyword`,`location_code`,`language_code`,`device`);--> statement-breakpoint +CREATE INDEX `tracked_keywords_due_idx` ON `tracked_keywords` (`tracking_enabled`,`next_check_at`);--> statement-breakpoint +CREATE INDEX `tracked_keywords_cluster_idx` ON `tracked_keywords` (`cluster_id`); \ No newline at end of file diff --git a/drizzle/meta/0037_snapshot.json b/drizzle/meta/0037_snapshot.json new file mode 100644 index 00000000..42706d20 --- /dev/null +++ b/drizzle/meta/0037_snapshot.json @@ -0,0 +1,4301 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f2b56e8d-bb82-4381-ac2e-97784d375b60", + "prevId": "3eed5245-3458-4f6b-b6ce-b970ca141bd8", + "tables": { + "backlink_snapshots": { + "name": "backlink_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backlinks": { + "name": "backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referring_domains": { + "name": "referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "broken_backlinks": { + "name": "broken_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_backlinks": { + "name": "new_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lost_backlinks": { + "name": "lost_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_referring_domains": { + "name": "new_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lost_referring_domains": { + "name": "lost_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "captured_at": { + "name": "captured_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "backlink_snapshots_project_captured_idx": { + "name": "backlink_snapshots_project_captured_idx", + "columns": [ + "project_id", + "captured_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "backlink_snapshots_project_id_projects_id_fk": { + "name": "backlink_snapshots_project_id_projects_id_fk", + "tableFrom": "backlink_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_activation_state": { + "name": "organization_activation_state", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "first_mcp_authorized_at": { + "name": "first_mcp_authorized_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_mcp_tool_call_at": { + "name": "first_mcp_tool_call_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_activation_state_organization_id_organization_id_fk": { + "name": "organization_activation_state_organization_id_organization_id_fk", + "tableFrom": "organization_activation_state", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_activation_state": { + "name": "project_activation_state", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "competitor_step_clicked_at": { + "name": "competitor_step_clicked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_card_dismissed_at": { + "name": "mcp_card_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "project_activation_state_project_id_projects_id_fk": { + "name": "project_activation_state_project_id_projects_id_fk", + "tableFrom": "project_activation_state", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL" + }, + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "location_name": { + "name": "location_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_configs_project_active_created_idx": { + "name": "rank_tracking_configs_project_active_created_idx", + "columns": [ + "project_id", + "is_active", + "created_at" + ], + "isUnique": false + }, + "rank_tracking_configs_national_idx": { + "name": "rank_tracking_configs_national_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NULL" + }, + "rank_tracking_configs_local_idx": { + "name": "rank_tracking_configs_local_idx", + "columns": [ + "project_id", + "domain", + "location_code", + "location_name" + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL" + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + "saved_keyword_id", + "tag_id" + ], + "isUnique": true + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tags": { + "name": "saved_keyword_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + "project_id", + "normalized_name" + ], + "isUnique": true + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_onboarding_answers": { + "name": "user_onboarding_answers", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_issues": { + "name": "audit_issues", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "details_json": { + "name": "details_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_issues_audit_type_idx": { + "name": "audit_issues_audit_type_idx", + "columns": [ + "audit_id", + "issue_type" + ], + "isUnique": false + }, + "audit_issues_page_id_idx": { + "name": "audit_issues_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_issues_audit_id_audits_id_fk": { + "name": "audit_issues_audit_id_audits_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_issues_page_id_audit_pages_id_fk": { + "name": "audit_issues_page_id_audit_pages_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + }, + "audit_lighthouse_results_page_id_idx": { + "name": "audit_lighthouse_results_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_links": { + "name": "audit_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_page_id": { + "name": "source_page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_internal": { + "name": "is_internal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_nofollow": { + "name": "is_nofollow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "audit_links_audit_target_idx": { + "name": "audit_links_audit_target_idx", + "columns": [ + "audit_id", + "target_url" + ], + "isUnique": false + }, + "audit_links_source_page_id_idx": { + "name": "audit_links_source_page_id_idx", + "columns": [ + "source_page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_links_audit_id_audits_id_fk": { + "name": "audit_links_audit_id_audits_id_fk", + "tableFrom": "audit_links", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_links_source_page_id_audit_pages_id_fk": { + "name": "audit_links_source_page_id_audit_pages_id_fk", + "tableFrom": "audit_links", + "tableTo": "audit_pages", + "columnsFrom": [ + "source_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "x_robots_tag": { + "name": "x_robots_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header_canonical_url": { + "name": "header_canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawl_depth": { + "name": "crawl_depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "in_sitemap": { + "name": "in_sitemap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetch_class": { + "name": "fetch_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_url_idx": { + "name": "audit_pages_audit_url_idx", + "columns": [ + "audit_id", + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sam_project_memory": { + "name": "sam_project_memory", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "sam_project_memory_project_id_projects_id_fk": { + "name": "sam_project_memory_project_id_projects_id_fk", + "tableFrom": "sam_project_memory", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sam_project_memory_project_id_label_pk": { + "columns": [ + "project_id", + "label" + ], + "name": "sam_project_memory_project_id_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sam_sessions": { + "name": "sam_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sam_sessions_project_updated_idx": { + "name": "sam_sessions_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sam_sessions_project_id_projects_id_fk": { + "name": "sam_sessions_project_id_projects_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sam_sessions_user_id_user_id_fk": { + "name": "sam_sessions_user_id_user_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "account_accountId_providerId_idx": { + "name": "account_accountId_providerId_idx", + "columns": [ + "account_id", + "provider_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + }, + "verification_expiresAt_idx": { + "name": "verification_expiresAt_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_customer_status": { + "name": "billing_customer_status", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_paying": { + "name": "is_paying", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gsc_connections": { + "name": "gsc_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gsc_account_id": { + "name": "gsc_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_attributions": { + "name": "reddit_attributions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "telemetry_state": { + "name": "telemetry_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version": { + "name": "last_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_tool_call_count": { + "name": "mcp_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_refresh_jobs": { + "name": "domain_refresh_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "domain_refresh_jobs_dedupe_idx": { + "name": "domain_refresh_jobs_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "domain_refresh_jobs_status_idx": { + "name": "domain_refresh_jobs_status_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "domain_refresh_jobs_entity_idx": { + "name": "domain_refresh_jobs_entity_idx", + "columns": [ + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_refresh_jobs_entity_id_search_entities_id_fk": { + "name": "domain_refresh_jobs_entity_id_search_entities_id_fk", + "tableFrom": "domain_refresh_jobs", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshot_keywords": { + "name": "domain_snapshot_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_intent": { + "name": "search_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_updated_at": { + "name": "serp_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "domain_snapshot_keywords_snapshot_idx": { + "name": "domain_snapshot_keywords_snapshot_idx", + "columns": [ + "snapshot_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_keywords", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshot_pages": { + "name": "domain_snapshot_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_count": { + "name": "keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "top_keyword": { + "name": "top_keyword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "top_keyword_position": { + "name": "top_keyword_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "domain_snapshot_pages_snapshot_idx": { + "name": "domain_snapshot_pages_snapshot_idx", + "columns": [ + "snapshot_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_pages", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshots": { + "name": "domain_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organic_traffic_estimate": { + "name": "organic_traffic_estimate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organic_keyword_count": { + "name": "organic_keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backlink_count": { + "name": "backlink_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referring_domain_count": { + "name": "referring_domain_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank_signal": { + "name": "rank_signal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "domain_snapshots_dedupe_idx": { + "name": "domain_snapshots_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "domain_snapshots_entity_date_idx": { + "name": "domain_snapshots_entity_date_idx", + "columns": [ + "entity_id", + "snapshot_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshots_entity_id_search_entities_id_fk": { + "name": "domain_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "domain_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_budget_state": { + "name": "search_budget_state", + "columns": { + "month": { + "name": "month", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monthly_cost_micros": { + "name": "monthly_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_day": { + "name": "current_day", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_cost_micros": { + "name": "daily_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "circuit_opened_at": { + "name": "circuit_opened_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_alert_threshold": { + "name": "last_alert_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_entities": { + "name": "search_entities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "canonical_domain": { + "name": "canonical_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_domain": { + "name": "normalized_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "include_subdomains": { + "name": "include_subdomains", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'it'" + }, + "refresh_interval_hours": { + "name": "refresh_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 24 + }, + "backlink_interval_hours": { + "name": "backlink_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 168 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backlink_refreshed_at": { + "name": "last_backlink_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_entities_domain_market_idx": { + "name": "search_entities_domain_market_idx", + "columns": [ + "normalized_domain", + "location_code", + "language_code" + ], + "isUnique": true + }, + "search_entities_enabled_idx": { + "name": "search_entities_enabled_idx", + "columns": [ + "enabled", + "entity_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_usage_ledger": { + "name": "search_usage_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_path": { + "name": "endpoint_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metering_class": { + "name": "metering_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requests": { + "name": "requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failed_requests": { + "name": "failed_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "retry_requests": { + "name": "retry_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "search_usage_ledger_day_endpoint_idx": { + "name": "search_usage_ledger_day_endpoint_idx", + "columns": [ + "day", + "endpoint_path", + "metering_class" + ], + "isUnique": true + }, + "search_usage_ledger_day_idx": { + "name": "search_usage_ledger_day_idx", + "columns": [ + "day" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0038_snapshot.json b/drizzle/meta/0038_snapshot.json new file mode 100644 index 00000000..1e7b7db6 --- /dev/null +++ b/drizzle/meta/0038_snapshot.json @@ -0,0 +1,5347 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a83d414c-7009-4643-b382-34f7808a99aa", + "prevId": "f2b56e8d-bb82-4381-ac2e-97784d375b60", + "tables": { + "backlink_snapshots": { + "name": "backlink_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backlinks": { + "name": "backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referring_domains": { + "name": "referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "broken_backlinks": { + "name": "broken_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_backlinks": { + "name": "new_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lost_backlinks": { + "name": "lost_backlinks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_referring_domains": { + "name": "new_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lost_referring_domains": { + "name": "lost_referring_domains", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "captured_at": { + "name": "captured_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "backlink_snapshots_project_captured_idx": { + "name": "backlink_snapshots_project_captured_idx", + "columns": [ + "project_id", + "captured_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "backlink_snapshots_project_id_projects_id_fk": { + "name": "backlink_snapshots_project_id_projects_id_fk", + "tableFrom": "backlink_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_activation_state": { + "name": "organization_activation_state", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "first_mcp_authorized_at": { + "name": "first_mcp_authorized_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_mcp_tool_call_at": { + "name": "first_mcp_tool_call_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_activation_state_organization_id_organization_id_fk": { + "name": "organization_activation_state_organization_id_organization_id_fk", + "tableFrom": "organization_activation_state", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_activation_state": { + "name": "project_activation_state", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "competitor_step_clicked_at": { + "name": "competitor_step_clicked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_card_dismissed_at": { + "name": "mcp_card_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "project_activation_state_project_id_projects_id_fk": { + "name": "project_activation_state_project_id_projects_id_fk", + "tableFrom": "project_activation_state", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL AND \"projects\".\"archived_at\" IS NULL" + }, + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "location_name": { + "name": "location_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_configs_project_active_created_idx": { + "name": "rank_tracking_configs_project_active_created_idx", + "columns": [ + "project_id", + "is_active", + "created_at" + ], + "isUnique": false + }, + "rank_tracking_configs_national_idx": { + "name": "rank_tracking_configs_national_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NULL" + }, + "rank_tracking_configs_local_idx": { + "name": "rank_tracking_configs_local_idx", + "columns": [ + "project_id", + "domain", + "location_code", + "location_name" + ], + "isUnique": true, + "where": "\"rank_tracking_configs\".\"location_name\" IS NOT NULL" + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + "saved_keyword_id", + "tag_id" + ], + "isUnique": true + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tags": { + "name": "saved_keyword_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + "project_id", + "normalized_name" + ], + "isUnique": true + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_onboarding_answers": { + "name": "user_onboarding_answers", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_issues": { + "name": "audit_issues", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "details_json": { + "name": "details_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_issues_audit_type_idx": { + "name": "audit_issues_audit_type_idx", + "columns": [ + "audit_id", + "issue_type" + ], + "isUnique": false + }, + "audit_issues_page_id_idx": { + "name": "audit_issues_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_issues_audit_id_audits_id_fk": { + "name": "audit_issues_audit_id_audits_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_issues_page_id_audit_pages_id_fk": { + "name": "audit_issues_page_id_audit_pages_id_fk", + "tableFrom": "audit_issues", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + }, + "audit_lighthouse_results_page_id_idx": { + "name": "audit_lighthouse_results_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_links": { + "name": "audit_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_page_id": { + "name": "source_page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_internal": { + "name": "is_internal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_nofollow": { + "name": "is_nofollow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "audit_links_audit_target_idx": { + "name": "audit_links_audit_target_idx", + "columns": [ + "audit_id", + "target_url" + ], + "isUnique": false + }, + "audit_links_source_page_id_idx": { + "name": "audit_links_source_page_id_idx", + "columns": [ + "source_page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_links_audit_id_audits_id_fk": { + "name": "audit_links_audit_id_audits_id_fk", + "tableFrom": "audit_links", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_links_source_page_id_audit_pages_id_fk": { + "name": "audit_links_source_page_id_audit_pages_id_fk", + "tableFrom": "audit_links", + "tableTo": "audit_pages", + "columnsFrom": [ + "source_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "x_robots_tag": { + "name": "x_robots_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header_canonical_url": { + "name": "header_canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawl_depth": { + "name": "crawl_depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "in_sitemap": { + "name": "in_sitemap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetch_class": { + "name": "fetch_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_url_idx": { + "name": "audit_pages_audit_url_idx", + "columns": [ + "audit_id", + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sam_project_memory": { + "name": "sam_project_memory", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "sam_project_memory_project_id_projects_id_fk": { + "name": "sam_project_memory_project_id_projects_id_fk", + "tableFrom": "sam_project_memory", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sam_project_memory_project_id_label_pk": { + "columns": [ + "project_id", + "label" + ], + "name": "sam_project_memory_project_id_label_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sam_sessions": { + "name": "sam_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New chat'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sam_sessions_project_updated_idx": { + "name": "sam_sessions_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sam_sessions_project_id_projects_id_fk": { + "name": "sam_sessions_project_id_projects_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sam_sessions_user_id_user_id_fk": { + "name": "sam_sessions_user_id_user_id_fk", + "tableFrom": "sam_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "account_accountId_providerId_idx": { + "name": "account_accountId_providerId_idx", + "columns": [ + "account_id", + "provider_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + }, + "verification_expiresAt_idx": { + "name": "verification_expiresAt_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_customer_status": { + "name": "billing_customer_status", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_paying": { + "name": "is_paying", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_plan_status": { + "name": "paid_plan_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gsc_connections": { + "name": "gsc_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gsc_account_id": { + "name": "gsc_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connected_account_email": { + "name": "connected_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reddit_attributions": { + "name": "reddit_attributions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_sent_at": { + "name": "purchase_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "telemetry_state": { + "name": "telemetry_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_version": { + "name": "last_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_tool_call_count": { + "name": "mcp_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_refresh_jobs": { + "name": "domain_refresh_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "domain_refresh_jobs_dedupe_idx": { + "name": "domain_refresh_jobs_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "domain_refresh_jobs_status_idx": { + "name": "domain_refresh_jobs_status_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "domain_refresh_jobs_entity_idx": { + "name": "domain_refresh_jobs_entity_idx", + "columns": [ + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_refresh_jobs_entity_id_search_entities_id_fk": { + "name": "domain_refresh_jobs_entity_id_search_entities_id_fk", + "tableFrom": "domain_refresh_jobs", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshot_keywords": { + "name": "domain_snapshot_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_intent": { + "name": "search_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_updated_at": { + "name": "serp_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "domain_snapshot_keywords_snapshot_idx": { + "name": "domain_snapshot_keywords_snapshot_idx", + "columns": [ + "snapshot_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_keywords_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_keywords", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshot_pages": { + "name": "domain_snapshot_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_traffic": { + "name": "estimated_traffic", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_count": { + "name": "keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "top_keyword": { + "name": "top_keyword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "top_keyword_position": { + "name": "top_keyword_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "domain_snapshot_pages_snapshot_idx": { + "name": "domain_snapshot_pages_snapshot_idx", + "columns": [ + "snapshot_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk": { + "name": "domain_snapshot_pages_snapshot_id_domain_snapshots_id_fk", + "tableFrom": "domain_snapshot_pages", + "tableTo": "domain_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domain_snapshots": { + "name": "domain_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organic_traffic_estimate": { + "name": "organic_traffic_estimate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organic_keyword_count": { + "name": "organic_keyword_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backlink_count": { + "name": "backlink_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referring_domain_count": { + "name": "referring_domain_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank_signal": { + "name": "rank_signal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "domain_snapshots_dedupe_idx": { + "name": "domain_snapshots_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "domain_snapshots_entity_date_idx": { + "name": "domain_snapshots_entity_date_idx", + "columns": [ + "entity_id", + "snapshot_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "domain_snapshots_entity_id_search_entities_id_fk": { + "name": "domain_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "domain_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_budget_state": { + "name": "search_budget_state", + "columns": { + "month": { + "name": "month", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monthly_cost_micros": { + "name": "monthly_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_day": { + "name": "current_day", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "daily_cost_micros": { + "name": "daily_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "circuit_opened_at": { + "name": "circuit_opened_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_alert_threshold": { + "name": "last_alert_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_entities": { + "name": "search_entities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "canonical_domain": { + "name": "canonical_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_domain": { + "name": "normalized_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "include_subdomains": { + "name": "include_subdomains", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'it'" + }, + "refresh_interval_hours": { + "name": "refresh_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 24 + }, + "backlink_interval_hours": { + "name": "backlink_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 168 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backlink_refreshed_at": { + "name": "last_backlink_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_entities_domain_market_idx": { + "name": "search_entities_domain_market_idx", + "columns": [ + "normalized_domain", + "location_code", + "language_code" + ], + "isUnique": true + }, + "search_entities_enabled_idx": { + "name": "search_entities_enabled_idx", + "columns": [ + "enabled", + "entity_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_usage_ledger": { + "name": "search_usage_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_path": { + "name": "endpoint_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metering_class": { + "name": "metering_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requests": { + "name": "requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failed_requests": { + "name": "failed_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "retry_requests": { + "name": "retry_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "search_usage_ledger_day_endpoint_idx": { + "name": "search_usage_ledger_day_endpoint_idx", + "columns": [ + "day", + "endpoint_path", + "metering_class" + ], + "isUnique": true + }, + "search_usage_ledger_day_idx": { + "name": "search_usage_ledger_day_idx", + "columns": [ + "day" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_clusters": { + "name": "keyword_clusters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_clusters_slug_idx": { + "name": "keyword_clusters_slug_idx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_gap_snapshots": { + "name": "keyword_gap_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary_rank": { + "name": "primary_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_competitor_rank": { + "name": "best_competitor_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_competitor_entity_id": { + "name": "best_competitor_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opportunity_score": { + "name": "opportunity_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_gap_snapshots_dedupe_idx": { + "name": "keyword_gap_snapshots_dedupe_idx", + "columns": [ + "tracked_keyword_id", + "snapshot_date" + ], + "isUnique": true + }, + "keyword_gap_snapshots_date_idx": { + "name": "keyword_gap_snapshots_date_idx", + "columns": [ + "snapshot_date", + "category" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_gap_snapshots_tracked_keyword_id_tracked_keywords_id_fk": { + "name": "keyword_gap_snapshots_tracked_keyword_id_tracked_keywords_id_fk", + "tableFrom": "keyword_gap_snapshots", + "tableTo": "tracked_keywords", + "columnsFrom": [ + "tracked_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "phase2_usage_ledger": { + "name": "phase2_usage_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "http_requests": { + "name": "http_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metered_requests": { + "name": "metered_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "paid_tasks": { + "name": "paid_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_hits": { + "name": "cache_hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_misses": { + "name": "cache_misses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "blocked_by_budget": { + "name": "blocked_by_budget", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "phase2_usage_ledger_day_job_idx": { + "name": "phase2_usage_ledger_day_job_idx", + "columns": [ + "day", + "job_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ranking_events": { + "name": "ranking_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_rank": { + "name": "previous_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_rank": { + "name": "current_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competitor_entity_id": { + "name": "competitor_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_at": { + "name": "detected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notified_at": { + "name": "notified_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ranking_events_dedupe_idx": { + "name": "ranking_events_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "ranking_events_detected_idx": { + "name": "ranking_events_detected_idx", + "columns": [ + "detected_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ranking_jobs": { + "name": "ranking_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ranking_jobs_dedupe_idx": { + "name": "ranking_jobs_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "ranking_jobs_queue_idx": { + "name": "ranking_jobs_queue_idx", + "columns": [ + "status", + "priority", + "scheduled_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_of_search_snapshots": { + "name": "share_of_search_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cluster_id": { + "name": "cluster_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility_score": { + "name": "visibility_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share": { + "name": "share", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keywords_considered": { + "name": "keywords_considered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_covered": { + "name": "keywords_covered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ctr_model_version": { + "name": "ctr_model_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "share_of_search_dedupe_idx": { + "name": "share_of_search_dedupe_idx", + "columns": [ + "entity_id", + "cluster_id", + "snapshot_date" + ], + "isUnique": true + }, + "share_of_search_date_idx": { + "name": "share_of_search_date_idx", + "columns": [ + "snapshot_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "share_of_search_snapshots_entity_id_search_entities_id_fk": { + "name": "share_of_search_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "share_of_search_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "si_rank_snapshots": { + "name": "si_rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tracked_keyword_id": { + "name": "tracked_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_at": { + "name": "snapshot_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rank_group": { + "name": "rank_group", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank_absolute": { + "name": "rank_absolute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ranking_url": { + "name": "ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalized_ranking_url": { + "name": "normalized_ranking_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_found": { + "name": "is_found", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_cost_micros": { + "name": "estimated_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "actual_cost_micros": { + "name": "actual_cost_micros", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "si_rank_snapshots_dedupe_idx": { + "name": "si_rank_snapshots_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": true + }, + "si_rank_snapshots_keyword_date_idx": { + "name": "si_rank_snapshots_keyword_date_idx", + "columns": [ + "tracked_keyword_id", + "snapshot_date" + ], + "isUnique": false + }, + "si_rank_snapshots_entity_date_idx": { + "name": "si_rank_snapshots_entity_date_idx", + "columns": [ + "entity_id", + "snapshot_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "si_rank_snapshots_tracked_keyword_id_tracked_keywords_id_fk": { + "name": "si_rank_snapshots_tracked_keyword_id_tracked_keywords_id_fk", + "tableFrom": "si_rank_snapshots", + "tableTo": "tracked_keywords", + "columnsFrom": [ + "tracked_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "si_rank_snapshots_entity_id_search_entities_id_fk": { + "name": "si_rank_snapshots_entity_id_search_entities_id_fk", + "tableFrom": "si_rank_snapshots", + "tableTo": "search_entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tracked_keywords": { + "name": "tracked_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_keyword": { + "name": "normalized_keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cluster_id": { + "name": "cluster_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2380 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'it'" + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'desktop'" + }, + "tracking_frequency_hours": { + "name": "tracking_frequency_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 168 + }, + "tracking_enabled": { + "name": "tracking_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "alerting_enabled": { + "name": "alerting_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "disabled_at": { + "name": "disabled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tracked_keywords_keyword_market_idx": { + "name": "tracked_keywords_keyword_market_idx", + "columns": [ + "normalized_keyword", + "location_code", + "language_code", + "device" + ], + "isUnique": true + }, + "tracked_keywords_due_idx": { + "name": "tracked_keywords_due_idx", + "columns": [ + "tracking_enabled", + "next_check_at" + ], + "isUnique": false + }, + "tracked_keywords_cluster_idx": { + "name": "tracked_keywords_cluster_idx", + "columns": [ + "cluster_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tracked_keywords_cluster_id_keyword_clusters_id_fk": { + "name": "tracked_keywords_cluster_id_keyword_clusters_id_fk", + "tableFrom": "tracked_keywords", + "tableTo": "keyword_clusters", + "columnsFrom": [ + "cluster_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8711f67f..5800520b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -260,6 +260,20 @@ "when": 1784426967421, "tag": "0036_curvy_silk_fever", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1785968528980, + "tag": "0037_search_intelligence", + "breakpoints": true + }, + { + "idx": 38, + "version": "6", + "when": 1785976061163, + "tag": "0038_search_intelligence_phase2", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/scripts/assert-isolation.mjs b/scripts/assert-isolation.mjs new file mode 100644 index 00000000..9aee52ca --- /dev/null +++ b/scripts/assert-isolation.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +/** + * Morgana Search Intelligence — resource isolation guard. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P1). + * + * Refuses to let a migration or a deploy target any Morgana Brand Monitoring + * resource. This exists because the two systems share one Cloudflare account: + * a mistyped database name or a copy-pasted id is the single most damaging + * mistake available here, and it is silent. + * + * Run before every `d1 migrations apply` and every `wrangler deploy`: + * node scripts/assert-isolation.mjs + * + * Migration preflight — additionally assert the exact database the migrator + * will target, so a correct config cannot be paired with a wrong CLI argument: + * node scripts/assert-isolation.mjs --expect-d1 + * + * Exits non-zero on any violation. Read-only: it never calls Cloudflare. + */ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import process from "node:process"; + +/** + * Morgana production resources, stored as truncated SHA-256 digests. + * + * This fork is PUBLIC. Publishing the literal resource names and the production + * D1 UUID would put internal infrastructure identifiers in a public repository + * for no benefit — the guard only ever needs to answer "is this value one of + * the forbidden ones", and a hash answers that exactly as well. The private + * Morgana repository documents which resources these correspond to. + * + * Matching is case-insensitive and on the whole value, so + * `morgana-search-intelligence-staging` cannot collide with a hashed entry. + */ +const FORBIDDEN_NAME_HASHES = new Set([ + "85a473b68a354596", + "cf28d344850bcec7", + "dea4457437b832e0", + "2521ed066e97ea17", + "150b52648b9d542e", + "181a0a49ca35123a", + "de4bb646acf32ab2", + "0e33950f7fd4025f", + "09f13b870af0efd4", +]); + +const FORBIDDEN_ID_HASHES = new Set(["96e3a45820af4479"]); + +const digest = (value) => + createHash("sha256") + .update(value.trim().toLowerCase()) + .digest("hex") + .slice(0, 16); + +const isForbiddenName = (value) => FORBIDDEN_NAME_HASHES.has(digest(value)); +const isForbiddenId = (value) => FORBIDDEN_ID_HASHES.has(digest(value)); + +/** Every resource name must say what it is and that it is not production. */ +const REQUIRED_NAME_FRAGMENT = "search-intelligence"; +const REQUIRED_STAGE_FRAGMENT = "staging"; + +function stripJsonComments(text) { + // The config is JSONC: strip // and /* */ comments and trailing commas. + return text + .replace( + /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, + (match, comment) => (comment ? "" : match), + ) + .replace(/,(\s*[}\]])/g, "$1"); +} + +function collectStrings(value, out = []) { + if (typeof value === "string") { + out.push(value); + } else if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out); + } else if (value && typeof value === "object") { + for (const item of Object.values(value)) collectStrings(item, out); + } + return out; +} + +function main() { + const configPath = process.argv[2]; + if (!configPath) { + console.error("usage: node scripts/assert-isolation.mjs "); + process.exit(2); + } + + let config; + try { + config = JSON.parse(stripJsonComments(readFileSync(configPath, "utf8"))); + } catch (error) { + console.error( + `isolation guard: cannot parse ${configPath}: ${error.message}`, + ); + process.exit(2); + } + + const violations = []; + const values = collectStrings(config); + + // Migration preflight. The config can be correct while the CLI argument is + // wrong — `wrangler d1 migrations apply -c ` takes the database + // from the ARGUMENT, and a config-only check would happily watch the migrator + // rewrite the wrong database. Assert the pair agrees. + const expectIndex = process.argv.indexOf("--expect-d1"); + if (expectIndex !== -1) { + const expected = process.argv[expectIndex + 1]; + const configured = config.d1_databases?.[0]?.database_name; + if (!expected) { + console.error("isolation guard: --expect-d1 requires a database name"); + process.exit(2); + } + if (isForbiddenName(expected)) { + violations.push( + `migration target "${expected}" is a Morgana production database — refusing`, + ); + } + if (!expected.toLowerCase().includes(REQUIRED_STAGE_FRAGMENT)) { + violations.push( + `migration target "${expected}" does not contain "${REQUIRED_STAGE_FRAGMENT}" — phase 1 migrates staging only`, + ); + } + if (configured !== expected) { + violations.push( + `migration target "${expected}" does not match the configured d1 database "${String(configured)}" — ambiguous binding, refusing`, + ); + } + if (!config.d1_databases || config.d1_databases.length !== 1) { + violations.push( + `expected exactly one d1 binding, found ${String(config.d1_databases?.length ?? 0)} — ambiguous migration target`, + ); + } + } + + for (const value of values) { + const normalized = value.trim().toLowerCase(); + if (isForbiddenName(normalized)) { + violations.push( + `"${value}" is a Morgana production resource — Search Intelligence must never share one`, + ); + } + if (isForbiddenId(normalized)) { + violations.push( + `"${value.slice(0, 8)}…" is a Morgana production resource id — refusing`, + ); + } + } + + // Positive assertions: the named resources must be unmistakably ours. + const named = [ + ...(config.d1_databases ?? []).map((d) => ["d1", d.database_name]), + ...(config.r2_buckets ?? []).map((r) => ["r2", r.bucket_name]), + ...(config.kv_namespaces ?? []).map((k) => ["kv", k.binding]), + ["worker", config.name], + ]; + for (const [kind, value] of named) { + if (!value) continue; + const normalized = String(value).toLowerCase(); + // KV bindings are upstream-owned identifiers (KV, OAUTH_KV) and are scoped + // by the namespace id, not the binding name, so they are exempt. + if (kind === "kv") continue; + if (!normalized.includes(REQUIRED_NAME_FRAGMENT)) { + violations.push( + `${kind} "${value}" does not contain "${REQUIRED_NAME_FRAGMENT}" — every resource must be identifiable as Search Intelligence`, + ); + } + if (!normalized.includes(REQUIRED_STAGE_FRAGMENT)) { + violations.push( + `${kind} "${value}" does not contain "${REQUIRED_STAGE_FRAGMENT}" — Phase 0 provisions staging only`, + ); + } + } + + // Deploy posture: no public ingress is the compensating control for the + // absent Access application, so a config that re-enables ingress must fail. + if (config.workers_dev !== false) { + violations.push( + "workers_dev must be explicitly false — a workers.dev route would publish the engine with no Cloudflare Access in front", + ); + } + if (config.preview_urls !== false) { + violations.push( + "preview_urls must be explicitly false — a preview URL would publish the engine with no Cloudflare Access in front", + ); + } + if (config.routes || config.route) { + violations.push( + "routes must not be set — Phase 0 engine is reachable only over the Service Binding", + ); + } + if (config.triggers?.crons?.length) { + violations.push( + "triggers.crons must be empty — a cron would execute scheduled SEO collection", + ); + } + + // Spend posture: the zero-cap invariant, asserted on the committed config so + // it cannot regress between deploys. + const vars = config.vars ?? {}; + if (vars.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED !== "false") { + violations.push( + 'SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED must be "false" in Phase 0', + ); + } + for (const key of [ + "SEO_DATAFORSEO_DAILY_COST_CAP_USD", + "SEO_DATAFORSEO_MONTHLY_COST_CAP_USD", + ]) { + if (vars[key] !== "0") { + violations.push( + `${key} must be "0" in Phase 0 (found ${String(vars[key])})`, + ); + } + } + for (const key of [ + "SEARCH_INTELLIGENCE_MCP_ENABLED", + "SEARCH_INTELLIGENCE_AI_ENABLED", + "SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED", + "SEARCH_INTELLIGENCE_UI_ENABLED", + ]) { + if (vars[key] !== "false") { + violations.push(`${key} must be "false" in Phase 0`); + } + } + // Upstream self-host telemetry is ON by default and posts to a hardcoded + // PostHog project. Phase 0 must not emit it. + if (vars.OPENSEO_TELEMETRY_DISABLED !== "1" || vars.DO_NOT_TRACK !== "1") { + violations.push( + 'OPENSEO_TELEMETRY_DISABLED and DO_NOT_TRACK must both be "1" — upstream self-host telemetry is enabled by default', + ); + } + + // A secret value must never live in config. Names are fine; values are not. + for (const [key, value] of Object.entries(vars)) { + if (/KEY|SECRET|TOKEN|PASSWORD|WEBHOOK/i.test(key) && value) { + violations.push( + `vars.${key} looks like a secret — secrets belong in \`wrangler secret put\`, never in config`, + ); + } + } + + if (violations.length > 0) { + console.error(`\nisolation guard FAILED for ${configPath}:\n`); + for (const violation of violations) { + console.error(` ✗ ${violation}`); + } + console.error(""); + process.exit(1); + } + + console.log(`isolation guard passed for ${configPath}`); + console.log( + ` worker=${config.name} d1=${config.d1_databases?.[0]?.database_name ?? "none"} r2=${config.r2_buckets?.[0]?.bucket_name ?? "none"}`, + ); + console.log( + " no public ingress, no cron, paid calls off, caps zero, telemetry off", + ); +} + +main(); diff --git a/scripts/check-upstream.mjs b/scripts/check-upstream.mjs new file mode 100644 index 00000000..a72487b3 --- /dev/null +++ b/scripts/check-upstream.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Morgana Search Intelligence — upstream drift report. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P5). + * + * READ-ONLY BY DESIGN. This script reports how far the pinned commit is from + * the latest upstream release. It deliberately does NOT: + * - fetch, merge or rebase + * - modify the pin in UPSTREAM.md + * - deploy anything + * + * Advancing the pin is a human decision that goes through the update procedure + * documented in UPSTREAM.md, including a security review. An automatic upgrade + * path for a dependency that can spend money and call third-party APIs is + * exactly what we do not want. + * + * Usage: node scripts/check-upstream.mjs [--json] + * Exit codes: 0 up to date · 3 behind upstream · 2 could not determine + */ +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +const UPSTREAM_REPO = "every-app/open-seo"; +const PIN_FILE = new URL("../UPSTREAM.md", import.meta.url); + +function readPin() { + const text = readFileSync(PIN_FILE, "utf8"); + const commit = /`upstream_commit`\s*\|\s*`([0-9a-f]{40})`/.exec(text)?.[1]; + const release = /`upstream_release`\s*\|\s*`([^`]+)`/.exec(text)?.[1]; + if (!commit || !release) { + throw new Error("could not parse the pin out of UPSTREAM.md"); + } + return { commit, release }; +} + +function gh(args) { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function main() { + const asJson = process.argv.includes("--json"); + let pin; + try { + pin = readPin(); + } catch (error) { + console.error(`check-upstream: ${error.message}`); + process.exit(2); + } + + let latestTag; + let comparison; + try { + latestTag = gh([ + "api", + `repos/${UPSTREAM_REPO}/releases/latest`, + "--jq", + ".tag_name", + ]); + comparison = JSON.parse( + gh([ + "api", + `repos/${UPSTREAM_REPO}/compare/${pin.commit}...${latestTag}`, + "--jq", + "{ahead:.ahead_by, behind:.behind_by, status:.status}", + ]), + ); + } catch (error) { + console.error( + "check-upstream: could not query upstream (is `gh` installed and authenticated?)", + ); + console.error( + String(error.stderr ?? error.message) + .trim() + .split("\n")[0], + ); + process.exit(2); + } + + const behind = comparison.ahead ?? 0; + const report = { + upstream_repository: `https://github.com/${UPSTREAM_REPO}`, + pinned_release: pin.release, + pinned_commit: pin.commit, + latest_release: latestTag, + commits_behind_latest_release: behind, + up_to_date: behind === 0, + }; + + if (asJson) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`upstream ${report.upstream_repository}`); + console.log(`pinned ${pin.release} (${pin.commit.slice(0, 12)}…)`); + console.log(`latest ${latestTag}`); + if (behind === 0) { + console.log("status up to date with the latest upstream release"); + } else { + console.log(`status ${behind} commit(s) behind ${latestTag}`); + console.log(""); + console.log("This is a report, not an action. To upgrade, follow the"); + console.log("update procedure in UPSTREAM.md — fetch, branch, merge by"); + console.log( + "hand, run the full gate, repeat the security review, deploy", + ); + console.log("to staging, and only then move the pin."); + } + } + + // Non-zero on drift so a scheduled check can surface it, without ever + // implying the upgrade happened. + process.exit(behind === 0 ? 0 : 3); +} + +main(); diff --git a/src/db/d1/schema.ts b/src/db/d1/schema.ts index 60794b10..064e7697 100644 --- a/src/db/d1/schema.ts +++ b/src/db/d1/schema.ts @@ -9,3 +9,6 @@ export * from "../billing.schema"; export * from "../gsc.schema"; export * from "../reddit-attribution.schema"; export * from "../telemetry.schema"; +// MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). +export * from "../search-intelligence.schema"; +export * from "../search-intelligence-p2.schema"; diff --git a/src/db/pg/schema.ts b/src/db/pg/schema.ts index 65bda458..4abf64db 100644 --- a/src/db/pg/schema.ts +++ b/src/db/pg/schema.ts @@ -6,3 +6,6 @@ export * from "./billing.schema"; export * from "./gsc.schema"; export * from "./reddit-attribution.schema"; export * from "./telemetry.schema"; +// MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). +export * from "./search-intelligence.schema"; +export * from "./search-intelligence-p2.schema"; diff --git a/src/db/pg/search-intelligence-p2.schema.ts b/src/db/pg/search-intelligence-p2.schema.ts new file mode 100644 index 00000000..2187fa79 --- /dev/null +++ b/src/db/pg/search-intelligence-p2.schema.ts @@ -0,0 +1,289 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgTable, + real, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { searchEntities } from "./search-intelligence.schema"; + +/** + * Morgana Search Intelligence — phase 2 schema (Postgres mirror). + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Structural mirror of `src/db/search-intelligence-p2.schema.ts`. Rationale for + * each table lives there and is not repeated, so the two cannot disagree. + */ + +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 keywordClusters = pgTable( + "keyword_clusters", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull(), + description: text("description"), + weight: real("weight").notNull().default(1), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + }, + (table) => [uniqueIndex("keyword_clusters_slug_idx").on(table.slug)], +); + +export const trackedKeywords = pgTable( + "tracked_keywords", + { + id: text("id").primaryKey(), + keyword: text("keyword").notNull(), + normalizedKeyword: text("normalized_keyword").notNull(), + clusterId: text("cluster_id").references(() => keywordClusters.id, { + onDelete: "set null", + }), + priority: text("priority", { + enum: ["critical", "high", "normal", "low"], + }) + .notNull() + .default("normal"), + locationCode: integer("location_code").notNull().default(2380), + languageCode: text("language_code").notNull().default("it"), + device: text("device", { enum: ["desktop", "mobile"] }) + .notNull() + .default("desktop"), + trackingFrequencyHours: integer("tracking_frequency_hours") + .notNull() + .default(168), + trackingEnabled: boolean("tracking_enabled").notNull().default(true), + alertingEnabled: boolean("alerting_enabled").notNull().default(true), + searchVolume: integer("search_volume"), + lastCheckedAt: timestampColumn("last_checked_at"), + nextCheckAt: timestampColumn("next_check_at"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + disabledAt: timestampColumn("disabled_at"), + }, + (table) => [ + uniqueIndex("tracked_keywords_keyword_market_idx").on( + table.normalizedKeyword, + table.locationCode, + table.languageCode, + table.device, + ), + index("tracked_keywords_due_idx").on( + table.trackingEnabled, + table.nextCheckAt, + ), + index("tracked_keywords_cluster_idx").on(table.clusterId), + ], +); + +export const siRankSnapshots = pgTable( + "si_rank_snapshots", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id") + .notNull() + .references(() => trackedKeywords.id, { onDelete: "cascade" }), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + snapshotAt: timestampColumn("snapshot_at").notNull(), + snapshotDate: text("snapshot_date").notNull(), + locationCode: integer("location_code").notNull(), + languageCode: text("language_code").notNull(), + device: text("device", { enum: ["desktop", "mobile"] }).notNull(), + rankGroup: integer("rank_group"), + rankAbsolute: integer("rank_absolute"), + rankingUrl: text("ranking_url"), + normalizedRankingUrl: text("normalized_ranking_url"), + isFound: boolean("is_found").notNull(), + provider: text("provider", { enum: ["dataforseo", "fixture"] }).notNull(), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + dedupeKey: text("dedupe_key").notNull(), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("si_rank_snapshots_dedupe_idx").on(table.dedupeKey), + index("si_rank_snapshots_keyword_date_idx").on( + table.trackedKeywordId, + table.snapshotDate, + ), + index("si_rank_snapshots_entity_date_idx").on( + table.entityId, + table.snapshotDate, + ), + ], +); + +export const keywordGapSnapshots = pgTable( + "keyword_gap_snapshots", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id") + .notNull() + .references(() => trackedKeywords.id, { onDelete: "cascade" }), + snapshotDate: text("snapshot_date").notNull(), + category: text("category", { + enum: [ + "shared", + "primary_only", + "competitor_only", + "missing", + "weak", + "strong", + "new", + "lost", + "improved", + "declined", + ], + }).notNull(), + primaryRank: integer("primary_rank"), + bestCompetitorRank: integer("best_competitor_rank"), + bestCompetitorEntityId: text("best_competitor_entity_id"), + opportunityScore: real("opportunity_score"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("keyword_gap_snapshots_dedupe_idx").on( + table.trackedKeywordId, + table.snapshotDate, + ), + index("keyword_gap_snapshots_date_idx").on( + table.snapshotDate, + table.category, + ), + ], +); + +export const shareOfSearchSnapshots = pgTable( + "share_of_search_snapshots", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + clusterId: text("cluster_id"), + snapshotDate: text("snapshot_date").notNull(), + visibilityScore: real("visibility_score"), + share: real("share"), + status: text("status", { enum: ["ok", "insufficient_data"] }).notNull(), + reason: text("reason"), + keywordsConsidered: integer("keywords_considered").notNull().default(0), + keywordsCovered: integer("keywords_covered").notNull().default(0), + ctrModelVersion: text("ctr_model_version").notNull(), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("share_of_search_dedupe_idx").on( + table.entityId, + table.clusterId, + table.snapshotDate, + ), + index("share_of_search_date_idx").on(table.snapshotDate), + ], +); + +export const rankingEvents = pgTable( + "ranking_events", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id").notNull(), + entityId: text("entity_id").notNull(), + eventType: text("event_type", { + enum: [ + "entered_top_3", + "entered_top_10", + "left_top_10", + "dropped_10_plus", + "gained_10_plus", + "overtaken_by_competitor", + "overtook_competitors", + "critical_keyword_lost", + "share_of_search_shift", + ], + }).notNull(), + previousRank: integer("previous_rank"), + currentRank: integer("current_rank"), + competitorEntityId: text("competitor_entity_id"), + rankingUrl: text("ranking_url"), + detectedAt: timestampColumn("detected_at").notNull(), + dedupeKey: text("dedupe_key").notNull(), + notifiedAt: timestampColumn("notified_at"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("ranking_events_dedupe_idx").on(table.dedupeKey), + index("ranking_events_detected_idx").on(table.detectedAt), + ], +); + +export const rankingJobs = pgTable( + "ranking_jobs", + { + id: text("id").primaryKey(), + jobType: text("job_type", { + enum: [ + "rank_check", + "keyword_gap_refresh", + "share_of_search_recalculate", + "weekly_digest", + ], + }).notNull(), + trackedKeywordId: text("tracked_keyword_id"), + priority: text("priority", { + enum: ["critical", "high", "normal", "low"], + }) + .notNull() + .default("normal"), + status: text("status", { + enum: ["pending", "running", "succeeded", "failed", "skipped"], + }).notNull(), + scheduledAt: timestampColumn("scheduled_at").notNull(), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + skipReason: text("skip_reason"), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + dedupeKey: text("dedupe_key").notNull(), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + startedAt: timestampColumn("started_at"), + finishedAt: timestampColumn("finished_at"), + }, + (table) => [ + uniqueIndex("ranking_jobs_dedupe_idx").on(table.dedupeKey), + index("ranking_jobs_queue_idx").on( + table.status, + table.priority, + table.scheduledAt, + ), + ], +); + +export const phase2UsageLedger = pgTable( + "phase2_usage_ledger", + { + id: text("id").primaryKey(), + day: text("day").notNull(), + jobType: text("job_type").notNull(), + httpRequests: integer("http_requests").notNull().default(0), + meteredRequests: integer("metered_requests").notNull().default(0), + paidTasks: integer("paid_tasks").notNull().default(0), + keywordsChecked: integer("keywords_checked").notNull().default(0), + cacheHits: integer("cache_hits").notNull().default(0), + cacheMisses: integer("cache_misses").notNull().default(0), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + blockedByBudget: integer("blocked_by_budget").notNull().default(0), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("phase2_usage_ledger_day_job_idx").on(table.day, table.jobType), + ], +); diff --git a/src/db/pg/search-intelligence.schema.ts b/src/db/pg/search-intelligence.schema.ts new file mode 100644 index 00000000..c7bbc531 --- /dev/null +++ b/src/db/pg/search-intelligence.schema.ts @@ -0,0 +1,230 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgTable, + real, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +/** + * Morgana Search Intelligence — phase 1 schema (Postgres mirror). + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Structural mirror of `src/db/search-intelligence.schema.ts`. This file is the + * one artifact `db:generate` does NOT regenerate, so `schema-parity.test.ts` is + * its drift guard: same tables, columns, nullability, defaults and unique + * indexes, or the build fails. + * + * See the SQLite file for the rationale behind each table — it is not repeated + * here, so the two cannot disagree about intent. + */ + +// Timestamps are text in both dialects (see app.schema.ts for why: timestamptz +// would be parsed back into a Date and break lexicographic comparisons). +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 searchEntities = pgTable( + "search_entities", + { + id: text("id").primaryKey(), + displayName: text("display_name").notNull(), + canonicalDomain: text("canonical_domain").notNull(), + normalizedDomain: text("normalized_domain").notNull(), + entityType: text("entity_type", { + enum: ["primary", "competitor", "watch"], + }).notNull(), + enabled: boolean("enabled").notNull().default(true), + priority: text("priority", { enum: ["high", "normal", "low"] }) + .notNull() + .default("normal"), + includeSubdomains: boolean("include_subdomains").notNull().default(false), + locationCode: integer("location_code").notNull().default(2380), + languageCode: text("language_code").notNull().default("it"), + refreshIntervalHours: integer("refresh_interval_hours") + .notNull() + .default(24), + backlinkIntervalHours: integer("backlink_interval_hours") + .notNull() + .default(168), + lastRefreshedAt: timestampColumn("last_refreshed_at"), + lastBacklinkRefreshedAt: timestampColumn("last_backlink_refreshed_at"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + disabledAt: timestampColumn("disabled_at"), + }, + (table) => [ + uniqueIndex("search_entities_domain_market_idx").on( + table.normalizedDomain, + table.locationCode, + table.languageCode, + ), + index("search_entities_enabled_idx").on(table.enabled, table.entityType), + ], +); + +export const domainSnapshots = pgTable( + "domain_snapshots", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + organicTrafficEstimate: real("organic_traffic_estimate"), + organicKeywordCount: integer("organic_keyword_count"), + backlinkCount: integer("backlink_count"), + referringDomainCount: integer("referring_domain_count"), + rankSignal: integer("rank_signal"), + locationCode: integer("location_code").notNull(), + languageCode: text("language_code").notNull(), + source: text("source", { enum: ["dataforseo", "fixture"] }).notNull(), + providerRequestId: text("provider_request_id"), + fetchedAt: timestampColumn("fetched_at").notNull(), + snapshotDate: text("snapshot_date").notNull(), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + dedupeKey: text("dedupe_key").notNull(), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("domain_snapshots_dedupe_idx").on(table.dedupeKey), + index("domain_snapshots_entity_date_idx").on( + table.entityId, + table.snapshotDate, + ), + ], +); + +export const domainSnapshotKeywords = pgTable( + "domain_snapshot_keywords", + { + id: text("id").primaryKey(), + snapshotId: text("snapshot_id") + .notNull() + .references(() => domainSnapshots.id, { onDelete: "cascade" }), + keyword: text("keyword").notNull(), + rankGroup: integer("rank_group"), + rankAbsolute: integer("rank_absolute"), + searchVolume: integer("search_volume"), + estimatedTraffic: real("estimated_traffic"), + cpc: real("cpc"), + keywordDifficulty: integer("keyword_difficulty"), + searchIntent: text("search_intent"), + rankingUrl: text("ranking_url"), + serpUpdatedAt: timestampColumn("serp_updated_at"), + position: integer("position").notNull(), + }, + (table) => [ + index("domain_snapshot_keywords_snapshot_idx").on( + table.snapshotId, + table.position, + ), + ], +); + +export const domainSnapshotPages = pgTable( + "domain_snapshot_pages", + { + id: text("id").primaryKey(), + snapshotId: text("snapshot_id") + .notNull() + .references(() => domainSnapshots.id, { onDelete: "cascade" }), + url: text("url").notNull(), + normalizedUrl: text("normalized_url").notNull(), + estimatedTraffic: real("estimated_traffic"), + keywordCount: integer("keyword_count"), + topKeyword: text("top_keyword"), + topKeywordPosition: integer("top_keyword_position"), + pageTitle: text("page_title"), + lastSeenAt: timestampColumn("last_seen_at"), + position: integer("position").notNull(), + }, + (table) => [ + index("domain_snapshot_pages_snapshot_idx").on( + table.snapshotId, + table.position, + ), + ], +); + +export const domainRefreshJobs = pgTable( + "domain_refresh_jobs", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + status: text("status", { + enum: ["pending", "running", "succeeded", "failed", "skipped"], + }).notNull(), + trigger: text("trigger", { enum: ["scheduled", "manual"] }).notNull(), + requestedBy: text("requested_by"), + dedupeKey: text("dedupe_key").notNull(), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + skipReason: text("skip_reason"), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + snapshotId: text("snapshot_id"), + createdAt: timestampColumn("created_at").notNull().default(isoNow), + startedAt: timestampColumn("started_at"), + finishedAt: timestampColumn("finished_at"), + }, + (table) => [ + uniqueIndex("domain_refresh_jobs_dedupe_idx").on(table.dedupeKey), + index("domain_refresh_jobs_status_idx").on(table.status, table.createdAt), + index("domain_refresh_jobs_entity_idx").on(table.entityId, table.createdAt), + ], +); + +export const searchUsageLedger = pgTable( + "search_usage_ledger", + { + id: text("id").primaryKey(), + day: text("day").notNull(), + entityId: text("entity_id"), + endpointPath: text("endpoint_path").notNull(), + meteringClass: text("metering_class", { + enum: [ + "paid_submission", + "free_poll", + "result_fetch", + "quota_metered_free", + "cache", + ], + }).notNull(), + requests: integer("requests").notNull().default(0), + meteredRequests: integer("metered_requests").notNull().default(0), + failedRequests: integer("failed_requests").notNull().default(0), + retryRequests: integer("retry_requests").notNull().default(0), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + cacheHits: integer("cache_hits").notNull().default(0), + cacheMisses: integer("cache_misses").notNull().default(0), + blockedByBudget: integer("blocked_by_budget").notNull().default(0), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), + }, + (table) => [ + uniqueIndex("search_usage_ledger_day_endpoint_idx").on( + table.day, + table.endpointPath, + table.meteringClass, + ), + index("search_usage_ledger_day_idx").on(table.day), + ], +); + +export const searchBudgetState = pgTable("search_budget_state", { + month: text("month").primaryKey(), + monthlyCostMicros: integer("monthly_cost_micros").notNull().default(0), + currentDay: text("current_day"), + dailyCostMicros: integer("daily_cost_micros").notNull().default(0), + consecutiveFailures: integer("consecutive_failures").notNull().default(0), + circuitOpenedAt: timestampColumn("circuit_opened_at"), + lastAlertThreshold: integer("last_alert_threshold"), + updatedAt: timestampColumn("updated_at").notNull().default(isoNow), +}); diff --git a/src/db/schema-parity.test.ts b/src/db/schema-parity.test.ts index c62c0b68..bef1a53b 100644 --- a/src/db/schema-parity.test.ts +++ b/src/db/schema-parity.test.ts @@ -11,6 +11,9 @@ import * as sqliteBilling from "./billing.schema"; import * as sqliteGsc from "./gsc.schema"; import * as sqliteReddit from "./reddit-attribution.schema"; import * as sqliteTelemetry from "./telemetry.schema"; +// MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). +import * as sqliteSearchIntelligence from "./search-intelligence.schema"; +import * as sqliteSearchIntelligenceP2 from "./search-intelligence-p2.schema"; import * as pgApp from "./pg/app.schema"; import * as pgSam from "./pg/sam.schema"; import * as pgAuth from "./pg/better-auth-schema"; @@ -18,6 +21,8 @@ import * as pgBilling from "./pg/billing.schema"; import * as pgGsc from "./pg/gsc.schema"; import * as pgReddit from "./pg/reddit-attribution.schema"; import * as pgTelemetry from "./pg/telemetry.schema"; +import * as pgSearchIntelligence from "./pg/search-intelligence.schema"; +import * as pgSearchIntelligenceP2 from "./pg/search-intelligence-p2.schema"; // Guards the ONE structural artifact `db:generate` does not regenerate: the // hand-written Postgres schema. The provider-aware `db`/`@/db/schema` barrel @@ -140,6 +145,8 @@ const sqliteAppTables = tablesFrom( sqliteGsc, sqliteReddit, sqliteTelemetry, + sqliteSearchIntelligence, + sqliteSearchIntelligenceP2, ); const pgAppTables = tablesFrom( pgApp, @@ -148,6 +155,8 @@ const pgAppTables = tablesFrom( pgGsc, pgReddit, pgTelemetry, + pgSearchIntelligence, + pgSearchIntelligenceP2, ); const sqliteAuthTables = tablesFrom(sqliteAuth); const pgAuthTables = tablesFrom(pgAuth); diff --git a/src/db/schema.ts b/src/db/schema.ts index 58af2a08..7b7ad90b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,6 +7,9 @@ import * as sqliteBilling from "./billing.schema"; import * as sqliteGsc from "./gsc.schema"; import * as sqliteReddit from "./reddit-attribution.schema"; import * as sqliteTelemetry from "./telemetry.schema"; +// MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). +import * as sqliteSearchIntelligence from "./search-intelligence.schema"; +import * as sqliteSearchIntelligenceP2 from "./search-intelligence-p2.schema"; import * as pgApp from "./pg/app.schema"; import * as pgAudit from "./pg/audit.schema"; import * as pgSam from "./pg/sam.schema"; @@ -15,6 +18,8 @@ import * as pgBilling from "./pg/billing.schema"; import * as pgGsc from "./pg/gsc.schema"; import * as pgReddit from "./pg/reddit-attribution.schema"; import * as pgTelemetry from "./pg/telemetry.schema"; +import * as pgSearchIntelligence from "./pg/search-intelligence.schema"; +import * as pgSearchIntelligenceP2 from "./pg/search-intelligence-p2.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 @@ -33,7 +38,9 @@ type AppSchema = typeof sqliteApp & typeof sqliteBilling & typeof sqliteGsc & typeof sqliteReddit & - typeof sqliteTelemetry; + typeof sqliteTelemetry & + typeof sqliteSearchIntelligence & + typeof sqliteSearchIntelligenceP2; const runtimeSchema = getDatabaseProvider() === "postgres" @@ -46,6 +53,8 @@ const runtimeSchema = ...pgGsc, ...pgReddit, ...pgTelemetry, + ...pgSearchIntelligence, + ...pgSearchIntelligenceP2, } : { ...sqliteApp, @@ -56,6 +65,8 @@ const runtimeSchema = ...sqliteGsc, ...sqliteReddit, ...sqliteTelemetry, + ...sqliteSearchIntelligence, + ...sqliteSearchIntelligenceP2, }; // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts @@ -93,4 +104,21 @@ export const { gscConnections, redditAttributions, telemetryState, + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). + searchEntities, + domainSnapshots, + domainSnapshotKeywords, + domainSnapshotPages, + domainRefreshJobs, + searchUsageLedger, + searchBudgetState, + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P7). + keywordClusters, + trackedKeywords, + siRankSnapshots, + keywordGapSnapshots, + shareOfSearchSnapshots, + rankingEvents, + rankingJobs, + phase2UsageLedger, } = schema; diff --git a/src/db/search-intelligence-p2.schema.ts b/src/db/search-intelligence-p2.schema.ts new file mode 100644 index 00000000..57747337 --- /dev/null +++ b/src/db/search-intelligence-p2.schema.ts @@ -0,0 +1,349 @@ +import { + sqliteTable, + text, + integer, + real, + uniqueIndex, + index, +} from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; +import { searchEntities } from "./search-intelligence.schema"; + +/** + * Morgana Search Intelligence — phase 2 schema (SQLite/D1). + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Keyword watchlist, rank tracking, keyword gap and Tracked Keyword Share of + * Search. Mirrored in `src/db/pg/search-intelligence-p2.schema.ts`; + * `schema-parity.test.ts` fails the build if the two drift. + * + * Money stays integer micro-USD, as everywhere else. + */ + +export const keywordClusters = sqliteTable( + "keyword_clusters", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull(), + description: text("description"), + /** + * Multiplies a keyword's contribution to Share of Search. A brand cluster + * matters more than a general-education one, and the weight is the honest + * place to say so rather than hiding it inside a score. + */ + weight: real("weight").notNull().default(1), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [uniqueIndex("keyword_clusters_slug_idx").on(table.slug)], +); + +export const trackedKeywords = sqliteTable( + "tracked_keywords", + { + id: text("id").primaryKey(), + keyword: text("keyword").notNull(), + /** Lowercased, whitespace-collapsed, accent-preserving. The match key. */ + normalizedKeyword: text("normalized_keyword").notNull(), + clusterId: text("cluster_id").references(() => keywordClusters.id, { + onDelete: "set null", + }), + priority: text("priority", { + enum: ["critical", "high", "normal", "low"], + }) + .notNull() + .default("normal"), + locationCode: integer("location_code").notNull().default(2380), + languageCode: text("language_code").notNull().default("it"), + /** Phase 2 is desktop only; the column exists so phase 3 needs no migration. */ + device: text("device", { enum: ["desktop", "mobile"] }) + .notNull() + .default("desktop"), + /** Hours between scheduled checks. Derived from priority at creation. */ + trackingFrequencyHours: integer("tracking_frequency_hours") + .notNull() + .default(168), + trackingEnabled: integer("tracking_enabled", { mode: "boolean" }) + .notNull() + .default(true), + alertingEnabled: integer("alerting_enabled", { mode: "boolean" }) + .notNull() + .default(true), + /** Provider search volume, refreshed opportunistically. Null until known. */ + searchVolume: integer("search_volume"), + lastCheckedAt: text("last_checked_at"), + nextCheckAt: text("next_check_at"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + disabledAt: text("disabled_at"), + }, + (table) => [ + // One row per keyword per market. The same keyword in two markets is two + // different tracking problems. + uniqueIndex("tracked_keywords_keyword_market_idx").on( + table.normalizedKeyword, + table.locationCode, + table.languageCode, + table.device, + ), + index("tracked_keywords_due_idx").on( + table.trackingEnabled, + table.nextCheckAt, + ), + index("tracked_keywords_cluster_idx").on(table.clusterId), + ], +); + +/** + * One observed position, for one keyword, for one domain, at one time. + * + * A domain that does not appear is recorded with `isFound = false` and NULL + * positions — never a sentinel like 101. A sentinel silently becomes a real + * number in every average, delta and chart that touches it. + */ +export const siRankSnapshots = sqliteTable( + "si_rank_snapshots", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id") + .notNull() + .references(() => trackedKeywords.id, { onDelete: "cascade" }), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + snapshotAt: text("snapshot_at").notNull(), + /** UTC day bucket, for dedupe and for daily rollups. */ + snapshotDate: text("snapshot_date").notNull(), + locationCode: integer("location_code").notNull(), + languageCode: text("language_code").notNull(), + device: text("device", { enum: ["desktop", "mobile"] }).notNull(), + /** Organic position as a human would say it. The one shown in the UI. */ + rankGroup: integer("rank_group"), + /** Counts ads and SERP features. Kept separate, never conflated. */ + rankAbsolute: integer("rank_absolute"), + rankingUrl: text("ranking_url"), + normalizedRankingUrl: text("normalized_ranking_url"), + isFound: integer("is_found", { mode: "boolean" }).notNull(), + provider: text("provider", { enum: ["dataforseo", "fixture"] }).notNull(), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + /** `keyword|entity|date` — one observation per pair per day. */ + dedupeKey: text("dedupe_key").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("si_rank_snapshots_dedupe_idx").on(table.dedupeKey), + index("si_rank_snapshots_keyword_date_idx").on( + table.trackedKeywordId, + table.snapshotDate, + ), + index("si_rank_snapshots_entity_date_idx").on( + table.entityId, + table.snapshotDate, + ), + ], +); + +/** A computed gap classification for one keyword at one point in time. */ +export const keywordGapSnapshots = sqliteTable( + "keyword_gap_snapshots", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id") + .notNull() + .references(() => trackedKeywords.id, { onDelete: "cascade" }), + snapshotDate: text("snapshot_date").notNull(), + category: text("category", { + enum: [ + "shared", + "primary_only", + "competitor_only", + "missing", + "weak", + "strong", + "new", + "lost", + "improved", + "declined", + ], + }).notNull(), + primaryRank: integer("primary_rank"), + bestCompetitorRank: integer("best_competitor_rank"), + bestCompetitorEntityId: text("best_competitor_entity_id"), + /** Simple, explainable: volume × gap size. Never an opaque composite. */ + opportunityScore: real("opportunity_score"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("keyword_gap_snapshots_dedupe_idx").on( + table.trackedKeywordId, + table.snapshotDate, + ), + index("keyword_gap_snapshots_date_idx").on( + table.snapshotDate, + table.category, + ), + ], +); + +export const shareOfSearchSnapshots = sqliteTable( + "share_of_search_snapshots", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + /** Null means "all clusters". */ + clusterId: text("cluster_id"), + snapshotDate: text("snapshot_date").notNull(), + visibilityScore: real("visibility_score"), + /** 0..1. Null when coverage was too thin to answer honestly. */ + share: real("share"), + status: text("status", { enum: ["ok", "insufficient_data"] }).notNull(), + reason: text("reason"), + keywordsConsidered: integer("keywords_considered").notNull().default(0), + keywordsCovered: integer("keywords_covered").notNull().default(0), + /** Which CTR curve produced this number. Changing the curve changes history. */ + ctrModelVersion: text("ctr_model_version").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("share_of_search_dedupe_idx").on( + table.entityId, + table.clusterId, + table.snapshotDate, + ), + index("share_of_search_date_idx").on(table.snapshotDate), + ], +); + +/** A detected, alert-worthy ranking change. One row per emitted event. */ +export const rankingEvents = sqliteTable( + "ranking_events", + { + id: text("id").primaryKey(), + trackedKeywordId: text("tracked_keyword_id").notNull(), + entityId: text("entity_id").notNull(), + eventType: text("event_type", { + enum: [ + "entered_top_3", + "entered_top_10", + "left_top_10", + "dropped_10_plus", + "gained_10_plus", + "overtaken_by_competitor", + "overtook_competitors", + "critical_keyword_lost", + "share_of_search_shift", + ], + }).notNull(), + previousRank: integer("previous_rank"), + currentRank: integer("current_rank"), + competitorEntityId: text("competitor_entity_id"), + rankingUrl: text("ranking_url"), + detectedAt: text("detected_at").notNull(), + /** `keyword|entity|type|date` — the cooldown and dedupe key. */ + dedupeKey: text("dedupe_key").notNull(), + notifiedAt: text("notified_at"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("ranking_events_dedupe_idx").on(table.dedupeKey), + index("ranking_events_detected_idx").on(table.detectedAt), + ], +); + +export const rankingJobs = sqliteTable( + "ranking_jobs", + { + id: text("id").primaryKey(), + jobType: text("job_type", { + enum: [ + "rank_check", + "keyword_gap_refresh", + "share_of_search_recalculate", + "weekly_digest", + ], + }).notNull(), + trackedKeywordId: text("tracked_keyword_id"), + priority: text("priority", { + enum: ["critical", "high", "normal", "low"], + }) + .notNull() + .default("normal"), + status: text("status", { + enum: ["pending", "running", "succeeded", "failed", "skipped"], + }).notNull(), + scheduledAt: text("scheduled_at").notNull(), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + skipReason: text("skip_reason"), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + dedupeKey: text("dedupe_key").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + uniqueIndex("ranking_jobs_dedupe_idx").on(table.dedupeKey), + index("ranking_jobs_queue_idx").on( + table.status, + table.priority, + table.scheduledAt, + ), + ], +); + +/** + * Phase-2 usage, kept separate from the phase-1 ledger so rank tracking's + * consumption is attributable on its own. + * + * `httpRequests` and `paidTasks` are distinct columns because one DataForSEO + * `task_post` can carry up to 100 keywords: counting HTTP calls would badly + * understate spend, and counting keywords would badly overstate it. + */ +export const phase2UsageLedger = sqliteTable( + "phase2_usage_ledger", + { + id: text("id").primaryKey(), + day: text("day").notNull(), + jobType: text("job_type").notNull(), + httpRequests: integer("http_requests").notNull().default(0), + meteredRequests: integer("metered_requests").notNull().default(0), + paidTasks: integer("paid_tasks").notNull().default(0), + keywordsChecked: integer("keywords_checked").notNull().default(0), + cacheHits: integer("cache_hits").notNull().default(0), + cacheMisses: integer("cache_misses").notNull().default(0), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + blockedByBudget: integer("blocked_by_budget").notNull().default(0), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("phase2_usage_ledger_day_job_idx").on(table.day, table.jobType), + ], +); diff --git a/src/db/search-intelligence.schema.ts b/src/db/search-intelligence.schema.ts new file mode 100644 index 00000000..a205812e --- /dev/null +++ b/src/db/search-intelligence.schema.ts @@ -0,0 +1,319 @@ +import { + sqliteTable, + text, + integer, + real, + uniqueIndex, + index, +} from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +/** + * Morgana Search Intelligence — phase 1 schema (SQLite/D1). + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Mirrored byte-for-structure in `src/db/pg/search-intelligence.schema.ts`; + * `schema-parity.test.ts` fails the build if the two drift. + * + * Deliberately standalone: no FK to `projects` or `organization`. These tables + * describe a domain watch-list that exists independently of OpenSEO's + * multi-tenant project model, which this deployment does not use (nobody can + * log into the engine — it is a private Worker behind a Service Binding). + * Keeping them unscoped also keeps them generic enough to remain in the public + * fork without carrying company structure. + * + * MONEY IS INTEGER MICRO-USD everywhere, matching Morgana's convention + * (decision #3): floats never touch a currency value. + */ + +/** + * The configured domains. Data-driven on purpose — nothing in the application + * hardcodes a competitor, so adding or retiring one is a row change. + */ +export const searchEntities = sqliteTable( + "search_entities", + { + id: text("id").primaryKey(), + /** Human label. Never used as a key — renaming must not orphan history. */ + displayName: text("display_name").notNull(), + /** As entered, for display. */ + canonicalDomain: text("canonical_domain").notNull(), + /** Lowercased, protocol/www/path stripped, IDN→punycode. The match key. */ + normalizedDomain: text("normalized_domain").notNull(), + entityType: text("entity_type", { + enum: ["primary", "competitor", "watch"], + }).notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + priority: text("priority", { enum: ["high", "normal", "low"] }) + .notNull() + .default("normal"), + includeSubdomains: integer("include_subdomains", { mode: "boolean" }) + .notNull() + .default(false), + /** DataForSEO location code. 2380 = Italy. */ + locationCode: integer("location_code").notNull().default(2380), + languageCode: text("language_code").notNull().default("it"), + /** + * How often a scheduled refresh is due, in hours. Held per entity rather + * than derived from `priority` so one domain can be retuned without + * reclassifying it. + */ + refreshIntervalHours: integer("refresh_interval_hours") + .notNull() + .default(24), + /** + * Backlink metrics refresh far less often than organic ones: the backlinks + * summary endpoint costs roughly thirty times the domain rank overview, and + * referring-domain counts move slowly. This is the single biggest lever on + * monthly spend. + */ + backlinkIntervalHours: integer("backlink_interval_hours") + .notNull() + .default(168), + lastRefreshedAt: text("last_refreshed_at"), + lastBacklinkRefreshedAt: text("last_backlink_refreshed_at"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + /** Set instead of deleting: disabling an entity must preserve its history. */ + disabledAt: text("disabled_at"), + }, + (table) => [ + // One entity per domain+market. The same domain may legitimately be tracked + // in two markets, so the market is part of the identity. + uniqueIndex("search_entities_domain_market_idx").on( + table.normalizedDomain, + table.locationCode, + table.languageCode, + ), + index("search_entities_enabled_idx").on(table.enabled, table.entityType), + ], +); + +/** + * One row per domain per market per snapshot period. Metrics are nullable + * throughout: a provider that returns no value must produce `null`, never a + * zero, or every comparison and delta silently becomes a lie. + */ +export const domainSnapshots = sqliteTable( + "domain_snapshots", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + organicTrafficEstimate: real("organic_traffic_estimate"), + organicKeywordCount: integer("organic_keyword_count"), + backlinkCount: integer("backlink_count"), + referringDomainCount: integer("referring_domain_count"), + /** Provider authority/rank signal, whatever the provider exposes. */ + rankSignal: integer("rank_signal"), + locationCode: integer("location_code").notNull(), + languageCode: text("language_code").notNull(), + /** `dataforseo` in production, `fixture` when running without credentials. */ + source: text("source", { enum: ["dataforseo", "fixture"] }).notNull(), + providerRequestId: text("provider_request_id"), + fetchedAt: text("fetched_at").notNull(), + /** UTC date bucket (YYYY-MM-DD) this snapshot represents. */ + snapshotDate: text("snapshot_date").notNull(), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + /** + * `entity|location|language|snapshotDate`. UNIQUE, and the whole reason + * retries, a concurrent manual refresh, a duplicated scheduler tick and a + * provider timeout-after-success cannot produce two snapshots for one day. + */ + dedupeKey: text("dedupe_key").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("domain_snapshots_dedupe_idx").on(table.dedupeKey), + index("domain_snapshots_entity_date_idx").on( + table.entityId, + table.snapshotDate, + ), + ], +); + +/** + * Top organic keywords, attached to the SNAPSHOT rather than the entity, so the + * historical state is reconstructable exactly rather than only the latest. + */ +export const domainSnapshotKeywords = sqliteTable( + "domain_snapshot_keywords", + { + id: text("id").primaryKey(), + snapshotId: text("snapshot_id") + .notNull() + .references(() => domainSnapshots.id, { onDelete: "cascade" }), + keyword: text("keyword").notNull(), + /** + * `rank_group` and `rank_absolute` are kept distinct because they answer + * different questions: rank_group is the organic position a human would + * call "position 3", rank_absolute counts every SERP element including ads + * and features. The UI shows rank_group. + */ + rankGroup: integer("rank_group"), + rankAbsolute: integer("rank_absolute"), + searchVolume: integer("search_volume"), + estimatedTraffic: real("estimated_traffic"), + cpc: real("cpc"), + keywordDifficulty: integer("keyword_difficulty"), + searchIntent: text("search_intent"), + rankingUrl: text("ranking_url"), + serpUpdatedAt: text("serp_updated_at"), + position: integer("position").notNull(), + }, + (table) => [ + index("domain_snapshot_keywords_snapshot_idx").on( + table.snapshotId, + table.position, + ), + ], +); + +/** Top organic pages, likewise attached to the snapshot. */ +export const domainSnapshotPages = sqliteTable( + "domain_snapshot_pages", + { + id: text("id").primaryKey(), + snapshotId: text("snapshot_id") + .notNull() + .references(() => domainSnapshots.id, { onDelete: "cascade" }), + /** As returned by the provider. */ + url: text("url").notNull(), + /** Protocol/www/trailing-slash/fragment/tracking-param stripped. */ + normalizedUrl: text("normalized_url").notNull(), + estimatedTraffic: real("estimated_traffic"), + keywordCount: integer("keyword_count"), + topKeyword: text("top_keyword"), + topKeywordPosition: integer("top_keyword_position"), + pageTitle: text("page_title"), + lastSeenAt: text("last_seen_at"), + position: integer("position").notNull(), + }, + (table) => [ + index("domain_snapshot_pages_snapshot_idx").on( + table.snapshotId, + table.position, + ), + ], +); + +/** + * Refresh jobs. A row is the unit of work AND the concurrency control: the + * partial unique index below is what makes "one active job per entity" true + * even with a scheduler tick and a manual refresh racing. + */ +export const domainRefreshJobs = sqliteTable( + "domain_refresh_jobs", + { + id: text("id").primaryKey(), + entityId: text("entity_id") + .notNull() + .references(() => searchEntities.id, { onDelete: "cascade" }), + status: text("status", { + enum: ["pending", "running", "succeeded", "failed", "skipped"], + }).notNull(), + trigger: text("trigger", { enum: ["scheduled", "manual"] }).notNull(), + /** Opaque actor reference; never an email. */ + requestedBy: text("requested_by"), + /** `entity|snapshotDate|trigger` — collapses duplicate requests. */ + dedupeKey: text("dedupe_key").notNull(), + attempts: integer("attempts").notNull().default(0), + /** Sanitized: never a stack trace or a provider credential. */ + lastError: text("last_error"), + skipReason: text("skip_reason"), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + snapshotId: text("snapshot_id"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + uniqueIndex("domain_refresh_jobs_dedupe_idx").on(table.dedupeKey), + index("domain_refresh_jobs_status_idx").on(table.status, table.createdAt), + index("domain_refresh_jobs_entity_idx").on(table.entityId, table.createdAt), + ], +); + +/** + * Per-day, per-endpoint usage. `requests` counts every call made; only + * `meteredRequests` counts against a cap. + * + * Brand Monitoring learned this the hard way (decision #84): DataForSEO's free + * `task_get` polls were counted as requests and rationed the paid work, burning + * 100% of the request allowance on 22% of the money. Search Intelligence + * separates the two from day one. + */ +export const searchUsageLedger = sqliteTable( + "search_usage_ledger", + { + id: text("id").primaryKey(), + /** UTC day, YYYY-MM-DD. */ + day: text("day").notNull(), + entityId: text("entity_id"), + /** DataForSEO path, joined with '/'. */ + endpointPath: text("endpoint_path").notNull(), + meteringClass: text("metering_class", { + enum: [ + "paid_submission", + "free_poll", + "result_fetch", + "quota_metered_free", + "cache", + ], + }).notNull(), + requests: integer("requests").notNull().default(0), + meteredRequests: integer("metered_requests").notNull().default(0), + failedRequests: integer("failed_requests").notNull().default(0), + retryRequests: integer("retry_requests").notNull().default(0), + estimatedCostMicros: integer("estimated_cost_micros").notNull().default(0), + /** Taken from the provider response, not guessed. */ + actualCostMicros: integer("actual_cost_micros").notNull().default(0), + cacheHits: integer("cache_hits").notNull().default(0), + cacheMisses: integer("cache_misses").notNull().default(0), + blockedByBudget: integer("blocked_by_budget").notNull().default(0), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("search_usage_ledger_day_endpoint_idx").on( + table.day, + table.endpointPath, + table.meteringClass, + ), + index("search_usage_ledger_day_idx").on(table.day), + ], +); + +/** + * Rolled-up budget state per billing month, plus the circuit breaker. Kept + * separate from the ledger so a spend decision is one indexed read rather than + * an aggregate over the month. + */ +export const searchBudgetState = sqliteTable("search_budget_state", { + /** Billing month, YYYY-MM. Primary key: one row per month. */ + month: text("month").primaryKey(), + monthlyCostMicros: integer("monthly_cost_micros").notNull().default(0), + /** Day the daily counter refers to; a different day resets it. */ + currentDay: text("current_day"), + dailyCostMicros: integer("daily_cost_micros").notNull().default(0), + consecutiveFailures: integer("consecutive_failures").notNull().default(0), + circuitOpenedAt: text("circuit_opened_at"), + /** Highest alert threshold already announced this month (70/85/95/100). */ + lastAlertThreshold: integer("last_alert_threshold"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/src/server.ts b/src/server.ts index 4abaf46a..501e8611 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,9 @@ import { handleAutumnWebhookRequest, } from "@/server/billing/autumn-webhook"; import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry"; +// MORGANA LOCAL PATCH (UPSTREAM.md, patch P2/P4). +import { handlePhase0Request } from "@/server/morgana/phase0-routes"; +import { readPhase0Config, isEnabled } from "@/server/morgana/phase0-env"; const appFetch = createStartHandler(defaultStreamHandler); const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch); @@ -133,11 +136,21 @@ function fetch( return withPgClient(() => Promise.resolve(handleFetch(request, env, ctx))); } -function handleFetch( +async function handleFetch( request: Request, env: Env, ctx: ExecutionContext, -): Response | Promise { +): Promise { + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P2). The Phase-0 contract endpoints + // are answered before anything else — including the telemetry heartbeat below + // — so a health probe does no work beyond what it reports on and cannot + // trigger an outbound call. Returns null for every other path, leaving + // upstream routing untouched. + const phase0 = await handlePhase0Request(request, env); + if (phase0) { + return phase0; + } + ctx.waitUntil(maybeSendSelfHostHeartbeat()); const authMode = getAuthMode(env.AUTH_MODE); @@ -164,6 +177,13 @@ function handleFetch( (authMode === "cloudflare_access" || authMode === "local_noauth") && pathname === MCP_ROUTE ) { + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P4). MCP is reachable in + // cloudflare_access mode, not just hosted mode, so "we published no route" + // is not on its own a control — a Service Binding call would still reach + // it. Phase 0 requires MCP off, so it is refused explicitly and by default. + if (!isEnabled(readPhase0Config(env).SEARCH_INTELLIGENCE_MCP_ENABLED)) { + return new Response("Not found", { status: 404 }); + } return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx); } @@ -185,6 +205,23 @@ export default { env: Env, _ctx: ExecutionContext, ) { + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P4). The staging deployment ships + // no cron trigger at all, so this handler should never fire. If it is ever + // invoked anyway — a re-added trigger, a manual `wrangler cron` — scheduled + // SEO collection is still refused. The trigger and this check are two + // independent controls on the same risk. + if (!isEnabled(readPhase0Config(env).SEARCH_INTELLIGENCE_ENABLED)) { + console.log( + JSON.stringify({ + timestamp: new Date().toISOString(), + level: "warn", + service: "morgana-search-intelligence", + event: "scheduled_run_refused", + error_code: "SEARCH_INTELLIGENCE_DISABLED", + }), + ); + return; + } // Scope a per-request Postgres client for the cron run (no-op in D1 mode). await withPgClient(() => runScheduledRankChecks(env)); }, diff --git a/src/server/lib/runtime-env.ts b/src/server/lib/runtime-env.ts index cf8748f1..d35cea60 100644 --- a/src/server/lib/runtime-env.ts +++ b/src/server/lib/runtime-env.ts @@ -20,6 +20,30 @@ export function getEnvValueSync( env: object, name: string, ): string | undefined { + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P3). Morgana Brand Monitoring and + // Morgana Search Intelligence must never share a DataForSEO credential or + // balance, so this deployment reads its own dedicated secret first. The + // upstream name remains the fallback, which keeps unpatched upstream + // behaviour intact and makes the patch a two-line addition rather than a + // rename across 20 call sites. + // + // A DataForSEO subaccount is just a different login:password pair, so + // switching to one is a secret change with no code change. + const aliased = MORGANA_ENV_ALIASES[name]; + if (aliased) { + const aliasValue = readEnvValue(env, aliased); + if (aliasValue) { + return aliasValue; + } + } + return readEnvValue(env, name); +} + +const MORGANA_ENV_ALIASES: Record = { + DATAFORSEO_API_KEY: "DATAFORSEO_SEARCH_INTELLIGENCE_API_KEY", +}; + +function readEnvValue(env: object, name: string): string | undefined { const processValue = typeof process !== "undefined" ? process.env?.[name] : undefined; if (processValue) { diff --git a/src/server/morgana/phase0-cost.ts b/src/server/morgana/phase0-cost.ts new file mode 100644 index 00000000..52b28560 --- /dev/null +++ b/src/server/morgana/phase0-cost.ts @@ -0,0 +1,146 @@ +import { type Phase0Config, isEnabled } from "./phase0-env"; +import { detectMeteredWhileDisabled } from "./phase0-guard"; + +/** + * Morgana Search Intelligence — Phase 0 cost centre. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P4). + * + * `dataforseo_search_intelligence` is a cost centre wholly separate from + * Morgana's `dataforseo_brand_monitoring`. The separation is structural, not + * merely logical: this engine is a different Worker, with a different secret + * store, a different D1 and a different DataForSEO credential. Exhausting the + * credit, revoking the key or tripping the breaker here cannot reach Brand + * Monitoring's code path at all. + * + * Money is integer micro-USD end to end (Morgana decision #3). USD floats + * appear only at the presentation boundary, in `toUsd()`. + */ + +export const SEARCH_INTELLIGENCE_COST_CENTRE = "dataforseo_search_intelligence"; +export const BRAND_MONITORING_COST_CENTRE = "dataforseo_brand_monitoring"; + +type DataForSeoStatus = + | "disabled" + | "not_configured" + | "configured_but_paid_calls_disabled" + | "configured"; + +type CostLedger = { + cost_centre: string; + requests: number; + metered_requests: number; + free_requests: number; + paid_submissions: number; + estimated_cost_usd: number; + actual_cost_usd: number; + daily_cost_cap_usd: number; + monthly_cost_cap_usd: number; + budget_remaining_usd: number; + projected_month_end_cost_usd: number; + cache_hits: number; + cache_misses: number; + blocked_by_budget: number; + /** Critical incident flag: paid calls off, yet something was metered. */ + unexpected_spend_detected: boolean; +}; + +function toUsd(micros: number): number { + return Math.round(micros) / 1_000_000; +} + +/** + * Resolve the DataForSEO configuration state. + * + * `not_configured` is a first-class, non-failing Phase-0 state: the engine's + * dedicated credential is expected to be absent until Phase 1, and its absence + * must not make the engine unready. + */ +export function resolveDataForSeoStatus( + config: Phase0Config, + credentialPresent: boolean, +): DataForSeoStatus { + if (!isEnabled(config.SEARCH_INTELLIGENCE_ENABLED)) { + return "disabled"; + } + if (!credentialPresent) { + return "not_configured"; + } + if (!isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED)) { + return "configured_but_paid_calls_disabled"; + } + return "configured"; +} + +type UsageTotals = { + requests: number; + meteredRequests: number; + paidSubmissions: number; + estimatedCostMicros: number; + actualCostMicros: number; + cacheHits: number; + cacheMisses: number; + blockedByBudget: number; +}; + +export const ZERO_USAGE: UsageTotals = { + requests: 0, + meteredRequests: 0, + paidSubmissions: 0, + estimatedCostMicros: 0, + actualCostMicros: 0, + cacheHits: 0, + cacheMisses: 0, + blockedByBudget: 0, +}; + +/** + * Build the cost-centre ledger. During Phase 0 every usage figure is zero + * because no billable path can execute, but the shape is the real one so + * Morgana's reader and the Phase-1 implementation do not have to change. + * + * `dayOfMonth`/`daysInMonth` drive the projection; with zero spend the + * projection is zero, which is the correct answer rather than a special case. + */ +export function buildCostLedger( + config: Phase0Config, + usage: UsageTotals = ZERO_USAGE, + now: Date = new Date(), +): CostLedger { + const monthlyCapMicros = config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD; + const remainingMicros = Math.max( + 0, + monthlyCapMicros - usage.actualCostMicros, + ); + const dayOfMonth = now.getUTCDate(); + const daysInMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + const projectedMicros = + usage.actualCostMicros === 0 + ? 0 + : Math.round((usage.actualCostMicros / dayOfMonth) * daysInMonth); + + return { + cost_centre: SEARCH_INTELLIGENCE_COST_CENTRE, + requests: usage.requests, + metered_requests: usage.meteredRequests, + // Free calls are tracked separately so they can never ration paid work — + // the failure mode Morgana diagnosed in its own pipeline (decision #84). + free_requests: Math.max(0, usage.requests - usage.meteredRequests), + paid_submissions: usage.paidSubmissions, + estimated_cost_usd: toUsd(usage.estimatedCostMicros), + actual_cost_usd: toUsd(usage.actualCostMicros), + daily_cost_cap_usd: toUsd(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD), + monthly_cost_cap_usd: toUsd(monthlyCapMicros), + budget_remaining_usd: toUsd(remainingMicros), + projected_month_end_cost_usd: toUsd(projectedMicros), + cache_hits: usage.cacheHits, + cache_misses: usage.cacheMisses, + blocked_by_budget: usage.blockedByBudget, + unexpected_spend_detected: detectMeteredWhileDisabled( + config, + usage.meteredRequests, + ), + }; +} diff --git a/src/server/morgana/phase0-env.ts b/src/server/morgana/phase0-env.ts new file mode 100644 index 00000000..2a91bd33 --- /dev/null +++ b/src/server/morgana/phase0-env.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import { getEnvValueSync } from "@/server/lib/runtime-env"; + +/** + * Morgana Search Intelligence — Phase 0 configuration. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P4). This module is additive: no + * upstream file reads it except the three lines patched into `src/server.ts`. + * + * Phase 0 imports no SEO functionality. The engine must be able to boot, + * authenticate, and report health/readiness/status while every paid capability + * is off and both DataForSEO cost caps are hard zero. + */ + +const boolFlag = z.enum(["true", "false"]); + +/** + * Caps are USD *strings* in config and parsed to integer micro-USD here. + * Morgana's own convention is integer micro-USD everywhere precisely so money + * never touches a float (Morgana decision #3); the same rule applies to the + * engine so the two ledgers can be compared without a unit conversion bug. + */ +const usdCapToMicros = z + .string() + .regex(/^\d+(\.\d{1,6})?$/, "cost cap must be a non-negative USD amount") + .transform((value) => Math.round(Number(value) * 1_000_000)); + +export const phase0EnvSchema = z + .object({ + SEARCH_INTELLIGENCE_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_STAGING_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_UI_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_MCP_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_AI_ENABLED: boolFlag.default("false"), + SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED: boolFlag.default("false"), + // Defaults are the transformed output (integer micro-USD), because Zod 4 + // applies `.default()` after the pipe. Zero is the fail-safe value: an + // absent cap must mean "cannot spend", never "unlimited". + SEO_DATAFORSEO_DAILY_COST_CAP_USD: usdCapToMicros.default(0), + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: usdCapToMicros.default(0), + SEARCH_INTELLIGENCE_ENVIRONMENT: z + .enum(["staging", "production"]) + .default("staging"), + SEARCH_INTELLIGENCE_API_VERSION: z.string().min(1).default("2026-08-05"), + ENGINE_UPSTREAM_REPOSITORY: z + .string() + .default("https://github.com/every-app/open-seo"), + ENGINE_UPSTREAM_RELEASE: z.string().default("unknown"), + ENGINE_UPSTREAM_COMMIT: z.string().default("unknown"), + ENGINE_LOCAL_COMMIT: z.string().default("unknown"), + ENGINE_DEPLOYED_AT: z.string().default("unknown"), + }) + .superRefine((cfg, ctx) => { + // The Phase-0 brief is explicit: enabling a paid capability against a zero + // budget must fail the build, not silently spend. Boot is the last place + // this can be caught, and failing closed here means a misconfigured deploy + // is inert rather than expensive. + if (cfg.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED === "true") { + if ( + cfg.SEO_DATAFORSEO_DAILY_COST_CAP_USD === 0 || + cfg.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD === 0 + ) { + ctx.addIssue({ + code: "custom", + message: + "SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED=true requires both " + + "SEO_DATAFORSEO_DAILY_COST_CAP_USD and " + + "SEO_DATAFORSEO_MONTHLY_COST_CAP_USD to be greater than zero", + }); + } + // A paid call is only meaningful once the engine's own credential exists. + // Morgana's Brand Monitoring credential is deliberately NOT accepted here. + if (cfg.SEARCH_INTELLIGENCE_ENABLED === "false") { + ctx.addIssue({ + code: "custom", + message: + "SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED=true requires " + + "SEARCH_INTELLIGENCE_ENABLED=true", + }); + } + } + // A daily cap above the monthly cap is always a configuration error. + if ( + cfg.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD > 0 && + cfg.SEO_DATAFORSEO_DAILY_COST_CAP_USD > + cfg.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD + ) { + ctx.addIssue({ + code: "custom", + message: + "SEO_DATAFORSEO_DAILY_COST_CAP_USD must not exceed " + + "SEO_DATAFORSEO_MONTHLY_COST_CAP_USD", + }); + } + }); + +export type Phase0Config = z.infer; + +const PHASE0_ENV_KEYS = [ + "SEARCH_INTELLIGENCE_ENABLED", + "SEARCH_INTELLIGENCE_STAGING_ENABLED", + "SEARCH_INTELLIGENCE_UI_ENABLED", + "SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED", + "SEARCH_INTELLIGENCE_MCP_ENABLED", + "SEARCH_INTELLIGENCE_AI_ENABLED", + "SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED", + "SEO_DATAFORSEO_DAILY_COST_CAP_USD", + "SEO_DATAFORSEO_MONTHLY_COST_CAP_USD", + "SEARCH_INTELLIGENCE_ENVIRONMENT", + "SEARCH_INTELLIGENCE_API_VERSION", + "ENGINE_UPSTREAM_REPOSITORY", + "ENGINE_UPSTREAM_RELEASE", + "ENGINE_UPSTREAM_COMMIT", + "ENGINE_LOCAL_COMMIT", + "ENGINE_DEPLOYED_AT", +] as const; + +/** + * Parse Phase-0 configuration from a Workers env. Throws on invalid config — + * the caller turns that into a 500 rather than serving a Worker whose spend + * posture is unknown. + */ +export function readPhase0Config(env: object): Phase0Config { + const raw: Record = {}; + for (const key of PHASE0_ENV_KEYS) { + const value = getEnvValueSync(env, key); + if (value !== undefined) { + raw[key] = value; + } + } + return phase0EnvSchema.parse(raw); +} + +export function isEnabled(value: "true" | "false"): boolean { + return value === "true"; +} diff --git a/src/server/morgana/phase0-guard.ts b/src/server/morgana/phase0-guard.ts new file mode 100644 index 00000000..5a767d77 --- /dev/null +++ b/src/server/morgana/phase0-guard.ts @@ -0,0 +1,89 @@ +import { type Phase0Config, isEnabled } from "./phase0-env"; +import { incrementCounter, log } from "./phase0-logging"; + +/** + * Morgana Search Intelligence — Phase 0 paid-call guard. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P4). + * + * Phase 0 must be incapable of spending money. Upstream already meters + * DataForSEO through its own credit system, but that system is designed to + * *allow* calls once credits exist. This guard sits in front of it and refuses + * unconditionally while the Phase-0 posture holds, so the zero-spend guarantee + * does not depend on upstream billing behaviour. + */ + +type PaidCallDecision = { allowed: true } | { allowed: false; reason: string }; + +export const PAID_CALLS_DISABLED_REASON = + "search intelligence paid calls are disabled (phase 0)"; +export const ZERO_CAP_REASON = "search intelligence cost cap is zero (phase 0)"; + +/** + * Decide whether a billable Search Intelligence call may proceed. + * + * The daily and monthly caps are evaluated independently of the flag: a + * deployment that flips the flag but leaves a cap at zero is still blocked, + * which is what makes "caps are zero" a real control rather than documentation. + */ +export function checkPaidCall(config: Phase0Config): PaidCallDecision { + if (!isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED)) { + return { allowed: false, reason: PAID_CALLS_DISABLED_REASON }; + } + if ( + config.SEO_DATAFORSEO_DAILY_COST_CAP_USD === 0 || + config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD === 0 + ) { + return { allowed: false, reason: ZERO_CAP_REASON }; + } + return { allowed: true }; +} + +/** + * Guard a billable operation. Records the block and throws — callers in the + * SEO paths are not expected to handle this gracefully during Phase 0, because + * during Phase 0 they are not supposed to run at all. + */ +export function assertPaidCallAllowed( + config: Phase0Config, + operation: string, +): void { + const decision = checkPaidCall(config); + if (decision.allowed) { + return; + } + incrementCounter("paid_calls_blocked"); + log("warn", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "paid_call_blocked", + request_id: "n/a", + operation, + error_code: "PAID_CALLS_BLOCKED", + reason: decision.reason, + }); + throw new Error( + `Blocked billable operation "${operation}": ${decision.reason}`, + ); +} + +/** + * The Critical staging incident condition from the Phase-0 brief: paid calls + * are supposed to be off, yet something metered a request. Surfaced by + * /internal/status so a smoke test can assert it, and logged at error level. + */ +export function detectMeteredWhileDisabled( + config: Phase0Config, + meteredRequests: number, +): boolean { + const breached = + !isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED) && + meteredRequests > 0; + if (breached) { + log("error", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "paid_calls_disabled_but_metered", + request_id: "n/a", + error_code: "CRITICAL_UNEXPECTED_SPEND", + metered_requests: meteredRequests, + }); + } + return breached; +} diff --git a/src/server/morgana/phase0-logging.ts b/src/server/morgana/phase0-logging.ts new file mode 100644 index 00000000..3fab4c13 --- /dev/null +++ b/src/server/morgana/phase0-logging.ts @@ -0,0 +1,155 @@ +/** + * Morgana Search Intelligence — Phase 0 structured logging and counters. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P4). + * + * Every log line carries the same field set so the engine's output can be + * queried next to Morgana's. Nothing that can carry a credential or an identity + * is ever emitted: this module redacts rather than trusting call sites. + */ + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const PHASE0_COUNTERS = [ + "health_requests", + "health_failures", + "readiness_failures", + "service_binding_requests", + "service_binding_failures", + "auth_failures", + "d1_errors", + "r2_errors", + "kv_errors", + "paid_calls_blocked", + "unexpected_external_calls", +] as const; + +type Phase0Counter = (typeof PHASE0_COUNTERS)[number]; + +/** + * Counters are per-isolate and best-effort: they exist so a smoke test and a + * log query can assert "zero paid calls, zero unexpected external calls" + * without a metrics backend. They are deliberately NOT persisted — Phase 0 + * introduces no new storage. + */ +const counters = new Map(); + +export function incrementCounter(name: Phase0Counter, by = 1): void { + counters.set(name, (counters.get(name) ?? 0) + by); +} + +export function readCounters(): Record { + // Written out rather than folded into an asserted empty object. An assertion + // would claim a shape that is not yet true at the moment it is made; an + // explicit literal is checked by the compiler, so adding a counter to the + // union without adding it here is a build error rather than a silent zero. + const read = (name: Phase0Counter): number => counters.get(name) ?? 0; + return { + health_requests: read("health_requests"), + health_failures: read("health_failures"), + readiness_failures: read("readiness_failures"), + service_binding_requests: read("service_binding_requests"), + service_binding_failures: read("service_binding_failures"), + auth_failures: read("auth_failures"), + d1_errors: read("d1_errors"), + r2_errors: read("r2_errors"), + kv_errors: read("kv_errors"), + paid_calls_blocked: read("paid_calls_blocked"), + unexpected_external_calls: read("unexpected_external_calls"), + }; +} + +/** Test-only: reset counters between cases. */ +export function resetCounters(): void { + counters.clear(); +} + +// Header names that must never reach a log line, in any casing. +const REDACTED_HEADERS = new Set([ + "authorization", + "cookie", + "set-cookie", + "cf-access-jwt-assertion", + "cf-access-client-id", + "cf-access-client-secret", + "x-api-key", + "proxy-authorization", +]); + +const EMAIL_PATTERN = /[\w.+-]+@[\w-]+\.[\w.-]+/g; +// Long opaque tokens: JWTs, base64 credentials, bearer values. +const JWT_PATTERN = /\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g; +// `=` is allowed only as trailing base64 padding, never inside the run. +// Including it in the body would make `key=` match from `key`, which +// both swallows the field label and leaves the `==` padding dangling — the log +// line stops being parseable while the secret is only partly removed. +const LONG_TOKEN_PATTERN = /\b[A-Za-z0-9+/_-]{40,}={0,2}/g; + +/** + * Redact a free-text value. Order matters: JWTs first (they would otherwise be + * partly matched by the generic long-token rule and produce a confusing + * half-redacted string), then emails, then any other long opaque run. + */ +export function redact(value: string): string { + return value + .replace(JWT_PATTERN, "[redacted-jwt]") + .replace(EMAIL_PATTERN, "[redacted-email]") + .replace(LONG_TOKEN_PATTERN, "[redacted]"); +} + +export function redactHeaders(headers: Headers): Record { + const safe: Record = {}; + headers.forEach((value, key) => { + safe[key] = REDACTED_HEADERS.has(key.toLowerCase()) + ? "[redacted]" + : redact(value); + }); + return safe; +} + +type Phase0LogFields = { + event: string; + request_id: string; + trace_id?: string; + latency_ms?: number; + status?: number; + error_code?: string; + [key: string]: unknown; +}; + +export function log( + level: LogLevel, + environment: string, + fields: Phase0LogFields, +): void { + const line: Record = { + timestamp: new Date().toISOString(), + level, + service: "morgana-search-intelligence", + environment, + ...fields, + }; + // Any string value in the payload goes through redaction — a caller cannot + // accidentally log a token by putting it in a custom field. + for (const [key, value] of Object.entries(line)) { + if (typeof value === "string") { + line[key] = redact(value); + } + } + const serialized = JSON.stringify(line); + if (level === "error") { + console.error(serialized); + } else if (level === "warn") { + console.warn(serialized); + } else { + console.log(serialized); + } +} + +/** + * A request id that is stable for the life of one request. Cloudflare's ray id + * is preferred so a log line joins the platform trace; otherwise a random id. + */ +export function requestIdFor(request: Request): string { + return request.headers.get("cf-ray") ?? crypto.randomUUID(); +} diff --git a/src/server/morgana/phase0-routes.ts b/src/server/morgana/phase0-routes.ts new file mode 100644 index 00000000..d542a8f1 --- /dev/null +++ b/src/server/morgana/phase0-routes.ts @@ -0,0 +1,346 @@ +import { getEnvValueSync } from "@/server/lib/runtime-env"; +import { type Phase0Config, isEnabled, readPhase0Config } from "./phase0-env"; +import { buildCostLedger, resolveDataForSeoStatus } from "./phase0-cost"; +import { + incrementCounter, + log, + requestIdFor, + readCounters, +} from "./phase0-logging"; + +/** + * Morgana Search Intelligence — Phase 0 contract endpoints. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P2). + * + * `/healthz`, `/readyz` and `/internal/status` are the entire Phase-0 surface. + * They are the only paths Morgana calls, and they are reachable ONLY over the + * Cloudflare Service Binding: the staging Worker is deployed with no route and + * `workers_dev: false`, so it has no public ingress whatsoever. + * + * They are intentionally handled before upstream's own dispatch — including + * before the self-host telemetry heartbeat — so a health probe performs no work + * beyond what it reports on. + * + * Nothing here emits a token, a secret, a full resource id, a bucket name, a + * user email or a stack trace. + */ + +export const PHASE0_PATHS = new Set([ + "/healthz", + "/readyz", + "/internal/status", +]); + +const STAGING_MARKER = "Morgana Search Intelligence — STAGING"; +const SERVICE_NAME = "morgana-search-intelligence"; + +/** Show enough of an id to correlate, never enough to identify a resource. */ +function idPrefix(value: string | undefined): string { + if (!value) return "unset"; + return `${value.slice(0, 8)}…`; +} + +type CheckStatus = "ok" | "degraded" | "unavailable" | "not_provisioned"; + +type Bindings = { + DB?: { prepare: (query: string) => { first: () => Promise } }; + KV?: { get: (key: string) => Promise }; + R2?: { head: (key: string) => Promise }; +}; + +async function checkD1(env: Bindings): Promise { + if (!env.DB) return "not_provisioned"; + try { + await env.DB.prepare("SELECT 1").first(); + return "ok"; + } catch { + incrementCounter("d1_errors"); + return "unavailable"; + } +} + +/** + * Migrations are considered applied when a table the upstream schema creates is + * queryable. This deliberately probes the schema rather than the migrations + * table: an empty `d1_migrations` with a populated schema, or the reverse, both + * mean "not ready" and only the schema probe catches the second case. + */ +async function checkMigrations(env: Bindings): Promise { + if (!env.DB) return "not_provisioned"; + try { + await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='projects'", + ).first(); + return "ok"; + } catch { + incrementCounter("d1_errors"); + return "unavailable"; + } +} + +async function checkKv(env: Bindings): Promise { + if (!env.KV) return "not_provisioned"; + try { + await env.KV.get("__morgana_readyz_probe"); + return "ok"; + } catch { + incrementCounter("kv_errors"); + return "unavailable"; + } +} + +async function checkR2(env: Bindings): Promise { + if (!env.R2) return "not_provisioned"; + try { + await env.R2.head("__morgana_readyz_probe"); + return "ok"; + } catch { + incrementCounter("r2_errors"); + return "unavailable"; + } +} + +function credentialPresent(env: object): boolean { + return Boolean( + getEnvValueSync(env, "DATAFORSEO_SEARCH_INTELLIGENCE_API_KEY") ?? + getEnvValueSync(env, "DATAFORSEO_API_KEY"), + ); +} + +/** + * Every capability the brief requires to stay off in Phase 0, reported as data + * so a test and a reviewer read the same list. + */ +function capabilities(config: Phase0Config) { + return { + search_intelligence: isEnabled(config.SEARCH_INTELLIGENCE_ENABLED), + ui: isEnabled(config.SEARCH_INTELLIGENCE_UI_ENABLED), + paid_calls: isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED), + mcp: isEnabled(config.SEARCH_INTELLIGENCE_MCP_ENABLED), + ai: isEnabled(config.SEARCH_INTELLIGENCE_AI_ENABLED), + site_audit: isEnabled(config.SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED), + // Phase 0 exposes no SEO capability at all; these are named so the Phase-1 + // reader sees an explicit false rather than an absent key. + domain_overview: false, + keyword_research: false, + rank_tracking: false, + backlinks: false, + scheduled_seo_jobs: false, + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + // The engine renders nothing and is called machine-to-machine only. + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "referrer-policy": "no-referrer", + }, + }); +} + +async function handleReadyz( + config: Phase0Config, + env: object, +): Promise { + const bindings = env as Bindings; + const [database, migrations, kv, r2] = await Promise.all([ + checkD1(bindings), + checkMigrations(bindings), + checkKv(bindings), + checkR2(bindings), + ]); + + const dataforseo = resolveDataForSeoStatus(config, credentialPresent(env)); + const paidCallsOff = !isEnabled( + config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED, + ); + const capsAreZero = + config.SEO_DATAFORSEO_DAILY_COST_CAP_USD === 0 && + config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD === 0; + + const checks = { + database, + migrations, + kv, + r2, + // Auth is "configured" when the engine is fail-closed. Phase 0 runs with + // AUTH_MODE=cloudflare_access and no TEAM_DOMAIN/POLICY_AUD, which makes + // every application route reject — the intended posture, not a fault. + auth: getEnvValueSync(env, "AUTH_MODE") ? "ok" : "degraded", + // The spend posture is part of readiness: a staging engine that could spend + // is not ready, however healthy its bindings are. + spend_posture: paidCallsOff && capsAreZero ? "ok" : "degraded", + dataforseo, + } as const; + + // A missing R2 bucket is a documented, tolerated Phase-0 outcome (bucket + // creation was not guaranteed by the available token scopes), so + // `not_provisioned` degrades rather than fails. DataForSEO never fails + // readiness in Phase 0 by design. + const blocking: CheckStatus[] = [database, migrations, kv]; + const ready = + !blocking.includes("unavailable") && + !blocking.includes("not_provisioned") && + checks.spend_posture === "ok"; + + if (!ready) { + incrementCounter("readiness_failures"); + } + + return jsonResponse( + { + status: ready ? "ready" : "not_ready", + service: SERVICE_NAME, + environment: config.SEARCH_INTELLIGENCE_ENVIRONMENT, + marker: STAGING_MARKER, + checks, + }, + ready ? 200 : 503, + ); +} + +function handleStatus(config: Phase0Config, env: object): Response { + const dataforseo = resolveDataForSeoStatus(config, credentialPresent(env)); + const ledger = buildCostLedger(config); + + return jsonResponse({ + service: SERVICE_NAME, + environment: config.SEARCH_INTELLIGENCE_ENVIRONMENT, + marker: STAGING_MARKER, + status: ledger.unexpected_spend_detected ? "critical" : "ok", + api_version: config.SEARCH_INTELLIGENCE_API_VERSION, + engine_version: config.ENGINE_UPSTREAM_RELEASE, + upstream_repository: config.ENGINE_UPSTREAM_REPOSITORY, + upstream_release: config.ENGINE_UPSTREAM_RELEASE, + upstream_commit: config.ENGINE_UPSTREAM_COMMIT, + local_commit: config.ENGINE_LOCAL_COMMIT, + auth_mode: getEnvValueSync(env, "AUTH_MODE") ?? "unset", + // Truncated on purpose: enough to confirm which database is bound, never + // enough to address it. + database_status: env && "DB" in env ? "bound" : "unbound", + database_id_prefix: idPrefix(getEnvValueSync(env, "ENGINE_D1_ID")), + r2_status: env && "R2" in env ? "bound" : "unbound", + kv_status: env && "KV" in env ? "bound" : "unbound", + dataforseo_status: dataforseo, + paid_calls_enabled: isEnabled( + config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED, + ), + daily_cost_cap_usd: ledger.daily_cost_cap_usd, + monthly_cost_cap_usd: ledger.monthly_cost_cap_usd, + mcp_enabled: isEnabled(config.SEARCH_INTELLIGENCE_MCP_ENABLED), + ai_enabled: isEnabled(config.SEARCH_INTELLIGENCE_AI_ENABLED), + site_audit_enabled: isEnabled( + config.SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED, + ), + capabilities: capabilities(config), + cost: ledger, + counters: readCounters(), + last_deploy_at: config.ENGINE_DEPLOYED_AT, + checked_at: new Date().toISOString(), + }); +} + +/** + * Entry point patched into `src/server.ts`. Returns null for any path that is + * not part of the Phase-0 contract, so upstream routing is untouched. + */ +export async function handlePhase0Request( + request: Request, + env: object, +): Promise { + const pathname = new URL(request.url).pathname; + + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P6). Phase-1 Search Intelligence + // shares this private surface: same "no public ingress, Service Binding only" + // reachability, same fail-closed posture. It is dispatched here rather than + // in `src/server.ts` so the engine has exactly one private entry point. + // + // The import is DYNAMIC on purpose. A static one would pull the Drizzle/D1 + // layer — and therefore `cloudflare:workers` — into this module's eager + // graph, which both breaks the Node-side Phase-0 tests and would make + // `/healthz` load the database stack it is explicitly supposed not to touch. + if (pathname.startsWith("/internal/si/")) { + const { handleSearchIntelligenceRequest } = await import("./si/api"); + const response = await handleSearchIntelligenceRequest(request, env); + if (response) return response; + } + + if (!PHASE0_PATHS.has(pathname)) { + return null; + } + + const started = Date.now(); + const requestId = requestIdFor(request); + incrementCounter("service_binding_requests"); + + let config: Phase0Config; + try { + config = readPhase0Config(env); + } catch { + // Invalid Phase-0 configuration means the spend posture is unknown. Fail + // closed and say nothing about which value was wrong. + incrementCounter("service_binding_failures"); + log("error", "unknown", { + event: "phase0_config_invalid", + request_id: requestId, + status: 500, + error_code: "CONFIG_INVALID", + }); + return jsonResponse( + { status: "error", error: "invalid engine configuration" }, + 500, + ); + } + + if (request.method !== "GET") { + return jsonResponse({ status: "error", error: "method not allowed" }, 405); + } + + try { + let response: Response; + if (pathname === "/healthz") { + incrementCounter("health_requests"); + response = jsonResponse({ + status: "ok", + service: SERVICE_NAME, + environment: config.SEARCH_INTELLIGENCE_ENVIRONMENT, + }); + } else if (pathname === "/readyz") { + response = await handleReadyz(config, env); + } else { + response = handleStatus(config, env); + } + + log("info", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "phase0_request", + request_id: requestId, + trace_id: request.headers.get("cf-ray") ?? undefined, + path: pathname, + status: response.status, + latency_ms: Date.now() - started, + }); + return response; + } catch { + // Never propagate an exception body: it can carry a binding name or a + // driver message. The detail stays in the counter and the log event. + incrementCounter("service_binding_failures"); + if (pathname === "/healthz") { + incrementCounter("health_failures"); + } + log("error", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "phase0_request_failed", + request_id: requestId, + path: pathname, + status: 500, + latency_ms: Date.now() - started, + error_code: "HANDLER_FAILED", + }); + return jsonResponse({ status: "error", error: "internal error" }, 500); + } +} diff --git a/src/server/morgana/phase0.test.ts b/src/server/morgana/phase0.test.ts new file mode 100644 index 00000000..ceafdccd --- /dev/null +++ b/src/server/morgana/phase0.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { phase0EnvSchema, readPhase0Config, isEnabled } from "./phase0-env"; +import { + checkPaidCall, + assertPaidCallAllowed, + detectMeteredWhileDisabled, + PAID_CALLS_DISABLED_REASON, + ZERO_CAP_REASON, +} from "./phase0-guard"; +import { + buildCostLedger, + resolveDataForSeoStatus, + SEARCH_INTELLIGENCE_COST_CENTRE, + BRAND_MONITORING_COST_CENTRE, + ZERO_USAGE, +} from "./phase0-cost"; +import { redact, redactHeaders, resetCounters } from "./phase0-logging"; +import { handlePhase0Request, PHASE0_PATHS } from "./phase0-routes"; + +/** + * Morgana Search Intelligence — Phase 0 tests. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P4). + * + * These assert the guarantees Phase 0 is accountable for: the engine cannot + * spend, cannot leak a credential into a log, cannot serve a capability that + * is supposed to be off, and reports a truthful posture. + */ + +const PHASE0_VARS = { + SEARCH_INTELLIGENCE_ENABLED: "false", + SEARCH_INTELLIGENCE_STAGING_ENABLED: "true", + SEARCH_INTELLIGENCE_UI_ENABLED: "false", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "false", + SEARCH_INTELLIGENCE_MCP_ENABLED: "false", + SEARCH_INTELLIGENCE_AI_ENABLED: "false", + SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED: "false", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "0", + SEARCH_INTELLIGENCE_ENVIRONMENT: "staging", + AUTH_MODE: "cloudflare_access", +}; + +function parse(overrides: Record = {}) { + return phase0EnvSchema.parse({ ...PHASE0_VARS, ...overrides }); +} + +beforeEach(() => { + resetCounters(); + // getEnvValueSync consults process.env first; keep it out of these cases. + vi.unstubAllEnvs(); +}); + +/** + * Parse a JSON body without asserting its shape. The engine returns unknown by + * contract, so the tests read it through an index rather than claiming a type + * the response has not been checked against. + */ +async function readBody( + response: Response | null | undefined, +): Promise> { + const body: unknown = await response?.json(); + return typeof body === "object" && body !== null && !Array.isArray(body) + ? Object.fromEntries(Object.entries(body)) + : {}; +} + +describe("phase 0 configuration", () => { + it("defaults every capability to off", () => { + const config = phase0EnvSchema.parse({}); + expect(isEnabled(config.SEARCH_INTELLIGENCE_ENABLED)).toBe(false); + expect(isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED)).toBe( + false, + ); + expect(isEnabled(config.SEARCH_INTELLIGENCE_MCP_ENABLED)).toBe(false); + expect(isEnabled(config.SEARCH_INTELLIGENCE_AI_ENABLED)).toBe(false); + expect(isEnabled(config.SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED)).toBe( + false, + ); + expect(isEnabled(config.SEARCH_INTELLIGENCE_UI_ENABLED)).toBe(false); + }); + + it("defaults both cost caps to zero, so an absent cap cannot mean unlimited", () => { + const config = phase0EnvSchema.parse({}); + expect(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD).toBe(0); + expect(config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD).toBe(0); + }); + + it("parses USD caps into integer micro-USD without floating point drift", () => { + const config = parse({ + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }); + expect(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD).toBe(200_000); + expect(config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD).toBe(2_000_000); + expect(Number.isInteger(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD)).toBe( + true, + ); + }); + + // This is the brief's "the build must fail if a paid feature is enabled with + // a zero budget" requirement, enforced at the last possible moment. + it("refuses to boot when paid calls are enabled against a zero daily cap", () => { + expect(() => + parse({ + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }), + ).toThrow(/greater than zero/); + }); + + it("refuses to boot when paid calls are enabled against a zero monthly cap", () => { + expect(() => + parse({ + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + }), + ).toThrow(/greater than zero/); + }); + + it("refuses to boot when paid calls are enabled but the engine is not", () => { + expect(() => + parse({ + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }), + ).toThrow(/requires SEARCH_INTELLIGENCE_ENABLED=true/); + }); + + it("rejects a daily cap larger than the monthly cap", () => { + expect(() => + parse({ + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "5", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }), + ).toThrow(/must not exceed/); + }); + + it("rejects a negative or malformed cap rather than coercing it", () => { + expect(() => parse({ SEO_DATAFORSEO_DAILY_COST_CAP_USD: "-1" })).toThrow(); + expect(() => + parse({ SEO_DATAFORSEO_DAILY_COST_CAP_USD: "lots" }), + ).toThrow(); + }); + + it("reads configuration from a Workers-style env object", () => { + const config = readPhase0Config({ ...PHASE0_VARS }); + expect(config.SEARCH_INTELLIGENCE_ENVIRONMENT).toBe("staging"); + expect(config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD).toBe(0); + }); +}); + +describe("paid-call guard", () => { + it("blocks every billable call while paid calls are disabled", () => { + const decision = checkPaidCall(parse()); + expect(decision).toEqual({ + allowed: false, + reason: PAID_CALLS_DISABLED_REASON, + }); + }); + + // The caps are evaluated independently of the flag on purpose: this is what + // makes "caps are zero" a control rather than a comment. + it("still blocks when the flag is on but a cap is zero", () => { + // Bypass schema validation to simulate a config that reached runtime. + const config = { + ...parse(), + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true" as const, + }; + expect(checkPaidCall(config)).toEqual({ + allowed: false, + reason: ZERO_CAP_REASON, + }); + }); + + it("allows a call only when the flag is on and both caps are positive", () => { + const config = parse({ + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }); + expect(checkPaidCall(config)).toEqual({ allowed: true }); + }); + + it("throws and counts a block when a billable operation is attempted", () => { + expect(() => assertPaidCallAllowed(parse(), "serp.task_post")).toThrow( + /Blocked billable operation/, + ); + }); + + it("flags metered requests while paid calls are off as a critical incident", () => { + expect(detectMeteredWhileDisabled(parse(), 0)).toBe(false); + expect(detectMeteredWhileDisabled(parse(), 1)).toBe(true); + }); +}); + +describe("cost centre", () => { + it("is separate from Brand Monitoring", () => { + expect(SEARCH_INTELLIGENCE_COST_CENTRE).toBe( + "dataforseo_search_intelligence", + ); + expect(BRAND_MONITORING_COST_CENTRE).toBe("dataforseo_brand_monitoring"); + expect(SEARCH_INTELLIGENCE_COST_CENTRE).not.toBe( + BRAND_MONITORING_COST_CENTRE, + ); + }); + + it("reports a zeroed ledger with every required field in Phase 0", () => { + const ledger = buildCostLedger(parse()); + expect(ledger).toMatchObject({ + cost_centre: SEARCH_INTELLIGENCE_COST_CENTRE, + requests: 0, + metered_requests: 0, + free_requests: 0, + paid_submissions: 0, + estimated_cost_usd: 0, + actual_cost_usd: 0, + daily_cost_cap_usd: 0, + monthly_cost_cap_usd: 0, + budget_remaining_usd: 0, + projected_month_end_cost_usd: 0, + cache_hits: 0, + cache_misses: 0, + blocked_by_budget: 0, + unexpected_spend_detected: false, + }); + }); + + it("derives free requests as the non-metered remainder", () => { + const ledger = buildCostLedger(parse(), { + ...ZERO_USAGE, + requests: 10, + meteredRequests: 3, + }); + // Free polls must never ration paid work — Morgana decision #84. + expect(ledger.free_requests).toBe(7); + }); + + it("projects month-end spend from the elapsed portion of the month", () => { + const ledger = buildCostLedger( + parse({ + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }), + { ...ZERO_USAGE, actualCostMicros: 500_000 }, + new Date("2026-08-10T00:00:00Z"), + ); + // $0.50 spent over 10 of 31 days projects to $1.55. + expect(ledger.projected_month_end_cost_usd).toBeCloseTo(1.55, 2); + expect(ledger.budget_remaining_usd).toBeCloseTo(1.5, 2); + }); + + it("treats an absent dedicated credential as not_configured, not an error", () => { + const enabled = parse({ SEARCH_INTELLIGENCE_ENABLED: "true" }); + expect(resolveDataForSeoStatus(enabled, false)).toBe("not_configured"); + expect(resolveDataForSeoStatus(enabled, true)).toBe( + "configured_but_paid_calls_disabled", + ); + expect(resolveDataForSeoStatus(parse(), true)).toBe("disabled"); + }); +}); + +describe("log redaction", () => { + it("redacts a JWT", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijkl"; + expect(redact(`token=${jwt}`)).toBe("token=[redacted-jwt]"); + }); + + it("redacts an email address", () => { + expect(redact("user person@example.com denied")).toBe( + "user [redacted-email] denied", + ); + }); + + it("redacts a long opaque token such as a base64 credential", () => { + // A DataForSEO key is base64("login:password"). The fixture is built at + // runtime from obviously fake input rather than pasted as a literal, so no + // string in this repository ever looks like a real credential to a secret + // scanner — the pre-commit hook is right to reject those on sight. + const fakeKey = btoa(`fake-login:${"not-a-real-password".repeat(2)}`); + expect(redact(`key=${fakeKey}`)).toBe("key=[redacted]"); + }); + + it("redacts credential-bearing headers by name", () => { + const headers = new Headers({ + authorization: "Bearer secret-value", + cookie: "CF_Authorization=abc", + "cf-access-jwt-assertion": "assertion", + "content-type": "application/json", + }); + const safe = redactHeaders(headers); + expect(safe.authorization).toBe("[redacted]"); + expect(safe.cookie).toBe("[redacted]"); + expect(safe["cf-access-jwt-assertion"]).toBe("[redacted]"); + // Non-sensitive headers survive, or the log stops being useful. + expect(safe["content-type"]).toBe("application/json"); + }); +}); + +describe("contract endpoints", () => { + const env = { ...PHASE0_VARS }; + + it("serves exactly three paths and nothing else", () => { + expect(Array.from(PHASE0_PATHS).toSorted()).toEqual([ + "/healthz", + "/internal/status", + "/readyz", + ]); + }); + + it("returns null for an unrelated path, leaving upstream routing untouched", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/dashboard"), + env, + ); + expect(response).toBeNull(); + }); + + it("answers /healthz without touching any binding", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/healthz"), + env, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ + status: "ok", + service: "morgana-search-intelligence", + environment: "staging", + }); + }); + + it("reports the Phase-0 posture on /internal/status", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/internal/status"), + { ...env, ENGINE_UPSTREAM_RELEASE: "v0.1.3" }, + ); + const body = await readBody(response); + expect(body.paid_calls_enabled).toBe(false); + expect(body.mcp_enabled).toBe(false); + expect(body.ai_enabled).toBe(false); + expect(body.site_audit_enabled).toBe(false); + expect(body.daily_cost_cap_usd).toBe(0); + expect(body.monthly_cost_cap_usd).toBe(0); + expect(body.dataforseo_status).toBe("disabled"); + expect(body.upstream_release).toBe("v0.1.3"); + expect(body.marker).toBe("Morgana Search Intelligence — STAGING"); + }); + + it("never exposes a secret, a full resource id or an email in the status payload", async () => { + // Built at runtime, never a literal — see the redaction test above. + const fakeKey = btoa("fake-login:fake-password-value"); + const response = await handlePhase0Request( + new Request("https://engine.internal/internal/status"), + { + ...env, + DATAFORSEO_SEARCH_INTELLIGENCE_API_KEY: fakeKey, + ENGINE_D1_ID: "0bce5159-d388-4d0e-8213-aa96803d37f9", + }, + ); + const text = await response?.text(); + expect(text).not.toContain(fakeKey); + // The id is truncated to a correlation prefix, never emitted in full. + expect(text).not.toContain("0bce5159-d388-4d0e-8213-aa96803d37f9"); + expect(text).toContain("0bce5159…"); + }); + + it("rejects a non-GET request", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/healthz", { method: "POST" }), + env, + ); + expect(response?.status).toBe(405); + }); + + it("fails closed with no detail when the spend configuration is invalid", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/healthz"), + { ...env, SEO_DATAFORSEO_DAILY_COST_CAP_USD: "not-a-number" }, + ); + expect(response?.status).toBe(500); + const body = await readBody(response); + expect(body.error).toBe("invalid engine configuration"); + // The failing variable name must not leak to the caller. + expect(JSON.stringify(body)).not.toContain("SEO_DATAFORSEO"); + }); + + it("is not ready when the spend posture is unsafe", async () => { + const response = await handlePhase0Request( + new Request("https://engine.internal/readyz"), + { + ...env, + SEARCH_INTELLIGENCE_ENABLED: "true", + SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED: "true", + SEO_DATAFORSEO_DAILY_COST_CAP_USD: "0.20", + SEO_DATAFORSEO_MONTHLY_COST_CAP_USD: "2", + }, + ); + // Bindings are absent here too, but the point is that a spendable engine + // is never reported ready. + expect(response?.status).toBe(503); + const body = await readBody(response); + const checks = body.checks; + expect(typeof checks === "object" && checks !== null).toBe(true); + expect(Object.fromEntries(Object.entries(checks ?? {})).spend_posture).toBe( + "degraded", + ); + }); +}); diff --git a/src/server/morgana/si/api.ts b/src/server/morgana/si/api.ts new file mode 100644 index 00000000..65b95cc1 --- /dev/null +++ b/src/server/morgana/si/api.ts @@ -0,0 +1,92 @@ +import { isEnabled, readPhase0Config, type Phase0Config } from "../phase0-env"; +import { incrementCounter, log, requestIdFor } from "../phase0-logging"; +import { DomainValidationError } from "./domains"; +import { badRequest, json, SI_PATH_PREFIX } from "./http"; +import { dispatch } from "./router"; +import * as service from "./service"; + +/** + * Morgana Search Intelligence — private API entry point. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Reachable only over Morgana's Service Binding: this Worker has no route and + * `workers_dev: false`, so nothing here is exposed to the internet. Morgana is + * the sole caller and performs all user authentication and authorisation before + * a request arrives — this layer's job is to be a correct, versioned, + * non-leaking data surface, not an auth boundary. + * + * The route table lives in `router.ts`; this file owns the posture around it: + * the feature gate, the logging, and turning any failure into a sanitized + * response. + */ +export async function handleSearchIntelligenceRequest( + request: Request, + env: object, +): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith(SI_PATH_PREFIX)) return null; + + const requestId = requestIdFor(request); + const started = Date.now(); + incrementCounter("service_binding_requests"); + + let config: Phase0Config; + try { + config = readPhase0Config(env); + } catch { + // Invalid configuration means the spend posture is unknown. Fail closed, + // and say nothing about which value was wrong. + incrementCounter("service_binding_failures"); + return json({ error: "invalid engine configuration" }, 500); + } + + // The whole surface is behind the master flag. With it off the engine behaves + // as though Search Intelligence does not exist, which is what makes the + // Morgana-side kill switch total rather than cosmetic. + if (!isEnabled(config.SEARCH_INTELLIGENCE_ENABLED)) { + return json( + { error: "search intelligence is disabled", code: "feature_disabled" }, + 404, + ); + } + + const route = url.pathname.slice(SI_PATH_PREFIX.length); + const providerStatus = service.resolveProviderStatus(config, env); + + try { + const response = await dispatch({ + route, + request, + url, + config, + env, + providerStatus, + }); + log("info", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "si_request", + request_id: requestId, + path: url.pathname, + status: response.status, + latency_ms: Date.now() - started, + }); + return response; + } catch (error) { + incrementCounter("service_binding_failures"); + if (error instanceof DomainValidationError) { + // A validation failure is the caller's, and its message is safe: it was + // authored here, never derived from provider or driver output. + return badRequest(error.code, error.message); + } + log("error", config.SEARCH_INTELLIGENCE_ENVIRONMENT, { + event: "si_request_failed", + request_id: requestId, + path: url.pathname, + status: 500, + latency_ms: Date.now() - started, + error_code: "HANDLER_FAILED", + }); + // Never propagate the cause: it can carry a binding name or driver text. + return json({ error: "internal error" }, 500); + } +} diff --git a/src/server/morgana/si/budget.ts b/src/server/morgana/si/budget.ts new file mode 100644 index 00000000..877a9dd3 --- /dev/null +++ b/src/server/morgana/si/budget.ts @@ -0,0 +1,195 @@ +/** + * Morgana Search Intelligence — budget guard and usage accounting. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Pure decision logic; persistence lives in `ledger.ts`. Money is integer + * micro-USD everywhere (Morgana decision #3). + * + * This is a SECOND, independent budget from Brand Monitoring's. The two share + * no credential, no ledger, no breaker and no code path, so exhausting or + * revoking this one cannot slow brand collection down by a single request. + */ + +const MICROS_PER_USD = 1_000_000; + +/** + * How a call is counted. The distinction exists because Brand Monitoring was + * throttled for weeks by conflating them (decision #84): DataForSEO's free + * `task_get` polls consumed the request allowance and rationed the paid work, + * spending 100% of the request budget on 22% of the money. + */ +export type MeteringClass = + /** A billable call. Counts against requests AND metered_requests. */ + | "paid_submission" + /** Free lifecycle poll. Counts as a request, never as metered. */ + | "free_poll" + /** Free retrieval of an already-billed result. Request only. */ + | "result_fetch" + /** Free of charge but rationed by an independent quota. Counts as metered. */ + | "quota_metered_free" + /** Served from cache. No provider call happened at all. */ + | "cache"; + +/** Whether a class consumes the metered allowance. */ +export function isMetered(meteringClass: MeteringClass): boolean { + return ( + meteringClass === "paid_submission" || + meteringClass === "quota_metered_free" + ); +} + +/** Whether a class represents an actual outbound provider call. */ +export function isProviderCall(meteringClass: MeteringClass): boolean { + return meteringClass !== "cache"; +} + +interface BudgetLimits { + dailyCapMicros: number; + monthlyCapMicros: number; + paidCallsEnabled: boolean; + circuitBreakerThreshold: number; +} + +interface BudgetUsage { + dailyCostMicros: number; + monthlyCostMicros: number; + consecutiveFailures: number; + circuitOpenedAt: string | null; +} + +export type BudgetLevel = + | "ok" + | "warning" + | "degraded" + | "stopping" + | "exhausted"; + +interface BudgetDecision { + allowed: boolean; + level: BudgetLevel; + /** Machine-readable block reason; absent when allowed. */ + reason?: string; + /** Percent of the monthly cap consumed, 0..100+. */ + monthlyPercent: number; +} + +/** §25 thresholds. */ +const WARNING_PERCENT = 70; +const DEGRADED_PERCENT = 85; +const STOP_PERCENT = 95; + +export function levelFor(percent: number): BudgetLevel { + if (percent >= 100) return "exhausted"; + if (percent >= STOP_PERCENT) return "stopping"; + if (percent >= DEGRADED_PERCENT) return "degraded"; + if (percent >= WARNING_PERCENT) return "warning"; + return "ok"; +} + +const CIRCUIT_COOLDOWN_MS = 15 * 60 * 1000; + +/** + * Decide whether a BILLABLE call may proceed. + * + * Reads only — the caller records usage after the fact. Cache reads and history + * queries never come through here: at and beyond the stop threshold the product + * must stay usable, it simply stops buying new data. + */ +export function checkBudget( + limits: BudgetLimits, + usage: BudgetUsage, + now: Date = new Date(), +): BudgetDecision { + const monthlyPercent = + limits.monthlyCapMicros > 0 + ? (usage.monthlyCostMicros / limits.monthlyCapMicros) * 100 + : 100; + + const deny = (reason: string, level?: BudgetLevel): BudgetDecision => ({ + allowed: false, + level: level ?? levelFor(monthlyPercent), + reason, + monthlyPercent, + }); + + if (!limits.paidCallsEnabled) { + return deny("paid_calls_disabled"); + } + // A zero cap means "cannot spend", never "unlimited". Evaluated independently + // of the flag so enabling the flag alone can never authorise spending. + if (limits.monthlyCapMicros <= 0 || limits.dailyCapMicros <= 0) { + return deny("zero_cost_cap"); + } + if (usage.circuitOpenedAt) { + const openedAt = new Date(usage.circuitOpenedAt).getTime(); + if ( + !Number.isNaN(openedAt) && + now.getTime() - openedAt < CIRCUIT_COOLDOWN_MS + ) { + return deny("circuit_open"); + } + } + if (usage.monthlyCostMicros >= limits.monthlyCapMicros) { + return deny("monthly_cap_reached", "exhausted"); + } + if (usage.dailyCostMicros >= limits.dailyCapMicros) { + return deny("daily_cap_reached"); + } + if (monthlyPercent >= STOP_PERCENT) { + // Jobs already billed may finish; no NEW billable submission starts. + return deny("monthly_cap_stop_threshold", "stopping"); + } + + return { allowed: true, level: levelFor(monthlyPercent), monthlyPercent }; +} + +/** + * The Critical condition from §30: spending happened while paid calls were off. + * Its value is that it is checkable from outside — a smoke test can assert it. + */ +export function detectUnexpectedSpend( + paidCallsEnabled: boolean, + meteredRequests: number, + actualCostMicros: number, +): boolean { + return !paidCallsEnabled && (meteredRequests > 0 || actualCostMicros > 0); +} + +/** Straight-line projection of month-end spend from the elapsed portion. */ +export function projectMonthEndMicros( + monthlyCostMicros: number, + now: Date = new Date(), +): number { + if (monthlyCostMicros <= 0) return 0; + const dayOfMonth = now.getUTCDate(); + const daysInMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + return Math.round((monthlyCostMicros / dayOfMonth) * daysInMonth); +} + +/** USD float → integer micro-USD. Provider costs arrive as floats. */ +export function usdToMicros(usd: number): number { + return Math.round(usd * MICROS_PER_USD); +} + +export function microsToUsd(micros: number): number { + return Math.round(micros) / MICROS_PER_USD; +} + +/** + * Alert thresholds crossed, given the previously announced high-water mark. + * Returns at most one threshold so a single tick cannot emit a burst. + */ +export function crossedThreshold( + monthlyPercent: number, + lastAnnounced: number | null, +): number | null { + const ladder = [WARNING_PERCENT, DEGRADED_PERCENT, STOP_PERCENT, 100]; + const reached = ladder.filter((t) => monthlyPercent >= t); + if (reached.length === 0) return null; + const highest = Math.max(...reached); + if (lastAnnounced !== null && highest <= lastAnnounced) return null; + return highest; +} diff --git a/src/server/morgana/si/cost.ts b/src/server/morgana/si/cost.ts new file mode 100644 index 00000000..60909c80 --- /dev/null +++ b/src/server/morgana/si/cost.ts @@ -0,0 +1,117 @@ +import { isEnabled, type Phase0Config } from "../phase0-env"; +import { + crossedThreshold, + detectUnexpectedSpend, + levelFor, + microsToUsd, + projectMonthEndMicros, + type BudgetLevel, +} from "./budget"; +import * as store from "./store"; +import * as ledger from "./ledger-store"; + +function monthOf(date: string): string { + return date.slice(0, 7); +} +import { resolveProviderStatus, type ProviderStatus } from "./service"; + +/** + * Morgana Search Intelligence — cost centre reporting. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Split out of service.ts to stay inside the 400-line module limit. This is the + * READ side of the ledger: what has been spent, what remains, and whether + * anything was spent that should not have been. + */ + +interface CostStatus { + costCentre: string; + providerStatus: ProviderStatus; + paidCallsEnabled: boolean; + level: BudgetLevel; + monthlyPercent: number; + requests: number; + meteredRequests: number; + freeRequests: number; + paidSubmissions: number; + freePollRequests: number; + resultFetchRequests: number; + failedRequests: number; + retryRequests: number; + estimatedCostUsd: number; + actualCostUsd: number; + dailyCostUsd: number; + dailyCostCapUsd: number; + monthlyCostCapUsd: number; + budgetRemainingUsd: number; + projectedMonthEndCostUsd: number; + cacheHits: number; + cacheMisses: number; + cacheHitRate: number | null; + blockedByBudget: number; + unexpectedSpendDetected: boolean; + pendingAlertThreshold: number | null; +} + +export async function costStatus( + config: Phase0Config, + env: object, + now: Date = new Date(), +): Promise { + const day = store.snapshotDateFor(now); + const month = monthOf(day); + const [totals, dayTotals, state] = await Promise.all([ + ledger.ledgerTotals(month), + ledger.ledgerTotals(day), + ledger.readBudgetState(month), + ]); + const monthlyCap = config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD; + const monthlyPercent = + monthlyCap > 0 ? (totals.actualCostMicros / monthlyCap) * 100 : 0; + const paidCallsEnabled = isEnabled( + config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED, + ); + const cacheTotal = totals.cacheHits + totals.cacheMisses; + + return { + costCentre: "dataforseo_search_intelligence", + providerStatus: resolveProviderStatus(config, env), + paidCallsEnabled, + level: levelFor(monthlyPercent), + monthlyPercent, + requests: totals.requests, + meteredRequests: totals.meteredRequests, + // Free calls are reported separately and never ration paid work. + freeRequests: Math.max(0, totals.requests - totals.meteredRequests), + paidSubmissions: totals.paidSubmissions, + freePollRequests: totals.freePollRequests, + resultFetchRequests: totals.resultFetchRequests, + failedRequests: totals.failedRequests, + retryRequests: totals.retryRequests, + estimatedCostUsd: microsToUsd(totals.estimatedCostMicros), + actualCostUsd: microsToUsd(totals.actualCostMicros), + dailyCostUsd: microsToUsd(dayTotals.actualCostMicros), + dailyCostCapUsd: microsToUsd(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD), + monthlyCostCapUsd: microsToUsd(monthlyCap), + budgetRemainingUsd: microsToUsd( + Math.max(0, monthlyCap - totals.actualCostMicros), + ), + projectedMonthEndCostUsd: microsToUsd( + projectMonthEndMicros(totals.actualCostMicros, now), + ), + cacheHits: totals.cacheHits, + cacheMisses: totals.cacheMisses, + cacheHitRate: cacheTotal > 0 ? totals.cacheHits / cacheTotal : null, + blockedByBudget: totals.blockedByBudget, + unexpectedSpendDetected: detectUnexpectedSpend( + paidCallsEnabled, + totals.meteredRequests, + totals.actualCostMicros, + ), + pendingAlertThreshold: crossedThreshold( + monthlyPercent, + state.lastAlertThreshold, + ), + }; +} diff --git a/src/server/morgana/si/domains.ts b/src/server/morgana/si/domains.ts new file mode 100644 index 00000000..275393cb --- /dev/null +++ b/src/server/morgana/si/domains.ts @@ -0,0 +1,211 @@ +import { normalizeDomainInput } from "@/server/lib/domainUtils"; +import { isValidDomainHost } from "@/types/schemas/domain"; + +/** + * Morgana Search Intelligence — strict domain input validation. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Upstream's `normalizeDomainInput` is permissive by design: it accepts any + * `scheme://` prefix and silently drops URL credentials, because it is fed by a + * product form where being forgiving is a feature. Search Intelligence accepts + * domains from an admin API, so it must REJECT what upstream forgives rather + * than quietly normalise it away — a caller who typed `user:pass@host` or + * `file://` has made an error worth surfacing, and silently accepting it is how + * an SSRF-shaped input becomes a stored configuration. + * + * This wraps upstream rather than replacing it, so the tldts-backed + * registrable-domain and fake-TLD logic stays in one place. + */ + +export class DomainValidationError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = "DomainValidationError"; + } +} + +interface NormalizedDomain { + /** What the operator typed, trimmed. Shown in the UI. */ + display: string; + /** Lowercased, ASCII/punycode, no protocol, no www, no path. The match key. */ + normalized: string; + /** + * True when the normalised host contains a punycode (`xn--`) label. + * + * The stored and displayed value stays ASCII punycode on purpose. Rendering + * the Unicode form is exactly what makes a homograph attack work — `аpple.com` + * with a Cyrillic а is indistinguishable from `apple.com` — so the mitigation + * is to show the punycode and flag it, not to prettify it. + */ + isInternationalized: boolean; +} + +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +// Hosts that must never be reachable targets even if some registry made them +// resolvable. `isValidDomainHost` already rejects bare IPs and suffix-less +// names; this is the belt to that braces. +const BLOCKED_HOSTS = new Set([ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", +]); + +const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".onion"]; + +/** + * Validate and normalise a domain for use as a Search Intelligence entity. + * + * Rejects (§35): credentials in the URL, non-HTTP(S) schemes, IP literals, + * loopback/private/link-local names, paths where a bare domain is required, + * malformed hosts, and unregistrable suffixes. + */ +export function normalizeEntityDomain( + input: string, + options: { includeSubdomains?: boolean } = {}, +): NormalizedDomain { + const display = input.trim(); + if (!display) { + throw new DomainValidationError("Domain is required", "domain_required"); + } + if (display.length > 253) { + throw new DomainValidationError("Domain is too long", "domain_too_long"); + } + + // Credentials must be refused before parsing: `new URL()` accepts them and + // exposes only `hostname`, so a later check would never see them. + if ( + /^[a-z][a-z\d+.-]*:\/\/[^/@]*@/i.test(display) || + /^[^/]*@/.test(display) + ) { + throw new DomainValidationError( + "Domain must not contain credentials", + "domain_has_credentials", + ); + } + + const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(display); + if (hasScheme) { + const scheme = /^([a-z][a-z\d+.-]*:)\/\//i + .exec(display)?.[1] + ?.toLowerCase(); + if (!scheme || !ALLOWED_PROTOCOLS.has(scheme)) { + throw new DomainValidationError( + "Only http and https are supported", + "domain_bad_scheme", + ); + } + } else if (display.includes("/")) { + // A bare domain with a path is ambiguous: the caller may have meant a page. + // Refuse rather than guess which part is the domain. + throw new DomainValidationError( + "Enter a domain, not a URL path", + "domain_has_path", + ); + } + + // Upstream does protocol stripping, www removal, IP and fake-TLD rejection + // and the registrable-domain reduction. Reuse it; do not reimplement. + let normalized: string; + try { + normalized = normalizeDomainInput( + display, + options.includeSubdomains ?? false, + ); + } catch (error) { + throw new DomainValidationError( + error instanceof Error && error.message + ? error.message + : "Domain is invalid", + "domain_invalid", + ); + } + + if (BLOCKED_HOSTS.has(normalized)) { + throw new DomainValidationError( + "Loopback hosts are not valid targets", + "domain_loopback", + ); + } + if (BLOCKED_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) { + throw new DomainValidationError( + "Private and non-public suffixes are not valid targets", + "domain_private_suffix", + ); + } + // Re-assert after normalisation: the reduction to a registrable domain can + // in principle change what we are validating. + if (!isValidDomainHost(normalized)) { + throw new DomainValidationError( + "Enter a valid domain like example.com", + "domain_invalid", + ); + } + + const isInternationalized = normalized + .split(".") + .some((label) => label.startsWith("xn--")); + + return { display, normalized, isInternationalized }; +} + +/** + * Canonicalise a page URL for storage and comparison: strip the fragment, + * lowercase the host, drop `www.`, remove tracking parameters and collapse a + * trailing slash. The original is always stored alongside. + */ +const TRACKING_PARAMS = new Set([ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "utm_id", + "gclid", + "fbclid", + "msclkid", + "mc_cid", + "mc_eid", + "ref", + "referrer", + "source", +]); + +export function normalizePageUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + const withProtocol = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(withProtocol); + } catch { + // Unparseable input is returned as-is rather than dropped: the caller + // stores it next to the original, and losing the row would lose data. + return trimmed.toLowerCase(); + } + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + return trimmed.toLowerCase(); + } + parsed.hash = ""; + parsed.hostname = parsed.hostname.toLowerCase().replace(/^www\./, ""); + for (const key of Array.from(parsed.searchParams.keys())) { + if (TRACKING_PARAMS.has(key.toLowerCase())) { + parsed.searchParams.delete(key); + } + } + // The root path collapses to nothing, not to "/". Leaving the slash would + // make `example.com/` and `example.com` two different keys for one page. + let path = parsed.pathname; + while (path.endsWith("/")) { + path = path.slice(0, -1); + } + const query = parsed.searchParams.toString(); + return `${parsed.hostname}${path}${query ? `?${query}` : ""}`; +} diff --git a/src/server/morgana/si/events.ts b/src/server/morgana/si/events.ts new file mode 100644 index 00000000..5b8d0208 --- /dev/null +++ b/src/server/morgana/si/events.ts @@ -0,0 +1,245 @@ +import type { Observation } from "./gap"; +import type { Priority as KeywordPriority } from "./keywords"; + +/** + * Morgana Search Intelligence — ranking event detection. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Pure. The hard part of ranking alerts is not detecting change, it is not + * crying wolf: SERP positions oscillate by a place or two constantly, and an + * alerting system that reports that noise gets muted within a week and prior + * misses the one thing that mattered. + * + * So: gains and entries fire immediately (they are good news and rarely + * spurious), losses require TWO consecutive observations before they are + * announced, and everything is deduplicated per keyword/entity/type/day. + */ + +export type RankingEventType = + | "entered_top_3" + | "entered_top_10" + | "left_top_10" + | "dropped_10_plus" + | "gained_10_plus" + | "overtaken_by_competitor" + | "overtook_competitors" + | "critical_keyword_lost" + | "share_of_search_shift"; + +export interface RankingEvent { + eventType: RankingEventType; + trackedKeywordId: string; + entityId: string; + previousRank: number | null; + currentRank: number | null; + competitorEntityId?: string | null; + rankingUrl?: string | null; + dedupeKey: string; +} + +interface DetectInput { + trackedKeywordId: string; + primaryEntityId: string; + priority: KeywordPriority; + snapshotDate: string; + current: readonly Observation[]; + /** The immediately preceding observation set. */ + previous?: readonly Observation[]; + /** The one before that — used to confirm a sustained loss. */ + beforePrevious?: readonly Observation[]; +} + +const rankOf = ( + observations: readonly Observation[] | undefined, + entityId: string, +): number | null => { + const o = observations?.find((x) => x.entityId === entityId); + return o && o.isFound && o.rankGroup !== null ? o.rankGroup : null; +}; + +function bestCompetitorRank( + observations: readonly Observation[] | undefined, + primaryEntityId: string, +): { entityId: string; rank: number } | null { + let best: { entityId: string; rank: number } | null = null; + for (const o of observations ?? []) { + if (o.entityId === primaryEntityId) continue; + if (!o.isFound || o.rankGroup === null) continue; + if (!best || o.rankGroup < best.rank) { + best = { entityId: o.entityId, rank: o.rankGroup }; + } + } + return best; +} + +const MOVEMENT_THRESHOLD = 10; + +/** + * Detect the alert-worthy changes for one keyword. + * + * Returns at most a handful of events; the caller persists them against a + * UNIQUE dedupe key, so re-running detection on the same day is a no-op. + */ +export function detectRankingEvents(input: DetectInput): RankingEvent[] { + const { + trackedKeywordId, + primaryEntityId, + priority, + snapshotDate, + current, + previous, + beforePrevious, + } = input; + + const now = rankOf(current, primaryEntityId); + const prior = rankOf(previous, primaryEntityId); + const before = rankOf(beforePrevious, primaryEntityId); + const events: RankingEvent[] = []; + + const url = + current.find((o) => o.entityId === primaryEntityId)?.rankingUrl ?? null; + + const push = ( + eventType: RankingEventType, + extra: Partial = {}, + ) => { + events.push({ + eventType, + trackedKeywordId, + entityId: primaryEntityId, + previousRank: prior, + currentRank: now, + rankingUrl: url, + dedupeKey: `${trackedKeywordId}|${primaryEntityId}|${eventType}|${snapshotDate}`, + ...extra, + }); + }; + + // No prior observation: nothing is a "change" yet. + if (previous === undefined) return events; + + // --- good news, announced immediately ----------------------------------- + if (now !== null && now <= 3 && (prior === null || prior > 3)) { + push("entered_top_3"); + } else if (now !== null && now <= 10 && (prior === null || prior > 10)) { + // Only when it is not already reported as a top-3 entry, so one move does + // not produce two notifications. + push("entered_top_10"); + } + if (prior !== null && now !== null && prior - now >= MOVEMENT_THRESHOLD) { + push("gained_10_plus"); + } + + for (const type of detectLosses({ now, prior, before, priority })) { + push(type); + } + for (const found of detectCompetitive({ + priority, + primaryEntityId, + current, + previous, + now, + prior, + })) { + push(found.type, { competitorEntityId: found.competitorEntityId }); + } + + return events; +} + +/** + * Losses, which require confirmation. + * + * A single bad observation is very often SERP noise. Requiring the observation + * BEFORE the previous one to have been healthy means we announce a sustained + * loss rather than a flicker — that is the whole anti-noise mechanism. + */ +function detectLosses(input: { + now: number | null; + prior: number | null; + before: number | null; + priority: KeywordPriority; +}): RankingEventType[] { + const { now, prior, before, priority } = input; + if (before === null || before > 10) return []; + const types: RankingEventType[] = []; + + if (now === null && prior !== null) { + types.push( + priority === "critical" ? "critical_keyword_lost" : "left_top_10", + ); + } else if (now !== null && now > 10 && prior !== null && prior <= 10) { + types.push("left_top_10"); + } + if (prior !== null && now !== null && now - prior >= MOVEMENT_THRESHOLD) { + types.push("dropped_10_plus"); + } + return types; +} + +/** + * Competitive position, critical keywords only. + * + * A competitor passing us on a long-tail term is not worth a notification, and + * reporting it would drown out the ones that are. + */ +function detectCompetitive(input: { + priority: KeywordPriority; + primaryEntityId: string; + current: readonly Observation[]; + previous: readonly Observation[] | undefined; + now: number | null; + prior: number | null; +}): { type: RankingEventType; competitorEntityId: string }[] { + const { priority, primaryEntityId, current, previous, now, prior } = input; + if (priority !== "critical") return []; + + const competitorNow = bestCompetitorRank(current, primaryEntityId); + const competitorThen = bestCompetitorRank(previous, primaryEntityId); + const weLedBefore = + prior !== null && (competitorThen === null || prior < competitorThen.rank); + const weLeadNow = + now !== null && (competitorNow === null || now < competitorNow.rank); + + if (weLedBefore && !weLeadNow && competitorNow) { + return [ + { + type: "overtaken_by_competitor", + competitorEntityId: competitorNow.entityId, + }, + ]; + } + if (!weLedBefore && weLeadNow && competitorThen) { + return [ + { + type: "overtook_competitors", + competitorEntityId: competitorThen.entityId, + }, + ]; + } + return []; +} + +/** Share of Search moves are announced only past a meaningful delta (§10). */ +const SHARE_SHIFT_THRESHOLD = 0.03; + +export function detectShareShift(input: { + entityId: string; + previousShare: number | null; + currentShare: number | null; + snapshotDate: string; +}): RankingEvent | null { + const { entityId, previousShare, currentShare, snapshotDate } = input; + if (previousShare === null || currentShare === null) return null; + if (Math.abs(currentShare - previousShare) < SHARE_SHIFT_THRESHOLD) + return null; + return { + eventType: "share_of_search_shift", + trackedKeywordId: "", + entityId, + previousRank: null, + currentRank: null, + dedupeKey: `share|${entityId}|share_of_search_shift|${snapshotDate}`, + }; +} diff --git a/src/server/morgana/si/fixtures.ts b/src/server/morgana/si/fixtures.ts new file mode 100644 index 00000000..6620e0cf --- /dev/null +++ b/src/server/morgana/si/fixtures.ts @@ -0,0 +1,180 @@ +/** + * Morgana Search Intelligence — deterministic fixture provider. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Phase 1 ships without a DataForSEO credential for Search Intelligence, and + * DataForSEO has no usable sandbox (`API_BASE` is a hardcoded constant and the + * sandbox would need credentials anyway). This provider stands in so the whole + * pipeline — collector, snapshots, deltas, comparison, UI, export — can be + * built and exercised end to end without spending anything. + * + * Two properties matter: + * 1. **Deterministic.** Values derive from a hash of the domain and date, so a + * test asserts a stable number and a UI screenshot does not churn. + * 2. **Unmistakable.** Every snapshot it produces is stored with + * `source: "fixture"`, and the API refuses to serve fixture data when paid + * calls are enabled. Fixture data must never be mistaken for measurement. + */ + +interface FixtureOverview { + organicTrafficEstimate: number | null; + organicKeywordCount: number | null; + backlinkCount: number | null; + referringDomainCount: number | null; + rankSignal: number | null; +} + +interface FixtureKeyword { + keyword: string; + rankGroup: number; + rankAbsolute: number; + searchVolume: number; + estimatedTraffic: number; + cpc: number; + keywordDifficulty: number; + searchIntent: string; + rankingUrl: string; +} + +interface FixturePage { + url: string; + estimatedTraffic: number; + keywordCount: number; + topKeyword: string; + topKeywordPosition: number; + pageTitle: string; +} + +const SEARCH_INTENTS = [ + "informational", + "commercial", + "transactional", + "navigational", +] as const; + +/** Stable 32-bit hash. Not cryptographic — determinism is the only goal. */ +function hash(input: string): number { + let h = 2166136261; + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return Math.abs(h); +} + +/** Deterministic value in [min, max]. */ +function between(seed: string, min: number, max: number): number { + return min + (hash(seed) % Math.max(1, max - min + 1)); +} + +/** + * A domain with no data is a real and important case — the UI must render it as + * "not available" rather than zero, and the visibility share must refuse to + * compute. `example-nodata.com` reproduces it on demand. + */ +const NO_DATA_DOMAINS = new Set(["example-nodata.com", "nodata.test"]); + +export function fixtureOverview( + domain: string, + snapshotDate: string, +): FixtureOverview { + if (NO_DATA_DOMAINS.has(domain)) { + return { + organicTrafficEstimate: null, + organicKeywordCount: null, + backlinkCount: null, + referringDomainCount: null, + rankSignal: null, + }; + } + const seed = `${domain}|${snapshotDate}`; + // Gentle day-over-day drift so deltas and trend charts have something real + // to show, rather than a flat line that hides a broken delta computation. + const drift = (between(`${seed}|drift`, 0, 200) - 100) / 1000; + const baseTraffic = between(domain, 500, 250_000); + return { + organicTrafficEstimate: Math.round(baseTraffic * (1 + drift)), + organicKeywordCount: between(`${domain}|kw`, 100, 40_000), + backlinkCount: between(`${domain}|bl`, 200, 900_000), + referringDomainCount: between(`${domain}|rd`, 50, 12_000), + rankSignal: between(`${domain}|rank`, 1, 1000), + }; +} + +export function fixtureKeywords( + domain: string, + snapshotDate: string, + limit: number, +): FixtureKeyword[] { + if (NO_DATA_DOMAINS.has(domain)) return []; + const label = domain.split(".")[0] ?? domain; + const stems = [ + "bitcoin", + "custodia bitcoin", + "comprare bitcoin", + "wallet bitcoin", + "exchange criptovalute", + "criptovalute", + "bitcoin italia", + "investire in bitcoin", + "quotazione bitcoin", + "sicurezza bitcoin", + ]; + const out: FixtureKeyword[] = []; + for (let i = 0; i < limit; i += 1) { + const stem = stems[i % stems.length] ?? "bitcoin"; + const keyword = i < stems.length ? stem : `${stem} ${label} ${i}`; + const seed = `${domain}|${snapshotDate}|${keyword}`; + const rankGroup = between(seed, 1, 60); + out.push({ + keyword, + rankGroup, + // rank_absolute counts ads and SERP features, so it is >= rank_group. + rankAbsolute: rankGroup + between(`${seed}|abs`, 0, 4), + searchVolume: between(`${seed}|vol`, 10, 90_000), + estimatedTraffic: between(`${seed}|etv`, 0, 5_000), + cpc: between(`${seed}|cpc`, 0, 900) / 100, + keywordDifficulty: between(`${seed}|kd`, 1, 100), + searchIntent: + SEARCH_INTENTS[between(`${seed}|intent`, 0, 3)] ?? "informational", + rankingUrl: `https://${domain}/${keyword.replace(/\s+/g, "-")}`, + }); + } + return out.toSorted((a, b) => b.estimatedTraffic - a.estimatedTraffic); +} + +export function fixturePages( + domain: string, + snapshotDate: string, + limit: number, +): FixturePage[] { + if (NO_DATA_DOMAINS.has(domain)) return []; + const slugs = [ + "", + "blog", + "prezzi", + "chi-siamo", + "custodia", + "sicurezza", + "faq", + "contatti", + "guide/bitcoin", + "guide/wallet", + ]; + const out: FixturePage[] = []; + for (let i = 0; i < limit; i += 1) { + const slug = slugs[i % slugs.length] ?? `page-${String(i)}`; + const path = i < slugs.length ? slug : `${slug}-${String(i)}`; + const seed = `${domain}|${snapshotDate}|${path}`; + out.push({ + url: `https://${domain}/${path}`, + estimatedTraffic: between(seed, 0, 20_000), + keywordCount: between(`${seed}|kw`, 1, 900), + topKeyword: `bitcoin ${path || "home"}`, + topKeywordPosition: between(`${seed}|pos`, 1, 40), + pageTitle: `${path || "Home"} — ${domain}`, + }); + } + return out.toSorted((a, b) => b.estimatedTraffic - a.estimatedTraffic); +} diff --git a/src/server/morgana/si/gap.ts b/src/server/morgana/si/gap.ts new file mode 100644 index 00000000..ee53f02c --- /dev/null +++ b/src/server/morgana/si/gap.ts @@ -0,0 +1,304 @@ +/** + * Morgana Search Intelligence — keyword gap and Tracked Keyword Share of Search. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Pure. Two rules govern this file: + * - **A domain that does not rank has a NULL position, never 101.** A sentinel + * becomes a real number in the next average or chart that touches it. + * - **Nothing opaque.** The opportunity score is volume × gap size, and Share + * of Search is a weighted sum divided by a total. Both are explainable to a + * marketer in one sentence, which is the point. + */ + +export type GapCategoryName = + | "shared" + | "primary_only" + | "competitor_only" + | "missing" + | "weak" + | "strong" + | "new" + | "lost" + | "improved" + | "declined"; + +type GapCategory = + | "shared" + | "primary_only" + | "competitor_only" + | "missing" + | "weak" + | "strong" + | "new" + | "lost" + | "improved" + | "declined"; + +/** One domain's observed position for one keyword. Null means "did not rank". */ +export interface Observation { + entityId: string; + rankGroup: number | null; + isFound: boolean; + rankingUrl?: string | null; +} + +interface GapInput { + primaryEntityId: string; + current: readonly Observation[]; + previous?: readonly Observation[]; + searchVolume?: number | null; + /** Positions below the best competitor before a keyword counts as weak. */ + weakThreshold?: number; + /** Position change before a keyword counts as improved or declined. */ + movementThreshold?: number; +} + +interface GapResult { + category: GapCategory; + primaryRank: number | null; + bestCompetitorRank: number | null; + bestCompetitorEntityId: string | null; + /** Null when volume is unknown — never a zero standing in for "no idea". */ + opportunityScore: number | null; +} + +const rankOf = (o: Observation | undefined): number | null => + o && o.isFound && o.rankGroup !== null ? o.rankGroup : null; + +function bestCompetitor( + observations: readonly Observation[], + primaryEntityId: string, +): { entityId: string; rank: number } | null { + let best: { entityId: string; rank: number } | null = null; + for (const o of observations) { + if (o.entityId === primaryEntityId) continue; + const rank = rankOf(o); + if (rank === null) continue; + if (!best || rank < best.rank) best = { entityId: o.entityId, rank }; + } + return best; +} + +/** + * Classify one keyword. + * + * Movement categories (`new`, `lost`, `improved`, `declined`) take precedence + * over positional ones, because a change is the more actionable fact: knowing a + * keyword is `shared` matters less than knowing it was lost yesterday. + */ +export function classifyGap(input: GapInput): GapResult { + const weakThreshold = input.weakThreshold ?? 10; + const movementThreshold = input.movementThreshold ?? 10; + + const primary = input.current.find( + (o) => o.entityId === input.primaryEntityId, + ); + const primaryRank = rankOf(primary); + const competitor = bestCompetitor(input.current, input.primaryEntityId); + + const base = { + primaryRank, + bestCompetitorRank: competitor?.rank ?? null, + bestCompetitorEntityId: competitor?.entityId ?? null, + opportunityScore: opportunityScore( + input.searchVolume ?? null, + primaryRank, + competitor?.rank ?? null, + ), + }; + + if (input.previous) { + const previousPrimary = rankOf( + input.previous.find((o) => o.entityId === input.primaryEntityId), + ); + if (previousPrimary === null && primaryRank !== null) { + return { ...base, category: "new" }; + } + if (previousPrimary !== null && primaryRank === null) { + return { ...base, category: "lost" }; + } + if (previousPrimary !== null && primaryRank !== null) { + // A smaller rank number is a better position, so a drop in the number is + // an improvement. + const movement = previousPrimary - primaryRank; + if (movement >= movementThreshold) + return { ...base, category: "improved" }; + if (-movement >= movementThreshold) + return { ...base, category: "declined" }; + } + } + + if (primaryRank === null && competitor === null) { + return { ...base, category: "missing" }; + } + if (primaryRank === null) { + return { ...base, category: "competitor_only" }; + } + if (competitor === null) { + return { ...base, category: "primary_only" }; + } + if (primaryRank < competitor.rank) { + return { ...base, category: "strong" }; + } + if (primaryRank - competitor.rank >= weakThreshold) { + return { ...base, category: "weak" }; + } + return { ...base, category: "shared" }; +} + +/** + * Volume × how far behind we are. Zero when we already lead, null when volume + * is unknown. Deliberately trivial: a number a marketer can recompute by hand + * is a number they will trust and challenge. + */ +export function opportunityScore( + searchVolume: number | null, + primaryRank: number | null, + bestCompetitorRank: number | null, +): number | null { + if (searchVolume === null) return null; + if (bestCompetitorRank === null) return 0; + // Not ranking at all is treated as the full addressable gap. + const gap = + primaryRank === null + ? Math.max(1, 100 - bestCompetitorRank) + : Math.max(0, primaryRank - bestCompetitorRank); + return Math.round(searchVolume * gap); +} + +// --- Tracked Keyword Share of Search --------------------------------------- + +/** + * Click-through rate by organic position. + * + * Versioned because changing the curve changes every historical number computed + * with it. A stored snapshot records which version produced it, so a curve + * change is visible rather than a silent rewrite of the past. + */ +export const CTR_MODEL_VERSION = "ctr-2026-08-v1"; + +const CTR_CURVE: readonly number[] = [ + 0, 0.284, 0.152, 0.099, 0.071, 0.052, 0.04, 0.032, 0.026, 0.022, 0.019, +]; +const CTR_TAIL = 0.01; + +/** CTR for a position. Null (not ranking) contributes nothing. */ +export function ctrFor(rankGroup: number | null): number { + if (rankGroup === null || rankGroup < 1) return 0; + if (rankGroup < CTR_CURVE.length) return CTR_CURVE[rankGroup] ?? CTR_TAIL; + // Everything past the modelled head gets a small flat value rather than zero: + // position 15 is worth less than position 3, but it is not worth nothing. + return rankGroup <= 20 ? CTR_TAIL : 0; +} + +interface SosKeyword { + trackedKeywordId: string; + searchVolume: number | null; + clusterWeight: number; + priorityWeight: number; + /** One observation per domain being compared. */ + observations: readonly Observation[]; +} + +interface SosEntityResult { + entityId: string; + visibilityScore: number; + share: number | null; +} + +interface SosResult { + status: "ok" | "insufficient_data"; + reason?: string; + ctrModelVersion: string; + keywordsConsidered: number; + keywordsCovered: number; + results: SosEntityResult[]; +} + +/** Minimum fraction of keywords that must have a usable observation. */ +const MIN_COVERAGE = 0.5; + +/** + * Tracked Keyword Share of Search. + * + * Distinct from phase 1's Estimated Organic Visibility Share, which divides + * provider-estimated site traffic. This one is computed from OUR keyword basket + * and OUR CTR curve, so the two answer different questions and must never be + * shown under the same label. + * + * A keyword with unknown volume is skipped rather than assumed: weighting it as + * 1 would let a long tail of unmeasured terms quietly dominate the result. + */ +export function computeShareOfSearch( + keywords: readonly SosKeyword[], + entityIds: readonly string[], +): SosResult { + const insufficient = (reason: string): SosResult => ({ + status: "insufficient_data", + reason, + ctrModelVersion: CTR_MODEL_VERSION, + keywordsConsidered: keywords.length, + keywordsCovered: 0, + results: entityIds.map((entityId) => ({ + entityId, + visibilityScore: 0, + share: null, + })), + }); + + if (entityIds.length === 0) return insufficient("no domains selected"); + if (keywords.length === 0) return insufficient("no tracked keywords"); + + const usable = keywords.filter( + (k) => k.searchVolume !== null && k.searchVolume > 0, + ); + if (usable.length === 0) + return insufficient("no keyword has a search volume"); + + const covered = usable.filter((k) => + k.observations.some((o) => o.isFound && o.rankGroup !== null), + ); + if (covered.length / usable.length < MIN_COVERAGE) { + return { + ...insufficient( + "fewer than half of the tracked keywords have a position", + ), + keywordsCovered: covered.length, + }; + } + + const scores = new Map(entityIds.map((id) => [id, 0])); + for (const keyword of usable) { + const weight = + (keyword.searchVolume ?? 0) * + keyword.clusterWeight * + keyword.priorityWeight; + for (const entityId of entityIds) { + const observation = keyword.observations.find( + (o) => o.entityId === entityId, + ); + const contribution = weight * ctrFor(rankOf(observation)); + scores.set(entityId, (scores.get(entityId) ?? 0) + contribution); + } + } + + const total = [...scores.values()].reduce((a, b) => a + b, 0); + if (total <= 0) { + return { + ...insufficient("no domain has any visible position"), + keywordsCovered: covered.length, + }; + } + + return { + status: "ok", + ctrModelVersion: CTR_MODEL_VERSION, + keywordsConsidered: usable.length, + keywordsCovered: covered.length, + results: entityIds.map((entityId) => { + const score = scores.get(entityId) ?? 0; + return { entityId, visibilityScore: score, share: score / total }; + }), + }; +} diff --git a/src/server/morgana/si/http.ts b/src/server/morgana/si/http.ts new file mode 100644 index 00000000..fe963401 --- /dev/null +++ b/src/server/morgana/si/http.ts @@ -0,0 +1,106 @@ +import type { Phase0Config } from "../phase0-env"; + +/** + * Morgana Search Intelligence — HTTP helpers. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Response shaping and request-input coercion, shared by the entry point and + * the route table so both produce identical envelopes and identical headers. + */ + +const SI_API_VERSION = "2026-08-06"; +export const SI_PATH_PREFIX = "/internal/si/"; + +interface EnvelopeMeta { + cacheStatus?: "hit" | "miss" | "not_applicable"; + providerStatus?: string; + costEstimatedUsd?: number; + costActualUsd?: number; +} + +/** + * Every response carries this wrapper so the client can validate one shape and + * detect a version mismatch, rather than mis-parsing a changed payload. + */ +export function envelope( + config: Phase0Config, + data: unknown, + meta: EnvelopeMeta = {}, +) { + return { + api_version: SI_API_VERSION, + engine_version: config.ENGINE_UPSTREAM_RELEASE, + upstream_version: config.ENGINE_UPSTREAM_RELEASE, + upstream_commit: config.ENGINE_UPSTREAM_COMMIT, + environment: config.SEARCH_INTELLIGENCE_ENVIRONMENT, + data, + fetched_at: new Date().toISOString(), + cache_status: meta.cacheStatus ?? "not_applicable", + provider_status: meta.providerStatus ?? "unknown", + cost_estimated_usd: meta.costEstimatedUsd ?? 0, + cost_actual_usd: meta.costActualUsd ?? 0, + }; +} + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + // The engine renders nothing and is called machine-to-machine only. + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "referrer-policy": "no-referrer", + }, + }); +} + +export function badRequest(code: string, message: string): Response { + return json({ error: message, code }, 400); +} + +/** Predicate, not an assertion: the check and the narrowing cannot drift apart. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function readJson( + request: Request, +): Promise> { + try { + const body: unknown = await request.json(); + return isRecord(body) ? body : {}; + } catch { + return {}; + } +} + +export function str(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : undefined; +} + +export function num(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +export function bool(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +/** Bounded positive integer from a query parameter, with a hard ceiling. */ +export function clamp( + raw: string | null, + fallback: number, + max: number, +): number | undefined { + if (raw === null) return fallback; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} diff --git a/src/server/morgana/si/ids.ts b/src/server/morgana/si/ids.ts new file mode 100644 index 00000000..14e19d2d --- /dev/null +++ b/src/server/morgana/si/ids.ts @@ -0,0 +1,18 @@ +/** + * Morgana Search Intelligence — id and timestamp helpers. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Shared by the store and the ledger store so the two cannot drift on id shape + * or timestamp format — both are part of dedupe keys, where a mismatch would be + * silent. + */ + +/** Prefixed, URL-safe id. The prefix makes a stray id readable in a log. */ +export function newId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`; +} + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/src/server/morgana/si/keywords.ts b/src/server/morgana/si/keywords.ts new file mode 100644 index 00000000..93a328bf --- /dev/null +++ b/src/server/morgana/si/keywords.ts @@ -0,0 +1,196 @@ +/** + * Morgana Search Intelligence — keyword normalisation and clustering. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Pure. Cluster assignment is deterministic rule matching, deliberately not AI: + * a keyword landing in the wrong cluster silently reweights Share of Search, and + * a rule you can read is a rule you can correct. + */ + +export type Priority = "critical" | "high" | "normal" | "low"; + +/** + * Lowercase, collapse whitespace, strip surrounding punctuation. Accents are + * PRESERVED — in Italian "perche" and "perché" are different queries, and + * folding them would merge two keywords that rank differently. + */ +export function normalizeKeyword(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""); +} + +export function isValidKeyword(input: string): boolean { + const normalized = normalizeKeyword(input); + return normalized.length >= 2 && normalized.length <= 200; +} + +/** Scheduled check interval, derived from priority (§7 cadences). */ +export function frequencyHoursFor(priority: Priority): number { + switch (priority) { + case "critical": + return 24; + case "high": + return 56; // three times a week + case "normal": + return 168; + case "low": + return 336; + } +} + +/** Weight applied to a keyword's Share-of-Search contribution. */ +export function priorityWeight(priority: Priority): number { + switch (priority) { + case "critical": + return 3; + case "high": + return 2; + case "normal": + return 1; + case "low": + return 0.5; + } +} + +interface ClusterRule { + slug: string; + name: string; + weight: number; + /** Matched against the normalised keyword, in order; first match wins. */ + patterns: readonly string[]; +} + +/** + * Default clusters. Data, not logic: an operator can add, rename or reweight + * them, and nothing in the code branches on a specific slug. + * + * Order matters — brand terms are checked first so "custodia bitcoin checksig" + * lands in the brand cluster rather than the generic custody one. + */ +export const DEFAULT_CLUSTERS: readonly ClusterRule[] = [ + { + slug: "brand-checksig", + name: "Brand CheckSig", + weight: 3, + patterns: ["checksig", "check sig", "check-sig"], + }, + { + slug: "competitor-brand", + name: "Competitor Brand", + weight: 1.5, + patterns: ["conio", "binance", "coinbase", "bitpanda", "young platform"], + }, + { + slug: "custodia-bitcoin", + name: "Custodia Bitcoin", + weight: 2.5, + patterns: ["custodia bitcoin", "bitcoin custody", "custodire bitcoin"], + }, + { + slug: "custodia-crypto", + name: "Custodia Crypto", + weight: 2, + patterns: [ + "custodia crypto", + "crypto custody", + "custodia criptovalute", + "custodia asset digitali", + ], + }, + { + slug: "acquisto-bitcoin", + name: "Acquisto Bitcoin", + weight: 2, + patterns: ["comprare bitcoin", "acquistare bitcoin", "acquisto bitcoin"], + }, + { + slug: "sicurezza", + name: "Sicurezza", + weight: 2, + patterns: ["sicurezza", "sicuro", "truffa", "rischi"], + }, + { + slug: "mica-casp", + name: "MiCA e CASP", + weight: 2, + patterns: ["mica", "casp", "regolamento", "normativa"], + }, + { + slug: "tassazione", + name: "Tassazione", + weight: 1.5, + patterns: ["tassazione", "tasse", "fisco", "dichiarazione"], + }, + { + slug: "successione", + name: "Successione", + weight: 1.5, + patterns: ["successione", "eredità", "eredita", "testamento"], + }, + { + slug: "proof-of-reserves", + name: "Proof of Reserves", + weight: 2, + patterns: ["proof of reserves", "riserve", "prova delle riserve"], + }, + { + slug: "dgi", + name: "Digital Gold Institute", + weight: 2.5, + patterns: ["digital gold institute", "ferdinando ametrano", "ametrano"], + }, + { + slug: "bitcoin-education", + name: "Bitcoin Education", + weight: 1, + patterns: [ + "cos'è bitcoin", + "cos e bitcoin", + "come funziona bitcoin", + "guida bitcoin", + ], + }, +]; + +/** + * Assign a cluster by first matching rule. Returns null when nothing matches — + * an unclustered keyword is a visible gap in the configuration, not something + * to bucket into a catch-all where it would silently pick up a weight. + */ +export function classifyKeyword( + keyword: string, + clusters: readonly ClusterRule[] = DEFAULT_CLUSTERS, +): ClusterRule | null { + const normalized = normalizeKeyword(keyword); + for (const cluster of clusters) { + if (cluster.patterns.some((pattern) => normalized.includes(pattern))) { + return cluster; + } + } + return null; +} + +/** Seed watchlist. Data only — nothing in the code depends on these strings. */ +export const SEED_KEYWORDS: readonly { keyword: string; priority: Priority }[] = + [ + { keyword: "checksig", priority: "critical" }, + { keyword: "check sig", priority: "critical" }, + { keyword: "digital gold institute", priority: "critical" }, + { keyword: "ferdinando ametrano", priority: "critical" }, + { keyword: "custodia bitcoin", priority: "critical" }, + { keyword: "custodia crypto", priority: "high" }, + { keyword: "bitcoin custody", priority: "high" }, + { keyword: "crypto custody", priority: "high" }, + { keyword: "comprare bitcoin", priority: "high" }, + { keyword: "comprare bitcoin in sicurezza", priority: "normal" }, + { keyword: "sicurezza bitcoin", priority: "normal" }, + { keyword: "mica italia", priority: "normal" }, + { keyword: "casp italia", priority: "normal" }, + { keyword: "proof of reserves", priority: "normal" }, + { keyword: "successione bitcoin", priority: "low" }, + { keyword: "tassazione bitcoin", priority: "low" }, + ]; diff --git a/src/server/morgana/si/ledger-store.ts b/src/server/morgana/si/ledger-store.ts new file mode 100644 index 00000000..bbde1d44 --- /dev/null +++ b/src/server/morgana/si/ledger-store.ts @@ -0,0 +1,165 @@ +import { eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { searchBudgetState, searchUsageLedger } from "@/db/schema"; +import { isMetered, type MeteringClass } from "./budget"; +import { newId, nowIso } from "./ids"; + +/** + * Morgana Search Intelligence — usage ledger and budget state. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Split out of store.ts to stay inside the repo's 400-line module limit, and + * because the accounting surface has a different reason to change from the + * entity and snapshot surface. + */ + +// --- usage ledger and budget state ----------------------------------------- + +interface RecordUsageInput { + day: string; + entityId?: string | null; + endpointPath: string; + meteringClass: MeteringClass; + estimatedCostMicros?: number; + actualCostMicros?: number; + failed?: boolean; + retry?: boolean; + blockedByBudget?: boolean; +} + +/** + * Record one call against the ledger. + * + * `requests` always increments; `metered_requests` only for classes that + * actually consume an allowance. That separation is the whole point (decision + * #84): free lifecycle polls must never ration paid work. + */ +export async function recordUsage(input: RecordUsageInput): Promise { + const metered = isMetered(input.meteringClass) ? 1 : 0; + const isCache = input.meteringClass === "cache"; + const values = { + id: newId("ul"), + day: input.day, + entityId: input.entityId ?? null, + endpointPath: input.endpointPath, + meteringClass: input.meteringClass, + requests: isCache ? 0 : 1, + meteredRequests: metered, + failedRequests: input.failed ? 1 : 0, + retryRequests: input.retry ? 1 : 0, + estimatedCostMicros: input.estimatedCostMicros ?? 0, + actualCostMicros: input.actualCostMicros ?? 0, + cacheHits: isCache ? 1 : 0, + cacheMisses: isCache ? 0 : 1, + blockedByBudget: input.blockedByBudget ? 1 : 0, + updatedAt: nowIso(), + }; + await db + .insert(searchUsageLedger) + .values(values) + .onConflictDoUpdate({ + target: [ + searchUsageLedger.day, + searchUsageLedger.endpointPath, + searchUsageLedger.meteringClass, + ], + set: { + requests: sql`${searchUsageLedger.requests} + ${values.requests}`, + meteredRequests: sql`${searchUsageLedger.meteredRequests} + ${values.meteredRequests}`, + failedRequests: sql`${searchUsageLedger.failedRequests} + ${values.failedRequests}`, + retryRequests: sql`${searchUsageLedger.retryRequests} + ${values.retryRequests}`, + estimatedCostMicros: sql`${searchUsageLedger.estimatedCostMicros} + ${values.estimatedCostMicros}`, + actualCostMicros: sql`${searchUsageLedger.actualCostMicros} + ${values.actualCostMicros}`, + cacheHits: sql`${searchUsageLedger.cacheHits} + ${values.cacheHits}`, + cacheMisses: sql`${searchUsageLedger.cacheMisses} + ${values.cacheMisses}`, + blockedByBudget: sql`${searchUsageLedger.blockedByBudget} + ${values.blockedByBudget}`, + updatedAt: values.updatedAt, + }, + }); +} + +interface LedgerTotals { + requests: number; + meteredRequests: number; + paidSubmissions: number; + freePollRequests: number; + resultFetchRequests: number; + failedRequests: number; + retryRequests: number; + estimatedCostMicros: number; + actualCostMicros: number; + cacheHits: number; + cacheMisses: number; + blockedByBudget: number; +} + +const ZERO_TOTALS: LedgerTotals = { + requests: 0, + meteredRequests: 0, + paidSubmissions: 0, + freePollRequests: 0, + resultFetchRequests: 0, + failedRequests: 0, + retryRequests: 0, + estimatedCostMicros: 0, + actualCostMicros: 0, + cacheHits: 0, + cacheMisses: 0, + blockedByBudget: 0, +}; + +/** Ledger totals for a month (`YYYY-MM`) or a single day (`YYYY-MM-DD`). */ +export async function ledgerTotals(prefix: string): Promise { + const rows = await db + .select() + .from(searchUsageLedger) + .where(sql`${searchUsageLedger.day} LIKE ${`${prefix}%`}`); + const totals: LedgerTotals = { ...ZERO_TOTALS }; + for (const row of rows) { + totals.requests += row.requests; + totals.meteredRequests += row.meteredRequests; + totals.failedRequests += row.failedRequests; + totals.retryRequests += row.retryRequests; + totals.estimatedCostMicros += row.estimatedCostMicros; + totals.actualCostMicros += row.actualCostMicros; + totals.cacheHits += row.cacheHits; + totals.cacheMisses += row.cacheMisses; + totals.blockedByBudget += row.blockedByBudget; + if (row.meteringClass === "paid_submission") + totals.paidSubmissions += row.requests; + if (row.meteringClass === "free_poll") + totals.freePollRequests += row.requests; + if (row.meteringClass === "result_fetch") + totals.resultFetchRequests += row.requests; + } + return totals; +} + +type BudgetStateRow = typeof searchBudgetState.$inferSelect; + +export async function readBudgetState(month: string): Promise { + const rows = await db + .select() + .from(searchBudgetState) + .where(eq(searchBudgetState.month, month)) + .limit(1); + const row = rows[0]; + return ( + row ?? { + month, + monthlyCostMicros: 0, + currentDay: null, + dailyCostMicros: 0, + consecutiveFailures: 0, + circuitOpenedAt: null, + lastAlertThreshold: null, + } + ); +} + +// NOTE: the budget WRITE path (accruing spend, tripping the breaker, recording an +// announced alert threshold) is deliberately absent. Nothing can call it: live +// collection is not implemented in phase 1, so there is no spend to accrue. It +// belongs with the live collector, and shipping it now would be unreachable code +// that knip is right to reject. The READ path above is used and tested. diff --git a/src/server/morgana/si/metrics.ts b/src/server/morgana/si/metrics.ts new file mode 100644 index 00000000..bfdb4bba --- /dev/null +++ b/src/server/morgana/si/metrics.ts @@ -0,0 +1,251 @@ +/** + * Morgana Search Intelligence — derived metrics. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Pure functions, no I/O. The governing rule for this whole file: **a missing + * value is never a zero**. A domain with no data and a domain with genuinely + * zero traffic are different facts, and collapsing them produces a comparison + * table that is confidently wrong — which is worse than an empty one. + */ + +type DeltaStatus = "ok" | "insufficient_history" | "not_available"; + +export interface Delta { + status: DeltaStatus; + /** Absolute change. Null unless status is `ok`. */ + absolute: number | null; + /** Fractional change (0.15 = +15%). Null when the baseline is 0 or absent. */ + relative: number | null; +} + +const UNAVAILABLE: Delta = { + status: "not_available", + absolute: null, + relative: null, +}; +const NO_HISTORY: Delta = { + status: "insufficient_history", + absolute: null, + relative: null, +}; + +/** + * Change between a current and a historical value. + * + * `current == null` → `not_available` (we do not know today's value). + * `previous == null` → `insufficient_history` (we know today, not the past). + * The distinction matters: the first is a provider gap, the second is a young + * time series that will heal on its own. + */ +export function computeDelta( + current: number | null | undefined, + previous: number | null | undefined, +): Delta { + if (current === null || current === undefined) return UNAVAILABLE; + if (previous === null || previous === undefined) return NO_HISTORY; + const absolute = current - previous; + // A zero baseline has no meaningful percentage; report the absolute only + // rather than emitting Infinity. + const relative = previous === 0 ? null : absolute / previous; + return { status: "ok", absolute, relative }; +} + +export interface SnapshotPoint { + snapshotDate: string; + organicTrafficEstimate: number | null; + organicKeywordCount: number | null; + backlinkCount: number | null; + referringDomainCount: number | null; +} + +export interface DeltaSet { + trafficDelta1d: Delta; + trafficDelta7d: Delta; + trafficDelta30d: Delta; + keywordCountDelta1d: Delta; + keywordCountDelta7d: Delta; + keywordCountDelta30d: Delta; + backlinkDelta7d: Delta; + referringDomainDelta7d: Delta; +} + +/** + * Pick the snapshot closest to `daysAgo` before `from`, within a tolerance. + * + * Snapshots are not guaranteed daily — a competitor on a weekly cadence has + * gaps, and a budget stop can create more. Rather than demanding an exact date + * (which would report `insufficient_history` forever for those entities) we + * accept the nearest snapshot inside a window, and report the window miss as + * insufficient history when nothing qualifies. + */ +export function findBaseline( + history: readonly SnapshotPoint[], + fromDate: string, + daysAgo: number, + toleranceDays = Math.max(1, Math.floor(daysAgo / 2)), +): SnapshotPoint | null { + const target = + new Date(`${fromDate}T00:00:00Z`).getTime() - daysAgo * 86_400_000; + const toleranceMs = toleranceDays * 86_400_000; + let best: SnapshotPoint | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const point of history) { + const time = new Date(`${point.snapshotDate}T00:00:00Z`).getTime(); + if (Number.isNaN(time)) continue; + const distance = Math.abs(time - target); + if (distance <= toleranceMs && distance < bestDistance) { + best = point; + bestDistance = distance; + } + } + return best; +} + +export function computeDeltas( + current: SnapshotPoint, + history: readonly SnapshotPoint[], +): DeltaSet { + // Exclude the current snapshot from its own baselines. + const past = history.filter((p) => p.snapshotDate < current.snapshotDate); + const at = (days: number) => findBaseline(past, current.snapshotDate, days); + const d1 = at(1); + const d7 = at(7); + const d30 = at(30); + + return { + trafficDelta1d: computeDelta( + current.organicTrafficEstimate, + d1?.organicTrafficEstimate, + ), + trafficDelta7d: computeDelta( + current.organicTrafficEstimate, + d7?.organicTrafficEstimate, + ), + trafficDelta30d: computeDelta( + current.organicTrafficEstimate, + d30?.organicTrafficEstimate, + ), + keywordCountDelta1d: computeDelta( + current.organicKeywordCount, + d1?.organicKeywordCount, + ), + keywordCountDelta7d: computeDelta( + current.organicKeywordCount, + d7?.organicKeywordCount, + ), + keywordCountDelta30d: computeDelta( + current.organicKeywordCount, + d30?.organicKeywordCount, + ), + backlinkDelta7d: computeDelta(current.backlinkCount, d7?.backlinkCount), + referringDomainDelta7d: computeDelta( + current.referringDomainCount, + d7?.referringDomainCount, + ), + }; +} + +// --- Estimated Organic Visibility Share ------------------------------------ + +export type VisibilityShareStatus = "ok" | "insufficient_data"; + +interface VisibilityShareInput { + entityId: string; + organicTrafficEstimate: number | null; + locationCode: number; + languageCode: string; + snapshotDate: string; +} + +export interface VisibilityShareResult { + entityId: string; + /** 0..1. Null when the share could not be computed honestly. */ + share: number | null; + status: VisibilityShareStatus; + reason?: string; +} + +export interface VisibilityShareOutcome { + status: VisibilityShareStatus; + reason?: string; + results: VisibilityShareResult[]; +} + +/** + * Estimated Organic Visibility Share — each domain's estimated organic traffic + * as a fraction of the compared set's total. + * + * Deliberately NOT called "Share of Search": that term means a share of a + * controlled keyword basket, which is Phase 2. Naming this one Share of Search + * would make two different numbers look like the same metric. + * + * Refuses to compute, rather than mislead, when: + * - fewer than two domains are being compared; + * - the compared snapshots are from different markets (traffic estimates are + * market-specific, so mixing them is meaningless); + * - the snapshots are not contemporaneous (see `maxSpreadDays`); + * - any compared domain has no traffic estimate — a missing domain silently + * inflates everyone else's share; + * - the total is zero. + */ +export function computeVisibilityShare( + inputs: readonly VisibilityShareInput[], + options: { maxSpreadDays?: number } = {}, +): VisibilityShareOutcome { + const maxSpreadDays = options.maxSpreadDays ?? 3; + + const insufficient = (reason: string): VisibilityShareOutcome => ({ + status: "insufficient_data", + reason, + results: inputs.map((input) => ({ + entityId: input.entityId, + share: null, + status: "insufficient_data" as const, + reason, + })), + }); + + if (inputs.length < 2) { + return insufficient("at least two domains are required"); + } + + const markets = new Set( + inputs.map((i) => `${String(i.locationCode)}|${i.languageCode}`), + ); + if (markets.size > 1) { + return insufficient("compared domains use incompatible markets"); + } + + const times = inputs.map((i) => + new Date(`${i.snapshotDate}T00:00:00Z`).getTime(), + ); + if (times.some((t) => Number.isNaN(t))) { + return insufficient("a snapshot has an unreadable date"); + } + const spreadDays = (Math.max(...times) - Math.min(...times)) / 86_400_000; + if (spreadDays > maxSpreadDays) { + return insufficient("compared snapshots are not contemporaneous"); + } + + if (inputs.some((i) => i.organicTrafficEstimate === null)) { + return insufficient("a compared domain has no traffic estimate"); + } + + const total = inputs.reduce( + (sum, i) => sum + (i.organicTrafficEstimate ?? 0), + 0, + ); + if (total <= 0) { + return insufficient("total estimated traffic is zero"); + } + + return { + status: "ok", + results: inputs.map((input) => ({ + entityId: input.entityId, + share: (input.organicTrafficEstimate ?? 0) / total, + status: "ok" as const, + })), + }; +} diff --git a/src/server/morgana/si/p2-analytics-store.ts b/src/server/morgana/si/p2-analytics-store.ts new file mode 100644 index 00000000..3ac6b8cc --- /dev/null +++ b/src/server/morgana/si/p2-analytics-store.ts @@ -0,0 +1,171 @@ +import { and, eq, gte, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { + keywordGapSnapshots, + rankingEvents, + shareOfSearchSnapshots, +} from "@/db/schema"; +import type { RankingEvent } from "./events"; +import type { GapCategoryName } from "./gap"; +import { newId, nowIso } from "./ids"; + +/** + * Morgana Search Intelligence — phase 2 derived analytics storage. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Gap, share of search and ranking events are all *derived* from rank + * observations and are recomputed as a unit, so they are split from the + * keyword configuration store they used to share a module with. + */ +// --- gap and share snapshots ------------------------------------------------ + +export async function saveGapSnapshot(input: { + trackedKeywordId: string; + snapshotDate: string; + category: GapCategoryName; + primaryRank: number | null; + bestCompetitorRank: number | null; + bestCompetitorEntityId: string | null; + opportunityScore: number | null; +}): Promise { + await db + .insert(keywordGapSnapshots) + .values({ + id: newId("kg"), + trackedKeywordId: input.trackedKeywordId, + snapshotDate: input.snapshotDate, + category: input.category, + primaryRank: input.primaryRank, + bestCompetitorRank: input.bestCompetitorRank, + bestCompetitorEntityId: input.bestCompetitorEntityId, + opportunityScore: input.opportunityScore, + createdAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [ + keywordGapSnapshots.trackedKeywordId, + keywordGapSnapshots.snapshotDate, + ], + set: { + category: input.category, + primaryRank: input.primaryRank, + bestCompetitorRank: input.bestCompetitorRank, + bestCompetitorEntityId: input.bestCompetitorEntityId, + opportunityScore: input.opportunityScore, + }, + }); +} + +export async function latestGapSnapshots(snapshotDate: string) { + return db + .select() + .from(keywordGapSnapshots) + .where(eq(keywordGapSnapshots.snapshotDate, snapshotDate)); +} + +export async function saveShareSnapshot(input: { + entityId: string; + clusterId: string | null; + snapshotDate: string; + visibilityScore: number | null; + share: number | null; + status: "ok" | "insufficient_data"; + reason?: string | null; + keywordsConsidered: number; + keywordsCovered: number; + ctrModelVersion: string; +}): Promise { + await db + .insert(shareOfSearchSnapshots) + .values({ + id: newId("ss"), + entityId: input.entityId, + clusterId: input.clusterId, + snapshotDate: input.snapshotDate, + visibilityScore: input.visibilityScore, + share: input.share, + status: input.status, + reason: input.reason ?? null, + keywordsConsidered: input.keywordsConsidered, + keywordsCovered: input.keywordsCovered, + ctrModelVersion: input.ctrModelVersion, + createdAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [ + shareOfSearchSnapshots.entityId, + shareOfSearchSnapshots.clusterId, + shareOfSearchSnapshots.snapshotDate, + ], + set: { + visibilityScore: input.visibilityScore, + share: input.share, + status: input.status, + reason: input.reason ?? null, + keywordsConsidered: input.keywordsConsidered, + keywordsCovered: input.keywordsCovered, + ctrModelVersion: input.ctrModelVersion, + }, + }); +} + +export async function shareHistory(sinceDate: string) { + return db + .select() + .from(shareOfSearchSnapshots) + .where(gte(shareOfSearchSnapshots.snapshotDate, sinceDate)) + .orderBy(shareOfSearchSnapshots.snapshotDate); +} + +// --- events ----------------------------------------------------------------- + +/** Persist detected events; the UNIQUE dedupe key is the cooldown. */ +export async function saveEvents( + events: readonly RankingEvent[], +): Promise { + const stored: RankingEvent[] = []; + for (const event of events) { + try { + await db.insert(rankingEvents).values({ + id: newId("re"), + trackedKeywordId: event.trackedKeywordId, + entityId: event.entityId, + eventType: event.eventType, + previousRank: event.previousRank, + currentRank: event.currentRank, + competitorEntityId: event.competitorEntityId ?? null, + rankingUrl: event.rankingUrl ?? null, + detectedAt: nowIso(), + dedupeKey: event.dedupeKey, + notifiedAt: null, + createdAt: nowIso(), + }); + stored.push(event); + } catch { + // Already announced for this keyword/entity/type/day. + } + } + return stored; +} + +/** Events that have not been announced yet, oldest first. */ +export async function pendingEvents(limit = 50) { + return db + .select() + .from(rankingEvents) + .where(isNull(rankingEvents.notifiedAt)) + .orderBy(rankingEvents.detectedAt) + .limit(limit); +} + +/** Mark events as announced so a later tick does not repeat them. */ +export async function markNotified(ids: readonly string[]): Promise { + const at = nowIso(); + for (const id of ids) { + await db + .update(rankingEvents) + .set({ notifiedAt: at }) + .where(and(eq(rankingEvents.id, id), isNull(rankingEvents.notifiedAt))); + } +} diff --git a/src/server/morgana/si/p2-jobs-store.ts b/src/server/morgana/si/p2-jobs-store.ts new file mode 100644 index 00000000..5b494bcd --- /dev/null +++ b/src/server/morgana/si/p2-jobs-store.ts @@ -0,0 +1,167 @@ +import { desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { phase2UsageLedger, rankingJobs } from "@/db/schema"; +import { newId, nowIso } from "./ids"; +import type { Priority } from "./keywords"; + +/** + * Morgana Search Intelligence — phase 2 jobs and usage ledger. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Split from p2-store to stay inside the 400-line module limit; scheduling + * and accounting change for different reasons than the keyword data does. + */ + +// --- jobs and usage --------------------------------------------------------- + +export async function claimJob(input: { + jobType: + | "rank_check" + | "keyword_gap_refresh" + | "share_of_search_recalculate" + | "weekly_digest"; + trackedKeywordId?: string | null; + priority?: Priority; + snapshotDate: string; +}): Promise<{ id: string } | null> { + const id = newId("rj"); + try { + await db.insert(rankingJobs).values({ + id, + jobType: input.jobType, + trackedKeywordId: input.trackedKeywordId ?? null, + priority: input.priority ?? "normal", + status: "running", + scheduledAt: nowIso(), + attempts: 1, + lastError: null, + skipReason: null, + estimatedCostMicros: 0, + actualCostMicros: 0, + dedupeKey: `${input.jobType}|${input.trackedKeywordId ?? "all"}|${input.snapshotDate}`, + createdAt: nowIso(), + startedAt: nowIso(), + finishedAt: null, + }); + return { id }; + } catch { + return null; + } +} + +export async function finishJob( + id: string, + status: "succeeded" | "failed" | "skipped", + detail: { + lastError?: string; + skipReason?: string; + actualCostMicros?: number; + } = {}, +): Promise { + await db + .update(rankingJobs) + .set({ + status, + lastError: detail.lastError?.slice(0, 500) ?? null, + skipReason: detail.skipReason ?? null, + actualCostMicros: detail.actualCostMicros ?? 0, + finishedAt: nowIso(), + }) + .where(eq(rankingJobs.id, id)); +} + +export async function recentJobs(limit = 50) { + return db + .select() + .from(rankingJobs) + .orderBy(desc(rankingJobs.createdAt)) + .limit(limit); +} + +/** + * Record phase-2 usage. + * + * `httpRequests` and `paidTasks` are separate because one DataForSEO + * `task_post` can carry up to 100 keywords: counting HTTP calls understates + * spend, counting keywords overstates it, and conflating them is exactly the + * mistake that throttled Brand Monitoring (decision #84). + */ +export async function recordPhase2Usage(input: { + day: string; + jobType: string; + httpRequests?: number; + meteredRequests?: number; + paidTasks?: number; + keywordsChecked?: number; + cacheHits?: number; + cacheMisses?: number; + estimatedCostMicros?: number; + actualCostMicros?: number; + blockedByBudget?: number; +}): Promise { + const v = { + id: newId("p2u"), + day: input.day, + jobType: input.jobType, + httpRequests: input.httpRequests ?? 0, + meteredRequests: input.meteredRequests ?? 0, + paidTasks: input.paidTasks ?? 0, + keywordsChecked: input.keywordsChecked ?? 0, + cacheHits: input.cacheHits ?? 0, + cacheMisses: input.cacheMisses ?? 0, + estimatedCostMicros: input.estimatedCostMicros ?? 0, + actualCostMicros: input.actualCostMicros ?? 0, + blockedByBudget: input.blockedByBudget ?? 0, + updatedAt: nowIso(), + }; + await db + .insert(phase2UsageLedger) + .values(v) + .onConflictDoUpdate({ + target: [phase2UsageLedger.day, phase2UsageLedger.jobType], + set: { + httpRequests: sql`${phase2UsageLedger.httpRequests} + ${v.httpRequests}`, + meteredRequests: sql`${phase2UsageLedger.meteredRequests} + ${v.meteredRequests}`, + paidTasks: sql`${phase2UsageLedger.paidTasks} + ${v.paidTasks}`, + keywordsChecked: sql`${phase2UsageLedger.keywordsChecked} + ${v.keywordsChecked}`, + cacheHits: sql`${phase2UsageLedger.cacheHits} + ${v.cacheHits}`, + cacheMisses: sql`${phase2UsageLedger.cacheMisses} + ${v.cacheMisses}`, + estimatedCostMicros: sql`${phase2UsageLedger.estimatedCostMicros} + ${v.estimatedCostMicros}`, + actualCostMicros: sql`${phase2UsageLedger.actualCostMicros} + ${v.actualCostMicros}`, + blockedByBudget: sql`${phase2UsageLedger.blockedByBudget} + ${v.blockedByBudget}`, + updatedAt: v.updatedAt, + }, + }); +} + +export async function phase2Totals(prefix: string) { + const rows = await db + .select() + .from(phase2UsageLedger) + .where(sql`${phase2UsageLedger.day} LIKE ${`${prefix}%`}`); + return rows.reduce( + (acc, r) => ({ + httpRequests: acc.httpRequests + r.httpRequests, + meteredRequests: acc.meteredRequests + r.meteredRequests, + paidTasks: acc.paidTasks + r.paidTasks, + keywordsChecked: acc.keywordsChecked + r.keywordsChecked, + cacheHits: acc.cacheHits + r.cacheHits, + cacheMisses: acc.cacheMisses + r.cacheMisses, + estimatedCostMicros: acc.estimatedCostMicros + r.estimatedCostMicros, + actualCostMicros: acc.actualCostMicros + r.actualCostMicros, + blockedByBudget: acc.blockedByBudget + r.blockedByBudget, + }), + { + httpRequests: 0, + meteredRequests: 0, + paidTasks: 0, + keywordsChecked: 0, + cacheHits: 0, + cacheMisses: 0, + estimatedCostMicros: 0, + actualCostMicros: 0, + blockedByBudget: 0, + }, + ); +} diff --git a/src/server/morgana/si/p2-router.ts b/src/server/morgana/si/p2-router.ts new file mode 100644 index 00000000..9e4ee4cd --- /dev/null +++ b/src/server/morgana/si/p2-router.ts @@ -0,0 +1,368 @@ +import { + badRequest, + envelope, + isRecord, + json, + num, + readJson, + str, +} from "./http"; +import * as p2 from "./p2-store"; +import * as p2an from "./p2-analytics-store"; +import * as p2jobs from "./p2-jobs-store"; +import * as p2service from "./p2-service"; +import type { SiRequestContext } from "./router"; +import { DEFAULT_CLUSTERS, type Priority } from "./keywords"; + +/** + * Morgana Search Intelligence — phase 2 routes. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Mounted under the same private `/internal/si/` prefix, kept in its own module + * so neither router exceeds the repo's complexity and size limits. + */ + +const asPriority = (value: unknown): Priority | undefined => + value === "critical" || + value === "high" || + value === "normal" || + value === "low" + ? value + : undefined; + +const today = () => new Date().toISOString().slice(0, 10); + +/** Cluster and keyword configuration — the mutating admin surface. */ +async function dispatchConfig(ctx: SiRequestContext): Promise { + const { route, request, url, config, providerStatus } = ctx; + const method = request.method; + + // --- clusters ------------------------------------------------------------ + if (route === "clusters" && method === "GET") { + // Seeding on read keeps the default taxonomy available without a bootstrap + // step, and is a no-op once the rows exist. + await p2.ensureDefaultClusters(); + const clusters = await p2.listClusters(); + return json(envelope(config, { clusters }, { providerStatus })); + } + + if (route === "clusters" && method === "POST") { + const body = await readJson(request); + const name = str(body.name); + const slug = str(body.slug); + if (!name || !slug) + return badRequest("name_slug_required", "name and slug are required"); + const cluster = await p2.createCluster({ + name, + slug, + weight: num(body.weight), + description: str(body.description) ?? null, + }); + return json(envelope(config, { cluster }, { providerStatus }), 201); + } + + { + const match = /^clusters\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (match?.[1] && method === "PATCH") { + const body = await readJson(request); + const cluster = await p2.updateCluster(match[1], { + name: str(body.name), + weight: num(body.weight), + enabled: typeof body.enabled === "boolean" ? body.enabled : undefined, + }); + if (!cluster) return json({ error: "cluster not found" }, 404); + return json(envelope(config, { cluster }, { providerStatus })); + } + } + + // --- tracked keywords ---------------------------------------------------- + if (route === "keywords" && method === "GET") { + const keywords = await p2.listTrackedKeywords({ + includeDisabled: url.searchParams.get("includeDisabled") === "true", + }); + return json(envelope(config, { keywords }, { providerStatus })); + } + + if (route === "keywords" && method === "POST") { + const body = await readJson(request); + const keyword = str(body.keyword); + if (!keyword) return badRequest("keyword_required", "keyword is required"); + await p2.ensureDefaultClusters(); + const clusters = await p2.listClusters(); + const created = await p2.createTrackedKeyword( + { + keyword, + priority: asPriority(body.priority), + clusterId: str(body.cluster_id) ?? null, + locationCode: num(body.location_code), + languageCode: str(body.language_code), + alertingEnabled: + typeof body.alerting_enabled === "boolean" + ? body.alerting_enabled + : undefined, + }, + clusters, + ); + if (!created) { + return json({ error: "keyword already tracked", code: "duplicate" }, 409); + } + return json( + envelope(config, { keyword: created }, { providerStatus }), + 201, + ); + } + + if (route === "keywords/import" && method === "POST") { + const body = await readJson(request); + const raw: unknown[] = Array.isArray(body.keywords) ? body.keywords : []; + if (raw.length === 0) { + return badRequest( + "keywords_required", + "keywords must be a non-empty array", + ); + } + // Bounded so one import cannot create an unbounded amount of future spend. + if (raw.length > 500) { + return badRequest("too_many", "at most 500 keywords per import"); + } + await p2.ensureDefaultClusters(); + const inputs = raw.flatMap((entry) => { + if (typeof entry === "string") return [{ keyword: entry }]; + if (isRecord(entry)) { + const keyword = str(entry.keyword); + return keyword + ? [{ keyword, priority: asPriority(entry.priority) }] + : []; + } + return []; + }); + const result = await p2.bulkImportKeywords(inputs); + return json(envelope(config, result, { providerStatus })); + } + + { + const match = /^keywords\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (match?.[1] && method === "PATCH") { + const body = await readJson(request); + const updated = await p2.updateTrackedKeyword(match[1], { + priority: asPriority(body.priority), + clusterId: + body.cluster_id === null ? null : (str(body.cluster_id) ?? undefined), + trackingEnabled: + typeof body.tracking_enabled === "boolean" + ? body.tracking_enabled + : undefined, + alertingEnabled: + typeof body.alerting_enabled === "boolean" + ? body.alerting_enabled + : undefined, + searchVolume: num(body.search_volume), + }); + if (!updated) return json({ error: "keyword not found" }, 404); + return json(envelope(config, { keyword: updated }, { providerStatus })); + } + } + + return null; +} + +/** Read surfaces: gap, rank tracking, share of search, events, cost. */ +async function dispatchP2Reads( + ctx: SiRequestContext, +): Promise { + const { route, request, url, config, env, providerStatus } = ctx; + const method = request.method; + // --- reads --------------------------------------------------------------- + if (route === "keyword-gap" && method === "GET") { + const date = str(url.searchParams.get("date")) ?? today(); + const [gaps, keywords, clusters] = await Promise.all([ + p2an.latestGapSnapshots(date), + p2.listTrackedKeywords({ includeDisabled: true }), + p2.listClusters(), + ]); + const byId = new Map(keywords.map((k) => [k.id, k])); + const clusterById = new Map(clusters.map((c) => [c.id, c])); + return json( + envelope( + config, + { + snapshot_date: date, + rows: gaps.map((g) => { + const keyword = byId.get(g.trackedKeywordId); + return { + tracked_keyword_id: g.trackedKeywordId, + keyword: keyword?.keyword ?? null, + cluster: keyword?.clusterId + ? (clusterById.get(keyword.clusterId)?.name ?? null) + : null, + priority: keyword?.priority ?? null, + search_volume: keyword?.searchVolume ?? null, + category: g.category, + primary_rank: g.primaryRank, + best_competitor_rank: g.bestCompetitorRank, + best_competitor_entity_id: g.bestCompetitorEntityId, + opportunity_score: g.opportunityScore, + }; + }), + }, + { providerStatus }, + ), + ); + } + + if (route === "rank-tracking" && method === "GET") { + const [keywords, clusters] = await Promise.all([ + p2.listTrackedKeywords({ includeDisabled: true }), + p2.listClusters(), + ]); + const clusterById = new Map(clusters.map((c) => [c.id, c])); + const date = today(); + const rows = await Promise.all( + keywords.map(async (k) => { + const dates = await p2.recentSnapshotDates(k.id, 2); + const current = await p2.observationsFor(k.id, dates[0] ?? date); + const previous = dates[1] + ? await p2.observationsFor(k.id, dates[1]) + : []; + return { + tracked_keyword_id: k.id, + keyword: k.keyword, + cluster: k.clusterId + ? (clusterById.get(k.clusterId)?.name ?? null) + : null, + priority: k.priority, + tracking_enabled: k.trackingEnabled, + alerting_enabled: k.alertingEnabled, + last_checked_at: k.lastCheckedAt, + next_check_at: k.nextCheckAt, + observations: current, + previous_observations: previous, + }; + }), + ); + return json(envelope(config, { rows }, { providerStatus })); + } + + { + const match = /^rank-history\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (match?.[1] && method === "GET") { + const days = num(Number(url.searchParams.get("days"))) ?? 30; + const since = new Date(Date.now() - days * 86_400_000) + .toISOString() + .slice(0, 10); + const history = await p2.rankHistory(match[1], since); + return json( + envelope( + config, + { tracked_keyword_id: match[1], days, history }, + { providerStatus }, + ), + ); + } + } + + if (route === "share-of-search" && method === "GET") { + const days = num(Number(url.searchParams.get("days"))) ?? 30; + const since = new Date(Date.now() - days * 86_400_000) + .toISOString() + .slice(0, 10); + const history = await p2an.shareHistory(since); + return json( + envelope( + config, + { + days, + // The name matters: this is NOT phase 1's Estimated Organic + // Visibility Share, and the two must never be conflated. + metric: "Tracked Keyword Share of Search", + snapshots: history, + }, + { providerStatus }, + ), + ); + } + + if (route === "ranking-events" && method === "GET") { + const events = await p2an.pendingEvents(50); + return json(envelope(config, { events }, { providerStatus })); + } + + if (route === "phase2-cost" && method === "GET") { + const status = await p2service.phase2CostStatus(config, env); + return json(envelope(config, status, { providerStatus })); + } + + return null; +} + +/** Operations: the scheduler tick, share recalculation, job inspection. */ +async function dispatchP2Operations( + ctx: SiRequestContext, +): Promise { + const { route, request, config, env, providerStatus } = ctx; + const method = request.method; + // --- operations ---------------------------------------------------------- + // Morgana owns delivery, so Morgana must tell us what it delivered; without + // this the same event is handed out on every tick and Slack repeats it. + if (route === "ranking-events/ack" && method === "POST") { + const body = await readJson(request); + const ids = Array.isArray(body.event_ids) + ? body.event_ids.flatMap((id: unknown) => { + const value = str(id); + return value ? [value] : []; + }) + : []; + if (ids.length === 0) { + return badRequest( + "event_ids_required", + "event_ids must be a non-empty array", + ); + } + await p2an.markNotified(ids); + return json( + envelope(config, { acknowledged: ids.length }, { providerStatus }), + ); + } + + if (route === "rank-tick" && method === "POST") { + const body = await readJson(request); + const result = await p2service.runRankTick(config, env, { + limit: num(body.limit) ?? 5, + }); + return json(envelope(config, result, { providerStatus })); + } + + if (route === "share-recalculate" && method === "POST") { + const result = await p2service.recalculateShareOfSearch(); + return json(envelope(config, result, { providerStatus })); + } + + if (route === "rank-jobs" && method === "GET") { + const jobs = await p2jobs.recentJobs(50); + return json(envelope(config, { jobs }, { providerStatus })); + } + + if (route === "seed-clusters" && method === "POST") { + const created = await p2.ensureDefaultClusters(); + return json( + envelope( + config, + { created, available: DEFAULT_CLUSTERS.length }, + { providerStatus }, + ), + ); + } + + return null; +} + +export async function dispatchPhase2( + ctx: SiRequestContext, +): Promise { + return ( + (await dispatchConfig(ctx)) ?? + (await dispatchP2Reads(ctx)) ?? + (await dispatchP2Operations(ctx)) + ); +} diff --git a/src/server/morgana/si/p2-service.ts b/src/server/morgana/si/p2-service.ts new file mode 100644 index 00000000..6a220ec5 --- /dev/null +++ b/src/server/morgana/si/p2-service.ts @@ -0,0 +1,370 @@ +import { isEnabled, type Phase0Config } from "../phase0-env"; +import { microsToUsd } from "./budget"; +import { classifyGap, computeShareOfSearch, CTR_MODEL_VERSION } from "./gap"; +import { detectRankingEvents, detectShareShift } from "./events"; +import { priorityWeight, type Priority } from "./keywords"; +import * as p2 from "./p2-store"; +import * as p2an from "./p2-analytics-store"; +import * as p2jobs from "./p2-jobs-store"; +import * as store from "./store"; +import { resolveProviderStatus } from "./service"; + +/** + * Morgana Search Intelligence — phase 2 orchestration. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Wires the pure logic to the store. As in phase 1, the engine has no cron: + * Morgana's scheduler calls `runRankTick`, so the engine still cannot spend + * unless asked. + */ + +const today = (now: Date = new Date()) => now.toISOString().slice(0, 10); + +/** Deterministic fixture rank, so the whole pipeline runs without a credential. */ +function fixtureRank( + keyword: string, + domain: string, + date: string, +): number | null { + let h = 2166136261; + for (const ch of `${keyword}|${domain}|${date}`) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 16777619); + } + const v = Math.abs(h) % 100; + // Roughly a quarter of keyword/domain pairs do not rank at all — the case the + // UI and the maths must handle, so the fixtures must produce it. + if (v >= 75) return null; + return 1 + (v % 40); +} + +interface RankTickResult { + due: number; + processed: number; + observationsRecorded: number; + eventsDetected: number; + provider: string; + skipped?: string; +} + +/** + * One scheduler step: check the most urgent due keywords against every enabled + * entity, store the observations, then classify the gap and detect events. + * + * Bounded per tick so a single invocation cannot exhaust the subrequest budget + * or the daily cap in one burst. + */ +export async function runRankTick( + config: Phase0Config, + env: object, + options: { limit?: number; now?: Date } = {}, +): Promise { + const now = options.now ?? new Date(); + const date = today(now); + const provider = resolveProviderStatus(config, env); + + if (provider === "not_configured") { + return { + due: 0, + processed: 0, + observationsRecorded: 0, + eventsDetected: 0, + provider, + skipped: "credential_not_configured", + }; + } + if (provider === "live") { + // Live SERP collection is not implemented in phase 2 — no credential exists + // in any deployed configuration. An explicit refusal beats a half-written + // provider call that would silently produce partial data. + return { + due: 0, + processed: 0, + observationsRecorded: 0, + eventsDetected: 0, + provider, + skipped: "live_collection_not_enabled", + }; + } + + const entities = await store.listEntities(); + const primary = entities.find((e) => e.entityType === "primary"); + if (!primary) { + return { + due: 0, + processed: 0, + observationsRecorded: 0, + eventsDetected: 0, + provider, + skipped: "no_primary_entity", + }; + } + + const due = await p2.dueKeywords(options.limit ?? 5, now); + let observations = 0; + let eventsDetected = 0; + + for (const keyword of due) { + const job = await p2jobs.claimJob({ + jobType: "rank_check", + trackedKeywordId: keyword.id, + priority: keyword.priority, + snapshotDate: date, + }); + if (!job) continue; + + for (const entity of entities) { + const rank = fixtureRank( + keyword.normalizedKeyword, + entity.normalizedDomain, + date, + ); + const recorded = await p2.recordRank({ + trackedKeywordId: keyword.id, + entityId: entity.id, + locationCode: keyword.locationCode, + languageCode: keyword.languageCode, + rankGroup: rank, + rankAbsolute: rank === null ? null : rank + 2, + rankingUrl: + rank === null + ? null + : `https://${entity.normalizedDomain}/${keyword.normalizedKeyword.replace(/\s+/g, "-")}`, + provider: "fixture", + now, + }); + if (recorded) observations += 1; + } + + const dates = await p2.recentSnapshotDates(keyword.id, 3); + const current = await p2.observationsFor(keyword.id, date); + const previous = dates[1] + ? await p2.observationsFor(keyword.id, dates[1]) + : undefined; + const beforePrevious = dates[2] + ? await p2.observationsFor(keyword.id, dates[2]) + : undefined; + + const gap = classifyGap({ + primaryEntityId: primary.id, + current, + previous, + searchVolume: keyword.searchVolume, + }); + await p2an.saveGapSnapshot({ + trackedKeywordId: keyword.id, + snapshotDate: date, + category: gap.category, + primaryRank: gap.primaryRank, + bestCompetitorRank: gap.bestCompetitorRank, + bestCompetitorEntityId: gap.bestCompetitorEntityId, + opportunityScore: gap.opportunityScore, + }); + + if ( + keyword.alertingEnabled && + isEnabled(config.SEARCH_INTELLIGENCE_ENABLED) + ) { + const events = detectRankingEvents({ + trackedKeywordId: keyword.id, + primaryEntityId: primary.id, + priority: keyword.priority, + snapshotDate: date, + current, + previous, + beforePrevious, + }); + eventsDetected += (await p2an.saveEvents(events)).length; + } + + await p2.markChecked(keyword.id, keyword.trackingFrequencyHours, now); + await p2jobs.finishJob(job.id, "succeeded"); + } + + await p2jobs.recordPhase2Usage({ + day: date, + jobType: "rank_check", + // Fixtures make no HTTP call and cost nothing; they are counted as cache + // hits so the ledger still reflects the work done. + cacheHits: observations, + keywordsChecked: due.length, + }); + + return { + due: due.length, + processed: due.length, + observationsRecorded: observations, + eventsDetected, + provider, + }; +} + +/** Recompute Tracked Keyword Share of Search for today, overall and per cluster. */ +export async function recalculateShareOfSearch( + options: { now?: Date } = {}, +): Promise<{ status: string; entities: number; clusters: number }> { + const now = options.now ?? new Date(); + const date = today(now); + const entities = await store.listEntities(); + const entityIds = entities.map((e) => e.id); + const keywords = await p2.listTrackedKeywords(); + const clusters = await p2.listClusters(); + const clusterWeight = new Map(clusters.map((c) => [c.id, c.weight])); + + const withObservations = await Promise.all( + keywords.map(async (k) => ({ + trackedKeywordId: k.id, + clusterId: k.clusterId, + searchVolume: k.searchVolume, + clusterWeight: k.clusterId ? (clusterWeight.get(k.clusterId) ?? 1) : 1, + priorityWeight: priorityWeight(k.priority as Priority), + observations: await p2.observationsFor(k.id, date), + })), + ); + + const overall = computeShareOfSearch(withObservations, entityIds); + for (const result of overall.results) { + await p2an.saveShareSnapshot({ + entityId: result.entityId, + clusterId: null, + snapshotDate: date, + visibilityScore: result.visibilityScore, + share: result.share, + status: overall.status, + reason: overall.reason, + keywordsConsidered: overall.keywordsConsidered, + keywordsCovered: overall.keywordsCovered, + ctrModelVersion: CTR_MODEL_VERSION, + }); + } + + let clusterCount = 0; + for (const cluster of clusters) { + const subset = withObservations.filter((k) => k.clusterId === cluster.id); + if (subset.length === 0) continue; + const result = computeShareOfSearch(subset, entityIds); + for (const row of result.results) { + await p2an.saveShareSnapshot({ + entityId: row.entityId, + clusterId: cluster.id, + snapshotDate: date, + visibilityScore: row.visibilityScore, + share: row.share, + status: result.status, + reason: result.reason, + keywordsConsidered: result.keywordsConsidered, + keywordsCovered: result.keywordsCovered, + ctrModelVersion: CTR_MODEL_VERSION, + }); + } + clusterCount += 1; + } + + // A material move in overall share is itself alert-worthy. + const history = await p2an.shareHistory( + new Date(now.getTime() - 14 * 86_400_000).toISOString().slice(0, 10), + ); + for (const result of overall.results) { + const prior = history + .filter( + (h) => + h.entityId === result.entityId && + h.clusterId === null && + h.snapshotDate < date, + ) + .at(-1); + const event = detectShareShift({ + entityId: result.entityId, + previousShare: prior?.share ?? null, + currentShare: result.share, + snapshotDate: date, + }); + if (event) await p2an.saveEvents([event]); + } + + return { + status: overall.status, + entities: overall.results.length, + clusters: clusterCount, + }; +} + +interface Phase2CostStatus { + costCentre: string; + providerStatus: string; + httpRequests: number; + meteredRequests: number; + paidTasks: number; + keywordsChecked: number; + cacheHits: number; + cacheMisses: number; + cacheHitRate: number | null; + estimatedCostUsd: number; + actualCostUsd: number; + dailyCostUsd: number; + costPerKeywordCheckUsd: number | null; + monthlyForecastUsd: number; + budgetRemainingUsd: number; + monthlyCapUsd: number; + dailyCapUsd: number; + blockedByBudget: number; +} + +export async function phase2CostStatus( + config: Phase0Config, + env: object, + now: Date = new Date(), +): Promise { + const day = today(now); + const month = day.slice(0, 7); + const [monthTotals, dayTotals] = await Promise.all([ + p2jobs.phase2Totals(month), + p2jobs.phase2Totals(day), + ]); + const cacheTotal = monthTotals.cacheHits + monthTotals.cacheMisses; + const dayOfMonth = now.getUTCDate(); + const daysInMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + const monthlyCap = config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD; + + return { + // Distinct from phase 1 on purpose: "what is rank tracking costing us" + // must be answerable without subtracting one ledger from another. + costCentre: "dataforseo_search_intelligence_p2", + providerStatus: resolveProviderStatus(config, env), + httpRequests: monthTotals.httpRequests, + meteredRequests: monthTotals.meteredRequests, + paidTasks: monthTotals.paidTasks, + keywordsChecked: monthTotals.keywordsChecked, + cacheHits: monthTotals.cacheHits, + cacheMisses: monthTotals.cacheMisses, + cacheHitRate: cacheTotal > 0 ? monthTotals.cacheHits / cacheTotal : null, + estimatedCostUsd: microsToUsd(monthTotals.estimatedCostMicros), + actualCostUsd: microsToUsd(monthTotals.actualCostMicros), + dailyCostUsd: microsToUsd(dayTotals.actualCostMicros), + costPerKeywordCheckUsd: + monthTotals.keywordsChecked > 0 + ? microsToUsd( + Math.round( + monthTotals.actualCostMicros / monthTotals.keywordsChecked, + ), + ) + : null, + monthlyForecastUsd: + monthTotals.actualCostMicros === 0 + ? 0 + : microsToUsd( + Math.round( + (monthTotals.actualCostMicros / dayOfMonth) * daysInMonth, + ), + ), + budgetRemainingUsd: microsToUsd( + Math.max(0, monthlyCap - monthTotals.actualCostMicros), + ), + monthlyCapUsd: microsToUsd(monthlyCap), + dailyCapUsd: microsToUsd(config.SEO_DATAFORSEO_DAILY_COST_CAP_USD), + blockedByBudget: monthTotals.blockedByBudget, + }; +} diff --git a/src/server/morgana/si/p2-store.ts b/src/server/morgana/si/p2-store.ts new file mode 100644 index 00000000..eea9fae9 --- /dev/null +++ b/src/server/morgana/si/p2-store.ts @@ -0,0 +1,375 @@ +import { and, desc, eq, gte } from "drizzle-orm"; +import { db } from "@/db"; +import { keywordClusters, siRankSnapshots, trackedKeywords } from "@/db/schema"; +import { newId, nowIso } from "./ids"; +import { + classifyKeyword, + DEFAULT_CLUSTERS, + frequencyHoursFor, + isValidKeyword, + normalizeKeyword, + type Priority, +} from "./keywords"; +import { normalizePageUrl } from "./domains"; +import type { Observation } from "./gap"; + +/** + * Morgana Search Intelligence — phase 2 persistence. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P7). + * + * Same discipline as phase 1: every insert that could be retried carries a + * natural key behind a UNIQUE index, so a duplicated job, a retry and a manual + * trigger collapse into one row instead of one billable call each. + */ + +type ClusterRow = typeof keywordClusters.$inferSelect; +type TrackedKeywordRow = typeof trackedKeywords.$inferSelect; +type RankSnapshotRow = typeof siRankSnapshots.$inferSelect; + +class KeywordValidationError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = "KeywordValidationError"; + } +} + +const today = (now: Date = new Date()) => now.toISOString().slice(0, 10); + +// --- clusters --------------------------------------------------------------- + +export async function listClusters(): Promise { + return db.select().from(keywordClusters).orderBy(keywordClusters.name); +} + +export async function ensureDefaultClusters(): Promise { + const existing = await listClusters(); + const known = new Set(existing.map((c) => c.slug)); + const missing = DEFAULT_CLUSTERS.filter((c) => !known.has(c.slug)); + if (missing.length === 0) return 0; + await db.insert(keywordClusters).values( + missing.map((c) => ({ + id: newId("kc"), + name: c.name, + slug: c.slug, + description: null, + weight: c.weight, + enabled: true, + createdAt: nowIso(), + updatedAt: nowIso(), + })), + ); + return missing.length; +} + +export async function createCluster(input: { + name: string; + slug: string; + weight?: number; + description?: string | null; +}): Promise { + const row = { + id: newId("kc"), + name: input.name.trim().slice(0, 120), + slug: input.slug.trim().toLowerCase().slice(0, 80), + description: input.description ?? null, + weight: input.weight ?? 1, + enabled: true, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await db.insert(keywordClusters).values(row); + return row; +} + +export async function updateCluster( + id: string, + patch: { name?: string; weight?: number; enabled?: boolean }, +): Promise { + const set: Record = { updatedAt: nowIso() }; + if (patch.name !== undefined) set.name = patch.name.trim().slice(0, 120); + if (patch.weight !== undefined) set.weight = patch.weight; + if (patch.enabled !== undefined) set.enabled = patch.enabled; + await db.update(keywordClusters).set(set).where(eq(keywordClusters.id, id)); + const rows = await db + .select() + .from(keywordClusters) + .where(eq(keywordClusters.id, id)) + .limit(1); + return rows[0] ?? null; +} + +// --- tracked keywords ------------------------------------------------------- + +interface CreateKeywordInput { + keyword: string; + priority?: Priority; + clusterId?: string | null; + locationCode?: number; + languageCode?: string; + alertingEnabled?: boolean; +} + +/** + * Create one tracked keyword, auto-clustering when no cluster is given. + * + * Returns null when the keyword already exists for that market — the caller + * treats that as a skip, not an error, so a re-import is idempotent. + */ +export async function createTrackedKeyword( + input: CreateKeywordInput, + clusters: ClusterRow[], +): Promise { + if (!isValidKeyword(input.keyword)) { + throw new KeywordValidationError( + "Keyword must be between 2 and 200 characters", + "keyword_invalid", + ); + } + const normalized = normalizeKeyword(input.keyword); + const priority = input.priority ?? "normal"; + const rule = classifyKeyword(normalized); + const clusterId = + input.clusterId ?? clusters.find((c) => c.slug === rule?.slug)?.id ?? null; + + const row = { + id: newId("tk"), + keyword: input.keyword.trim().slice(0, 200), + normalizedKeyword: normalized, + clusterId, + priority, + locationCode: input.locationCode ?? 2380, + languageCode: input.languageCode ?? "it", + device: "desktop" as const, + trackingFrequencyHours: frequencyHoursFor(priority), + trackingEnabled: true, + alertingEnabled: input.alertingEnabled ?? true, + searchVolume: null, + lastCheckedAt: null, + // Due immediately: a newly added keyword should be measured on the next + // tick rather than waiting out a full interval. + nextCheckAt: nowIso(), + createdAt: nowIso(), + updatedAt: nowIso(), + disabledAt: null, + }; + try { + await db.insert(trackedKeywords).values(row); + return row; + } catch { + return null; // already tracked for this market + } +} + +export async function bulkImportKeywords( + inputs: readonly CreateKeywordInput[], +): Promise<{ created: number; skipped: number }> { + const clusters = await listClusters(); + let created = 0; + let skipped = 0; + for (const input of inputs) { + const row = await createTrackedKeyword(input, clusters); + if (row) created += 1; + else skipped += 1; + } + return { created, skipped }; +} + +export async function listTrackedKeywords( + options: { + includeDisabled?: boolean; + } = {}, +): Promise { + const rows = await db + .select() + .from(trackedKeywords) + .orderBy(trackedKeywords.priority, trackedKeywords.normalizedKeyword); + return options.includeDisabled ? rows : rows.filter((r) => r.trackingEnabled); +} + +export async function updateTrackedKeyword( + id: string, + patch: { + priority?: Priority; + clusterId?: string | null; + trackingEnabled?: boolean; + alertingEnabled?: boolean; + searchVolume?: number | null; + }, +): Promise { + const set: Record = { updatedAt: nowIso() }; + if (patch.priority !== undefined) { + set.priority = patch.priority; + // The cadence follows the priority; changing one without the other would + // leave a critical keyword on a fortnightly schedule. + set.trackingFrequencyHours = frequencyHoursFor(patch.priority); + } + if (patch.clusterId !== undefined) set.clusterId = patch.clusterId; + if (patch.trackingEnabled !== undefined) { + set.trackingEnabled = patch.trackingEnabled; + set.disabledAt = patch.trackingEnabled ? null : nowIso(); + } + if (patch.alertingEnabled !== undefined) + set.alertingEnabled = patch.alertingEnabled; + if (patch.searchVolume !== undefined) set.searchVolume = patch.searchVolume; + await db.update(trackedKeywords).set(set).where(eq(trackedKeywords.id, id)); + const rows = await db + .select() + .from(trackedKeywords) + .where(eq(trackedKeywords.id, id)) + .limit(1); + return rows[0] ?? null; +} + +/** + * Keywords whose scheduled check is due, highest priority first. + * + * The ordering is the budget policy in SQL: when the cap bites mid-run, the + * work that got done is the work that mattered most (§14). + */ +export async function dueKeywords( + limit: number, + now: Date = new Date(), +): Promise { + const rows = await db + .select() + .from(trackedKeywords) + .where(eq(trackedKeywords.trackingEnabled, true)); + const priorityRank: Record = { + critical: 0, + high: 1, + normal: 2, + low: 3, + }; + return rows + .filter( + (r) => + !r.nextCheckAt || new Date(r.nextCheckAt).getTime() <= now.getTime(), + ) + .toSorted( + (a, b) => + (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9), + ) + .slice(0, limit); +} + +export async function markChecked( + id: string, + frequencyHours: number, + now: Date = new Date(), +): Promise { + await db + .update(trackedKeywords) + .set({ + lastCheckedAt: now.toISOString(), + nextCheckAt: new Date( + now.getTime() + frequencyHours * 3_600_000, + ).toISOString(), + updatedAt: now.toISOString(), + }) + .where(eq(trackedKeywords.id, id)); +} + +// --- rank snapshots --------------------------------------------------------- + +interface RecordRankInput { + trackedKeywordId: string; + entityId: string; + locationCode: number; + languageCode: string; + rankGroup: number | null; + rankAbsolute: number | null; + rankingUrl: string | null; + provider: "dataforseo" | "fixture"; + estimatedCostMicros?: number; + actualCostMicros?: number; + now?: Date; +} + +export async function recordRank(input: RecordRankInput): Promise { + const now = input.now ?? new Date(); + const snapshotDate = today(now); + const dedupeKey = `${input.trackedKeywordId}|${input.entityId}|${snapshotDate}`; + try { + await db.insert(siRankSnapshots).values({ + id: newId("rs"), + trackedKeywordId: input.trackedKeywordId, + entityId: input.entityId, + snapshotAt: now.toISOString(), + snapshotDate, + locationCode: input.locationCode, + languageCode: input.languageCode, + device: "desktop", + rankGroup: input.rankGroup, + rankAbsolute: input.rankAbsolute, + rankingUrl: input.rankingUrl, + normalizedRankingUrl: input.rankingUrl + ? normalizePageUrl(input.rankingUrl).slice(0, 2000) + : null, + // Absence is recorded as absence, never as a sentinel position. + isFound: input.rankGroup !== null, + provider: input.provider, + estimatedCostMicros: input.estimatedCostMicros ?? 0, + actualCostMicros: input.actualCostMicros ?? 0, + dedupeKey, + createdAt: now.toISOString(), + }); + return true; + } catch { + return false; // already recorded for today + } +} + +/** Observations for one keyword on a given date, shaped for the pure logic. */ +export async function observationsFor( + trackedKeywordId: string, + snapshotDate: string, +): Promise { + const rows = await db + .select() + .from(siRankSnapshots) + .where( + and( + eq(siRankSnapshots.trackedKeywordId, trackedKeywordId), + eq(siRankSnapshots.snapshotDate, snapshotDate), + ), + ); + return rows.map((r) => ({ + entityId: r.entityId, + rankGroup: r.rankGroup, + isFound: r.isFound, + rankingUrl: r.rankingUrl, + })); +} + +/** The distinct snapshot dates for a keyword, newest first. */ +export async function recentSnapshotDates( + trackedKeywordId: string, + limit = 3, +): Promise { + const rows = await db + .select({ snapshotDate: siRankSnapshots.snapshotDate }) + .from(siRankSnapshots) + .where(eq(siRankSnapshots.trackedKeywordId, trackedKeywordId)) + .orderBy(desc(siRankSnapshots.snapshotDate)); + return [...new Set(rows.map((r) => r.snapshotDate))].slice(0, limit); +} + +export async function rankHistory( + trackedKeywordId: string, + sinceDate: string, +): Promise { + return db + .select() + .from(siRankSnapshots) + .where( + and( + eq(siRankSnapshots.trackedKeywordId, trackedKeywordId), + gte(siRankSnapshots.snapshotDate, sinceDate), + ), + ) + .orderBy(siRankSnapshots.snapshotDate); +} diff --git a/src/server/morgana/si/projections.ts b/src/server/morgana/si/projections.ts new file mode 100644 index 00000000..31294447 --- /dev/null +++ b/src/server/morgana/si/projections.ts @@ -0,0 +1,119 @@ +import type * as store from "./store"; +import type * as service from "./service"; + +/** + * Morgana Search Intelligence — wire projections. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * The only place an internal row becomes a public payload. Split out of api.ts + * to keep both files inside the 400-line module limit, and because "what we + * expose" is a different concern from "how we route". + */ + +/** Public projection of an entity. No internal-only field leaves the engine. */ +export function projectEntity(row: store.SearchEntityRow) { + return { + id: row.id, + display_name: row.displayName, + canonical_domain: row.canonicalDomain, + normalized_domain: row.normalizedDomain, + entity_type: row.entityType, + enabled: row.enabled, + priority: row.priority, + include_subdomains: row.includeSubdomains, + location_code: row.locationCode, + language_code: row.languageCode, + refresh_interval_hours: row.refreshIntervalHours, + backlink_interval_hours: row.backlinkIntervalHours, + last_refreshed_at: row.lastRefreshedAt, + created_at: row.createdAt, + updated_at: row.updatedAt, + disabled_at: row.disabledAt, + }; +} + +function projectDelta(delta: { + status: string; + absolute: number | null; + relative: number | null; +}) { + return { + status: delta.status, + absolute: delta.absolute, + relative: delta.relative, + }; +} + +export function projectDeltas(deltas: service.DomainOverview["deltas"]) { + if (!deltas) return null; + return { + traffic_1d: projectDelta(deltas.trafficDelta1d), + traffic_7d: projectDelta(deltas.trafficDelta7d), + traffic_30d: projectDelta(deltas.trafficDelta30d), + keywords_1d: projectDelta(deltas.keywordCountDelta1d), + keywords_7d: projectDelta(deltas.keywordCountDelta7d), + keywords_30d: projectDelta(deltas.keywordCountDelta30d), + backlinks_7d: projectDelta(deltas.backlinkDelta7d), + referring_domains_7d: projectDelta(deltas.referringDomainDelta7d), + }; +} + +interface KeywordRecord { + keyword: string; + rankGroup: number | null; + rankAbsolute: number | null; + searchVolume: number | null; + estimatedTraffic: number | null; + cpc: number | null; + keywordDifficulty: number | null; + searchIntent: string | null; + rankingUrl: string | null; + serpUpdatedAt: string | null; + position: number; +} + +export function projectKeyword(kw: KeywordRecord) { + return { + keyword: kw.keyword, + // The user-facing organic position is rank_group; rank_absolute counts ads + // and SERP features and is exposed separately, never conflated. + organic_position: kw.rankGroup, + rank_group: kw.rankGroup, + rank_absolute: kw.rankAbsolute, + search_volume: kw.searchVolume, + estimated_traffic: kw.estimatedTraffic, + cpc: kw.cpc, + keyword_difficulty: kw.keywordDifficulty, + search_intent: kw.searchIntent, + ranking_url: kw.rankingUrl, + serp_updated_at: kw.serpUpdatedAt, + position: kw.position, + }; +} + +interface PageRecord { + url: string; + normalizedUrl: string; + estimatedTraffic: number | null; + keywordCount: number | null; + topKeyword: string | null; + topKeywordPosition: number | null; + pageTitle: string | null; + lastSeenAt: string | null; + position: number; +} + +export function projectPage(page: PageRecord) { + return { + url: page.url, + normalized_url: page.normalizedUrl, + estimated_traffic: page.estimatedTraffic, + keyword_count: page.keywordCount, + top_keyword: page.topKeyword, + top_keyword_position: page.topKeywordPosition, + page_title: page.pageTitle, + last_seen_at: page.lastSeenAt, + position: page.position, + }; +} diff --git a/src/server/morgana/si/router.ts b/src/server/morgana/si/router.ts new file mode 100644 index 00000000..23c3d9e2 --- /dev/null +++ b/src/server/morgana/si/router.ts @@ -0,0 +1,373 @@ +import { isEnabled, type Phase0Config } from "../phase0-env"; +import * as service from "./service"; +import { costStatus } from "./cost"; +import * as store from "./store"; +import { + projectDeltas, + projectEntity, + projectKeyword, + projectPage, +} from "./projections"; +import { dispatchPhase2 } from "./p2-router"; +import { + badRequest, + envelope, + json, + readJson, + bool, + num, + str, + clamp, +} from "./http"; + +/** + * Morgana Search Intelligence — request routing. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Split from api.ts so both stay inside the 400-line module limit, and so the + * entry point (auth posture, logging, error sanitising) reads separately from + * the route table. + */ + +/** + * One context object instead of six positional parameters. The repo caps + * parameters at five, and a context is the better shape anyway: every handler + * below needs the same five things, and adding a sixth later should not ripple + * through every signature. + */ +export interface SiRequestContext { + route: string; + request: Request; + url: URL; + config: Phase0Config; + env: object; + providerStatus: service.ProviderStatus; +} + +/** + * Read surfaces. Split from the mutating ones so neither function exceeds the + * repo's complexity ceiling, and so "what can change state" is a single + * readable list rather than a branch buried in a 60-way conditional. + */ +async function dispatchReads(ctx: SiRequestContext): Promise { + const { route, request, url, config, providerStatus } = ctx; + const method = request.method; + + if (route === "capabilities" && method === "GET") { + return json( + envelope( + config, + { + domain_overview: isEnabled(config.SEARCH_INTELLIGENCE_ENABLED), + competitor_comparison: isEnabled(config.SEARCH_INTELLIGENCE_ENABLED), + top_keywords: true, + top_pages: true, + backlink_overview: true, + visibility_share: true, + manual_refresh: true, + paid_calls: isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED), + // Explicitly false, not absent, so a Phase-2 reader sees the boundary. + keyword_gap: false, + rank_tracking: false, + backlink_details: false, + site_audit: isEnabled(config.SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED), + mcp: isEnabled(config.SEARCH_INTELLIGENCE_MCP_ENABLED), + ai: isEnabled(config.SEARCH_INTELLIGENCE_AI_ENABLED), + }, + { providerStatus }, + ), + ); + } + + if (route === "entities" && method === "GET") { + const includeDisabled = url.searchParams.get("includeDisabled") === "true"; + const entities = await store.listEntities({ includeDisabled }); + return json( + envelope( + config, + { entities: entities.map(projectEntity) }, + { providerStatus }, + ), + ); + } + + if (route === "entities" && method === "POST") { + const body = await readJson(request); + const displayName = str(body.display_name); + const domain = str(body.domain); + const entityType = str(body.entity_type); + if (!displayName) + return badRequest("display_name_required", "display_name is required"); + if (!domain) return badRequest("domain_required", "domain is required"); + if ( + entityType !== "primary" && + entityType !== "competitor" && + entityType !== "watch" + ) { + return badRequest( + "entity_type_invalid", + "entity_type must be primary, competitor or watch", + ); + } + const priority = str(body.priority); + const created = await store.createEntity({ + displayName, + domain, + entityType, + priority: + priority === "high" || priority === "low" || priority === "normal" + ? priority + : undefined, + includeSubdomains: bool(body.include_subdomains), + locationCode: num(body.location_code), + languageCode: str(body.language_code), + refreshIntervalHours: num(body.refresh_interval_hours), + backlinkIntervalHours: num(body.backlink_interval_hours), + }); + return json( + envelope(config, { entity: projectEntity(created) }, { providerStatus }), + 201, + ); + } + + const entityMatch = /^entities\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (entityMatch?.[1] && method === "PATCH") { + const body = await readJson(request); + const priority = str(body.priority); + const updated = await store.updateEntity(entityMatch[1], { + displayName: str(body.display_name), + priority: + priority === "high" || priority === "low" || priority === "normal" + ? priority + : undefined, + includeSubdomains: bool(body.include_subdomains), + locationCode: num(body.location_code), + languageCode: str(body.language_code), + refreshIntervalHours: num(body.refresh_interval_hours), + backlinkIntervalHours: num(body.backlink_interval_hours), + enabled: bool(body.enabled), + }); + if (!updated) return json({ error: "entity not found" }, 404); + return json( + envelope(config, { entity: projectEntity(updated) }, { providerStatus }), + ); + } + + const overviewMatch = /^overview\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (overviewMatch?.[1] && method === "GET") { + const overview = await service.domainOverview(overviewMatch[1], { + keywordLimit: clamp(url.searchParams.get("keywordLimit"), 20, 100), + pageLimit: clamp(url.searchParams.get("pageLimit"), 20, 100), + }); + if (!overview) return json({ error: "entity not found" }, 404); + return json( + envelope( + config, + { + entity: projectEntity(overview.entity), + snapshot: overview.snapshot, + deltas: projectDeltas(overview.deltas), + top_keywords: overview.topKeywords.map(projectKeyword), + top_pages: overview.topPages.map(projectPage), + freshness: overview.freshness, + }, + { providerStatus }, + ), + ); + } + + const historyMatch = /^history\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (historyMatch?.[1] && method === "GET") { + const days = clamp(url.searchParams.get("days"), 30, 365) ?? 30; + const since = new Date(Date.now() - days * 86_400_000) + .toISOString() + .slice(0, 10); + const history = await store.snapshotHistory(historyMatch[1], since); + return json( + envelope( + config, + { + entity_id: historyMatch[1], + days, + points: history.map((p) => ({ + snapshot_date: p.snapshotDate, + organic_traffic_estimate: p.organicTrafficEstimate, + organic_keyword_count: p.organicKeywordCount, + backlink_count: p.backlinkCount, + referring_domain_count: p.referringDomainCount, + })), + }, + { providerStatus }, + ), + ); + } + + const keywordsMatch = /^keywords\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (keywordsMatch?.[1] && method === "GET") { + const snapshot = await store.latestSnapshot(keywordsMatch[1]); + if (!snapshot) { + return json( + envelope(config, { keywords: [], snapshot: null }, { providerStatus }), + ); + } + const rows = await store.snapshotKeywords( + snapshot.id, + clamp(url.searchParams.get("limit"), 100, 100) ?? 100, + ); + return json( + envelope( + config, + { + snapshot: { id: snapshot.id, snapshot_date: snapshot.snapshotDate }, + keywords: rows.map(projectKeyword), + }, + { providerStatus }, + ), + ); + } + + const pagesMatch = /^pages\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (pagesMatch?.[1] && method === "GET") { + const snapshot = await store.latestSnapshot(pagesMatch[1]); + if (!snapshot) { + return json( + envelope(config, { pages: [], snapshot: null }, { providerStatus }), + ); + } + const rows = await store.snapshotPages( + snapshot.id, + clamp(url.searchParams.get("limit"), 100, 100) ?? 100, + ); + return json( + envelope( + config, + { + snapshot: { id: snapshot.id, snapshot_date: snapshot.snapshotDate }, + pages: rows.map(projectPage), + }, + { providerStatus }, + ), + ); + } + + return null; +} + +/** Mutating surfaces: comparison, refresh, the scheduler tick and cost. */ +async function dispatchOperations( + ctx: SiRequestContext, +): Promise { + const { route, request, url, config, env, providerStatus } = ctx; + const method = request.method; + if (route === "compare" && method === "POST") { + const body = await readJson(request); + const ids = Array.isArray(body.entity_ids) + ? body.entity_ids + .filter((v): v is string => typeof v === "string") + .slice(0, 20) + : []; + if (ids.length < 1) { + return badRequest( + "entity_ids_required", + "entity_ids must contain at least one id", + ); + } + const result = await service.compareDomains(ids); + return json( + envelope( + config, + { + rows: result.rows.map((row) => ({ + entity: projectEntity(row.entity), + snapshot_date: row.snapshotDate, + organic_traffic_estimate: row.organicTrafficEstimate, + organic_keyword_count: row.organicKeywordCount, + backlink_count: row.backlinkCount, + referring_domain_count: row.referringDomainCount, + deltas: projectDeltas(row.deltas), + visibility_share: row.visibilityShare, + visibility_share_status: row.visibilityShareStatus, + top_keyword: row.topKeyword, + top_page: row.topPage, + freshness: row.freshness, + })), + visibility_share_status: result.visibility.status, + visibility_share_reason: result.visibility.reason ?? null, + }, + { providerStatus }, + ), + ); + } + + if (route === "refresh" && method === "POST") { + const body = await readJson(request); + const entityId = str(body.entity_id); + if (!entityId) + return badRequest("entity_id_required", "entity_id is required"); + const trigger = str(body.trigger) === "scheduled" ? "scheduled" : "manual"; + const outcome = await service.refreshEntity(config, env, { + entityId, + trigger, + requestedBy: str(body.requested_by) ?? null, + }); + return json(envelope(config, { job: outcome }, { providerStatus })); + } + + const jobMatch = /^jobs\/([A-Za-z0-9_-]{1,64})$/.exec(route); + if (jobMatch?.[1] && method === "GET") { + const job = await store.getJob(jobMatch[1]); + if (!job) return json({ error: "job not found" }, 404); + return json(envelope(config, { job }, { providerStatus })); + } + + if (route === "jobs" && method === "GET") { + const jobs = await store.recentJobs( + clamp(url.searchParams.get("limit"), 50, 200) ?? 50, + ); + return json(envelope(config, { jobs }, { providerStatus })); + } + + /** + * Scheduler tick. Morgana's cron calls this; the engine has no cron of its + * own, which is what guarantees it cannot spend unless asked. + */ + if (route === "tick" && method === "POST") { + const entities = await store.listEntities(); + const due = service.selectDueEntities(entities); + const outcomes: service.RefreshOutcome[] = []; + // Bounded per tick so one invocation cannot exhaust the subrequest budget + // or the daily cap in a single burst. + for (const entity of due.slice(0, 3)) { + outcomes.push( + await service.refreshEntity(config, env, { + entityId: entity.id, + trigger: "scheduled", + }), + ); + } + return json( + envelope( + config, + { due: due.length, processed: outcomes.length, outcomes }, + { providerStatus }, + ), + ); + } + + if (route === "cost" && method === "GET") { + const status = await costStatus(config, env); + return json(envelope(config, status, { providerStatus })); + } + + return null; +} + +export async function dispatch(ctx: SiRequestContext): Promise { + return ( + (await dispatchReads(ctx)) ?? + (await dispatchOperations(ctx)) ?? + (await dispatchPhase2(ctx)) ?? + json({ error: "not found" }, 404) + ); +} diff --git a/src/server/morgana/si/service.ts b/src/server/morgana/si/service.ts new file mode 100644 index 00000000..f7df831a --- /dev/null +++ b/src/server/morgana/si/service.ts @@ -0,0 +1,453 @@ +import { isEnabled, type Phase0Config } from "../phase0-env"; +import { checkBudget } from "./budget"; +import { fixtureKeywords, fixtureOverview, fixturePages } from "./fixtures"; +import { + computeDeltas, + computeVisibilityShare, + type DeltaSet, + type VisibilityShareOutcome, +} from "./metrics"; +import * as store from "./store"; +import * as ledger from "./ledger-store"; +import type { SnapshotKeywordRow, SnapshotPageRow } from "./store"; +import { getEnvValueSync } from "@/server/lib/runtime-env"; + +/** + * Morgana Search Intelligence — orchestration. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * Sits between the private API surface and the store. Every path that could + * cost money passes through `checkBudget` first, and every provider call is + * recorded in the ledger whether it succeeded, failed or was served from cache. + * + * NOTE ON SCHEDULING: this engine has no cron. Refresh is driven by Morgana's + * existing scheduler over the Service Binding, which preserves the Phase-0 + * property that the engine cannot spend unless Morgana asks it to. + */ + +const TOP_KEYWORD_LIMIT = 100; +const TOP_PAGE_LIMIT = 100; + +export type ProviderStatus = + | "not_configured" + | "fixture" + | "live" + | "budget_exhausted" + | "circuit_open"; + +export interface RefreshOutcome { + entityId: string; + jobId: string | null; + status: "created" | "reused" | "skipped" | "failed"; + snapshotId: string | null; + reason?: string; + costMicros: number; +} + +function monthOf(date: string): string { + return date.slice(0, 7); +} + +/** Resolve the budget limits from the Phase-0 config. */ +function limitsFrom(config: Phase0Config) { + return { + dailyCapMicros: config.SEO_DATAFORSEO_DAILY_COST_CAP_USD, + monthlyCapMicros: config.SEO_DATAFORSEO_MONTHLY_COST_CAP_USD, + paidCallsEnabled: isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED), + circuitBreakerThreshold: 5, + }; +} + +function credentialPresent(env: object): boolean { + const value = + // getEnvValueSync reads an interface-typed env without an assertion and + // applies the Morgana credential alias, so there is nothing to cast. + getEnvValueSync(env, "DATAFORSEO_SEARCH_INTELLIGENCE_API_KEY") ?? + getEnvValueSync(env, "DATAFORSEO_API_KEY"); + return typeof value === "string" && value.trim() !== ""; +} + +/** + * Which data source a refresh would use right now. + * + * `fixture` is only ever chosen when paid calls are OFF. The engine must never + * silently serve synthetic numbers in a configuration where an operator has + * asked for real ones — a fixture mistaken for a measurement is worse than an + * outage. + */ +export function resolveProviderStatus( + config: Phase0Config, + env: object, +): ProviderStatus { + const paidCalls = isEnabled(config.SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED); + if (!paidCalls) return credentialPresent(env) ? "not_configured" : "fixture"; + return credentialPresent(env) ? "live" : "not_configured"; +} + +/** + * Refresh one entity, producing at most one snapshot per entity/market/day. + * + * Returns rather than throws for every expected outcome (already refreshed, + * budget exhausted, entity disabled), because the caller is a scheduler tick + * that must keep going for the remaining entities. + */ +export async function refreshEntity( + config: Phase0Config, + env: object, + input: { + entityId: string; + trigger: "scheduled" | "manual"; + requestedBy?: string | null; + force?: boolean; + now?: Date; + }, +): Promise { + const now = input.now ?? new Date(); + const snapshotDate = store.snapshotDateFor(now); + const month = monthOf(snapshotDate); + + const entity = await store.getEntity(input.entityId); + if (!entity) { + return { + entityId: input.entityId, + jobId: null, + status: "skipped", + snapshotId: null, + reason: "entity_not_found", + costMicros: 0, + }; + } + if (!entity.enabled) { + return { + entityId: entity.id, + jobId: null, + status: "skipped", + snapshotId: null, + reason: "entity_disabled", + costMicros: 0, + }; + } + + // Today's snapshot already exists — the cheapest possible outcome, and the + // one that makes a duplicated tick free rather than billable. + const dedupeKey = store.snapshotDedupeKey( + entity.id, + entity.locationCode, + entity.languageCode, + snapshotDate, + ); + const existing = await store.latestSnapshot(entity.id); + if (!input.force && existing?.dedupeKey === dedupeKey) { + return { + entityId: entity.id, + jobId: null, + status: "reused", + snapshotId: existing.id, + reason: "snapshot_already_current", + costMicros: 0, + }; + } + + const job = await store.claimJob({ + entityId: entity.id, + snapshotDate, + trigger: input.trigger, + requestedBy: input.requestedBy ?? null, + }); + if (!job) { + return { + entityId: entity.id, + jobId: null, + status: "skipped", + snapshotId: null, + reason: "job_already_claimed", + costMicros: 0, + }; + } + + const providerStatus = resolveProviderStatus(config, env); + + // Fixture mode: no provider call, no spend, and the snapshot is stamped + // `source: "fixture"` so nothing downstream can mistake it for measurement. + if (providerStatus === "fixture") { + const metrics = fixtureOverview(entity.normalizedDomain, snapshotDate); + const keywords = fixtureKeywords( + entity.normalizedDomain, + snapshotDate, + TOP_KEYWORD_LIMIT, + ); + const pages = fixturePages( + entity.normalizedDomain, + snapshotDate, + TOP_PAGE_LIMIT, + ); + const result = await store.persistSnapshot({ + entity, + snapshotDate, + metrics, + keywords, + pages, + source: "fixture", + estimatedCostMicros: 0, + actualCostMicros: 0, + }); + await ledger.recordUsage({ + day: snapshotDate, + entityId: entity.id, + endpointPath: "fixture/domain_overview", + meteringClass: "cache", + }); + await store.finishJob(job.id, { + status: "succeeded", + snapshotId: result.snapshotId, + }); + return { + entityId: entity.id, + jobId: job.id, + status: result.created ? "created" : "reused", + snapshotId: result.snapshotId, + reason: "fixture_source", + costMicros: 0, + }; + } + + // Any real collection needs both a credential and budget headroom. + if (providerStatus === "not_configured") { + await store.finishJob(job.id, { + status: "skipped", + skipReason: "credential_not_configured", + }); + return { + entityId: entity.id, + jobId: job.id, + status: "skipped", + snapshotId: null, + reason: "credential_not_configured", + costMicros: 0, + }; + } + + const budgetState = await ledger.readBudgetState(month); + const decision = checkBudget( + limitsFrom(config), + { + dailyCostMicros: + budgetState.currentDay === snapshotDate + ? budgetState.dailyCostMicros + : 0, + monthlyCostMicros: budgetState.monthlyCostMicros, + consecutiveFailures: budgetState.consecutiveFailures, + circuitOpenedAt: budgetState.circuitOpenedAt, + }, + now, + ); + if (!decision.allowed) { + await ledger.recordUsage({ + day: snapshotDate, + entityId: entity.id, + endpointPath: "dataforseo_labs/google/domain_rank_overview/live", + meteringClass: "paid_submission", + blockedByBudget: true, + }); + await store.finishJob(job.id, { + status: "skipped", + skipReason: decision.reason ?? "budget_blocked", + }); + return { + entityId: entity.id, + jobId: job.id, + status: "skipped", + snapshotId: null, + reason: decision.reason ?? "budget_blocked", + costMicros: 0, + }; + } + + // Live collection is deliberately NOT implemented in phase 1: the dedicated + // credential does not exist, so this branch is unreachable in every deployed + // configuration. Leaving it as an explicit refusal rather than a half-written + // provider call means the first person to add a credential gets a clear + // failure telling them what to implement, instead of a silent partial result. + await store.finishJob(job.id, { + status: "skipped", + skipReason: "live_collection_not_enabled", + }); + return { + entityId: entity.id, + jobId: job.id, + status: "skipped", + snapshotId: null, + reason: "live_collection_not_enabled", + costMicros: 0, + }; +} + +/** Entities whose scheduled refresh is due. */ +export function selectDueEntities( + entities: readonly store.SearchEntityRow[], + now: Date = new Date(), +): store.SearchEntityRow[] { + return entities.filter((entity) => { + if (!entity.enabled) return false; + if (!entity.lastRefreshedAt) return true; + const last = new Date(entity.lastRefreshedAt).getTime(); + if (Number.isNaN(last)) return true; + return now.getTime() - last >= entity.refreshIntervalHours * 3_600_000; + }); +} + +export interface DomainOverview { + entity: store.SearchEntityRow; + snapshot: { + id: string; + snapshotDate: string; + fetchedAt: string; + source: string; + organicTrafficEstimate: number | null; + organicKeywordCount: number | null; + backlinkCount: number | null; + referringDomainCount: number | null; + rankSignal: number | null; + } | null; + deltas: DeltaSet | null; + // Drizzle's inferred row types rather than `unknown[]`: the store already + // knows the shape, which lets callers read `topKeywords[0]?.keyword` without + // asserting anything. + topKeywords: SnapshotKeywordRow[]; + topPages: SnapshotPageRow[]; + freshness: "fresh" | "stale" | "none"; +} + +const STALE_AFTER_HOURS = 48; + +export async function domainOverview( + entityId: string, + options: { keywordLimit?: number; pageLimit?: number; now?: Date } = {}, +): Promise { + const entity = await store.getEntity(entityId); + if (!entity) return null; + const snapshot = await store.latestSnapshot(entityId); + if (!snapshot) { + return { + entity, + snapshot: null, + deltas: null, + topKeywords: [], + topPages: [], + freshness: "none", + }; + } + const history = await store.snapshotHistory( + entityId, + isoDaysAgo(options.now ?? new Date(), 90), + ); + const deltas = computeDeltas( + { + snapshotDate: snapshot.snapshotDate, + organicTrafficEstimate: snapshot.organicTrafficEstimate, + organicKeywordCount: snapshot.organicKeywordCount, + backlinkCount: snapshot.backlinkCount, + referringDomainCount: snapshot.referringDomainCount, + }, + history, + ); + const [topKeywords, topPages] = await Promise.all([ + store.snapshotKeywords(snapshot.id, options.keywordLimit ?? 20), + store.snapshotPages(snapshot.id, options.pageLimit ?? 20), + ]); + const ageMs = + (options.now ?? new Date()).getTime() - + new Date(snapshot.fetchedAt).getTime(); + return { + entity, + snapshot: { + id: snapshot.id, + snapshotDate: snapshot.snapshotDate, + fetchedAt: snapshot.fetchedAt, + source: snapshot.source, + organicTrafficEstimate: snapshot.organicTrafficEstimate, + organicKeywordCount: snapshot.organicKeywordCount, + backlinkCount: snapshot.backlinkCount, + referringDomainCount: snapshot.referringDomainCount, + rankSignal: snapshot.rankSignal, + }, + deltas, + topKeywords, + topPages, + freshness: ageMs > STALE_AFTER_HOURS * 3_600_000 ? "stale" : "fresh", + }; +} + +interface ComparisonRow { + entity: store.SearchEntityRow; + snapshotDate: string | null; + organicTrafficEstimate: number | null; + organicKeywordCount: number | null; + backlinkCount: number | null; + referringDomainCount: number | null; + deltas: DeltaSet | null; + visibilityShare: number | null; + visibilityShareStatus: string; + topKeyword: string | null; + topPage: string | null; + freshness: "fresh" | "stale" | "none"; +} + +interface ComparisonResult { + rows: ComparisonRow[]; + visibility: VisibilityShareOutcome; +} + +export async function compareDomains( + entityIds: readonly string[], + options: { now?: Date } = {}, +): Promise { + const now = options.now ?? new Date(); + const overviews = await Promise.all( + entityIds.map((id) => + domainOverview(id, { keywordLimit: 1, pageLimit: 1, now }), + ), + ); + const present = overviews.filter((o): o is DomainOverview => o !== null); + + const visibility = computeVisibilityShare( + present.map((o) => ({ + entityId: o.entity.id, + organicTrafficEstimate: o.snapshot?.organicTrafficEstimate ?? null, + locationCode: o.entity.locationCode, + languageCode: o.entity.languageCode, + // An entity with no snapshot cannot be dated; use the epoch so the + // contemporaneity check fails loudly rather than silently passing. + snapshotDate: o.snapshot?.snapshotDate ?? "1970-01-01", + })), + ); + const shareById = new Map( + visibility.results.map((r) => [r.entityId, r] as const), + ); + + return { + rows: present.map((o) => { + const share = shareById.get(o.entity.id); + return { + entity: o.entity, + snapshotDate: o.snapshot?.snapshotDate ?? null, + organicTrafficEstimate: o.snapshot?.organicTrafficEstimate ?? null, + organicKeywordCount: o.snapshot?.organicKeywordCount ?? null, + backlinkCount: o.snapshot?.backlinkCount ?? null, + referringDomainCount: o.snapshot?.referringDomainCount ?? null, + deltas: o.deltas, + visibilityShare: share?.share ?? null, + visibilityShareStatus: share?.status ?? "insufficient_data", + topKeyword: o.topKeywords[0]?.keyword ?? null, + topPage: o.topPages[0]?.url ?? null, + freshness: o.freshness, + }; + }), + visibility, + }; +} + +function isoDaysAgo(now: Date, days: number): string { + return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10); +} diff --git a/src/server/morgana/si/si-core.test.ts b/src/server/morgana/si/si-core.test.ts new file mode 100644 index 00000000..cc3946e3 --- /dev/null +++ b/src/server/morgana/si/si-core.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from "vitest"; +import { + DomainValidationError, + normalizeEntityDomain, + normalizePageUrl, +} from "./domains"; +import { + computeDelta, + computeDeltas, + computeVisibilityShare, + findBaseline, + type SnapshotPoint, +} from "./metrics"; +import { + checkBudget, + crossedThreshold, + detectUnexpectedSpend, + isMetered, + isProviderCall, + levelFor, + projectMonthEndMicros, + usdToMicros, +} from "./budget"; + +/** + * Morgana Search Intelligence — phase 1 core tests. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + */ + +describe("entity domain validation", () => { + it("normalises a bare domain", () => { + const result = normalizeEntityDomain("CheckSig.com"); + expect(result.normalized).toBe("checksig.com"); + expect(result.display).toBe("CheckSig.com"); + expect(result.isInternationalized).toBe(false); + }); + + it("strips protocol and www", () => { + expect(normalizeEntityDomain("https://www.conio.com").normalized).toBe( + "conio.com", + ); + }); + + it("reduces to the registrable domain unless subdomains are requested", () => { + expect(normalizeEntityDomain("blog.bitpanda.com").normalized).toBe( + "bitpanda.com", + ); + expect( + normalizeEntityDomain("blog.bitpanda.com", { includeSubdomains: true }) + .normalized, + ).toBe("blog.bitpanda.com"); + }); + + it("accepts a trailing dot and mixed case", () => { + expect(normalizeEntityDomain(" BINANCE.COM. ").normalized).toBe( + "binance.com", + ); + }); + + // §35 — each of these is a rejection, not a silent normalisation. + it("rejects credentials embedded in the URL", () => { + expect(() => normalizeEntityDomain("https://user:pass@evil.com")).toThrow( + DomainValidationError, + ); + expect(() => normalizeEntityDomain("user@evil.com")).toThrow( + /credentials/i, + ); + }); + + it("rejects non-HTTP schemes", () => { + expect(() => normalizeEntityDomain("file://etc/passwd")).toThrow(/http/i); + expect(() => normalizeEntityDomain("ftp://example.com")).toThrow(/http/i); + }); + + it("rejects a bare domain carrying a path", () => { + expect(() => normalizeEntityDomain("example.com/admin")).toThrow(/path/i); + }); + + it("rejects IP literals", () => { + expect(() => normalizeEntityDomain("127.0.0.1")).toThrow(); + expect(() => normalizeEntityDomain("http://169.254.169.254")).toThrow(); + expect(() => normalizeEntityDomain("10.0.0.1")).toThrow(); + expect(() => normalizeEntityDomain("[::1]")).toThrow(); + }); + + it("rejects loopback and private suffixes", () => { + expect(() => normalizeEntityDomain("localhost")).toThrow(); + expect(() => normalizeEntityDomain("service.internal")).toThrow(); + expect(() => normalizeEntityDomain("printer.local")).toThrow(); + }); + + it("rejects malformed and empty input", () => { + expect(() => normalizeEntityDomain("")).toThrow(/required/i); + expect(() => normalizeEntityDomain(" ")).toThrow(/required/i); + expect(() => normalizeEntityDomain("not a domain")).toThrow(); + expect(() => normalizeEntityDomain("example.invalidtld")).toThrow(); + }); + + it("rejects an over-long input before parsing", () => { + expect(() => normalizeEntityDomain(`${"a".repeat(300)}.com`)).toThrow( + /too long/i, + ); + }); + + it("flags an internationalised domain and keeps the stored form ASCII", () => { + // Showing punycode rather than Unicode is the homograph mitigation. + const result = normalizeEntityDomain("münchen.de"); + expect(result.normalized).toBe("xn--mnchen-3ya.de"); + expect(result.isInternationalized).toBe(true); + }); + + it("treats an already-punycode domain as internationalised", () => { + expect(normalizeEntityDomain("xn--mnchen-3ya.de").isInternationalized).toBe( + true, + ); + }); + + it("normalises two spellings of the same domain to one key", () => { + const a = normalizeEntityDomain("https://WWW.Coinbase.com/"); + const b = normalizeEntityDomain("coinbase.com"); + expect(a.normalized).toBe(b.normalized); + }); +}); + +describe("page url normalisation", () => { + it("strips protocol, www, fragment and trailing slash", () => { + expect(normalizePageUrl("https://www.checksig.com/blog/post/#top")).toBe( + "checksig.com/blog/post", + ); + }); + + it("removes tracking parameters but keeps meaningful ones", () => { + expect( + normalizePageUrl("https://checksig.com/p?utm_source=x&id=7&gclid=y"), + ).toBe("checksig.com/p?id=7"); + }); + + it("keeps the root path", () => { + expect(normalizePageUrl("https://checksig.com/")).toBe("checksig.com"); + }); + + it("returns unparseable input lowercased rather than dropping the row", () => { + expect(normalizePageUrl("::::")).toBe("::::"); + }); +}); + +describe("deltas", () => { + it("computes absolute and relative change", () => { + expect(computeDelta(120, 100)).toEqual({ + status: "ok", + absolute: 20, + relative: 0.2, + }); + }); + + it("reports insufficient_history when the baseline is missing", () => { + expect(computeDelta(120, null).status).toBe("insufficient_history"); + }); + + // The distinction that keeps the UI honest. + it("reports not_available when the current value is missing", () => { + expect(computeDelta(null, 100).status).toBe("not_available"); + }); + + it("never reports zero as a substitute for missing history", () => { + const delta = computeDelta(120, undefined); + expect(delta.absolute).toBeNull(); + expect(delta.relative).toBeNull(); + }); + + it("omits the percentage when the baseline is zero rather than emitting Infinity", () => { + const delta = computeDelta(5, 0); + expect(delta.status).toBe("ok"); + expect(delta.absolute).toBe(5); + expect(delta.relative).toBeNull(); + }); + + it("finds the nearest baseline inside the tolerance window", () => { + const history: SnapshotPoint[] = [ + point("2026-07-30", 100), + point("2026-08-01", 110), + ]; + expect(findBaseline(history, "2026-08-08", 7)?.snapshotDate).toBe( + "2026-08-01", + ); + }); + + it("returns no baseline when nothing falls inside the window", () => { + const history: SnapshotPoint[] = [point("2026-01-01", 100)]; + expect(findBaseline(history, "2026-08-08", 7)).toBeNull(); + }); + + it("excludes the current snapshot from its own baseline", () => { + const current = point("2026-08-08", 200); + const deltas = computeDeltas(current, [current]); + expect(deltas.trafficDelta1d.status).toBe("insufficient_history"); + }); + + it("computes a full delta set from a sparse weekly history", () => { + const current = point("2026-08-08", 200, 50); + const history = [ + point("2026-08-07", 190, 48), + point("2026-08-01", 150, 40), + point("2026-07-10", 100, 30), + ]; + const deltas = computeDeltas(current, history); + expect(deltas.trafficDelta1d.absolute).toBe(10); + expect(deltas.trafficDelta7d.absolute).toBe(50); + expect(deltas.trafficDelta30d.absolute).toBe(100); + expect(deltas.keywordCountDelta7d.absolute).toBe(10); + }); +}); + +describe("estimated organic visibility share", () => { + const base = { + locationCode: 2380, + languageCode: "it", + snapshotDate: "2026-08-08", + }; + + it("computes shares that sum to one", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + { entityId: "b", organicTrafficEstimate: 100, ...base }, + ]); + expect(outcome.status).toBe("ok"); + expect(outcome.results[0]?.share).toBeCloseTo(0.75, 6); + expect(outcome.results[1]?.share).toBeCloseTo(0.25, 6); + const sum = outcome.results.reduce((acc, r) => acc + (r.share ?? 0), 0); + expect(sum).toBeCloseTo(1, 6); + }); + + it("refuses with fewer than two domains", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + ]); + expect(outcome.status).toBe("insufficient_data"); + }); + + // A missing domain would silently inflate everyone else's share. + it("refuses when any compared domain has no traffic estimate", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + { entityId: "b", organicTrafficEstimate: null, ...base }, + ]); + expect(outcome.status).toBe("insufficient_data"); + expect(outcome.reason).toMatch(/no traffic estimate/); + expect(outcome.results.every((r) => r.share === null)).toBe(true); + }); + + it("refuses on a zero total instead of dividing by zero", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 0, ...base }, + { entityId: "b", organicTrafficEstimate: 0, ...base }, + ]); + expect(outcome.status).toBe("insufficient_data"); + expect(outcome.results.every((r) => r.share === null)).toBe(true); + }); + + it("refuses to mix incompatible markets", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + { + entityId: "b", + organicTrafficEstimate: 100, + locationCode: 2840, + languageCode: "en", + snapshotDate: "2026-08-08", + }, + ]); + expect(outcome.status).toBe("insufficient_data"); + expect(outcome.reason).toMatch(/market/i); + }); + + it("refuses non-contemporaneous snapshots", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + { + entityId: "b", + organicTrafficEstimate: 100, + locationCode: 2380, + languageCode: "it", + snapshotDate: "2026-06-01", + }, + ]); + expect(outcome.status).toBe("insufficient_data"); + expect(outcome.reason).toMatch(/contemporaneous/); + }); + + it("tolerates a small spread between snapshots", () => { + const outcome = computeVisibilityShare([ + { entityId: "a", organicTrafficEstimate: 300, ...base }, + { + entityId: "b", + organicTrafficEstimate: 100, + locationCode: 2380, + languageCode: "it", + snapshotDate: "2026-08-06", + }, + ]); + expect(outcome.status).toBe("ok"); + }); +}); + +describe("budget guard", () => { + const limits = { + dailyCapMicros: 250_000, + monthlyCapMicros: 5_000_000, + paidCallsEnabled: true, + circuitBreakerThreshold: 5, + }; + const usage = { + dailyCostMicros: 0, + monthlyCostMicros: 0, + consecutiveFailures: 0, + circuitOpenedAt: null, + }; + + it("allows a call inside both caps", () => { + expect(checkBudget(limits, usage).allowed).toBe(true); + }); + + it("blocks when paid calls are disabled", () => { + const decision = checkBudget({ ...limits, paidCallsEnabled: false }, usage); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("paid_calls_disabled"); + }); + + // Zero means "cannot spend", never "unlimited". + it("blocks on a zero cap even when the flag is on", () => { + expect(checkBudget({ ...limits, monthlyCapMicros: 0 }, usage).reason).toBe( + "zero_cost_cap", + ); + expect(checkBudget({ ...limits, dailyCapMicros: 0 }, usage).reason).toBe( + "zero_cost_cap", + ); + }); + + it("blocks at the daily cap", () => { + expect( + checkBudget(limits, { ...usage, dailyCostMicros: 250_000 }).reason, + ).toBe("daily_cap_reached"); + }); + + it("blocks at the monthly cap and reports exhausted", () => { + const decision = checkBudget(limits, { + ...usage, + monthlyCostMicros: 5_000_000, + }); + expect(decision.reason).toBe("monthly_cap_reached"); + expect(decision.level).toBe("exhausted"); + }); + + it("stops new billable submissions at 95%", () => { + const decision = checkBudget(limits, { + ...usage, + monthlyCostMicros: 4_800_000, + }); + expect(decision.allowed).toBe(false); + expect(decision.level).toBe("stopping"); + }); + + it("still allows calls at the warning and degraded thresholds", () => { + expect( + checkBudget(limits, { ...usage, monthlyCostMicros: 3_600_000 }).allowed, + ).toBe(true); + expect( + checkBudget(limits, { ...usage, monthlyCostMicros: 4_300_000 }).allowed, + ).toBe(true); + }); + + it("maps percentages to the documented ladder", () => { + expect(levelFor(10)).toBe("ok"); + expect(levelFor(70)).toBe("warning"); + expect(levelFor(85)).toBe("degraded"); + expect(levelFor(95)).toBe("stopping"); + expect(levelFor(100)).toBe("exhausted"); + }); + + it("blocks while the circuit breaker is open and recovers after cooldown", () => { + const now = new Date("2026-08-08T12:00:00Z"); + const justOpened = { + ...usage, + circuitOpenedAt: "2026-08-08T11:55:00Z", + }; + expect(checkBudget(limits, justOpened, now).reason).toBe("circuit_open"); + const longAgo = { ...usage, circuitOpenedAt: "2026-08-08T11:00:00Z" }; + expect(checkBudget(limits, longAgo, now).allowed).toBe(true); + }); +}); + +describe("metering classes", () => { + it("counts only billable and quota-rationed calls as metered", () => { + expect(isMetered("paid_submission")).toBe(true); + expect(isMetered("quota_metered_free")).toBe(true); + // The decision-#84 lesson: free lifecycle calls must not ration paid work. + expect(isMetered("free_poll")).toBe(false); + expect(isMetered("result_fetch")).toBe(false); + expect(isMetered("cache")).toBe(false); + }); + + it("treats only cache as a non-call", () => { + expect(isProviderCall("cache")).toBe(false); + expect(isProviderCall("free_poll")).toBe(true); + }); +}); + +describe("cost arithmetic", () => { + it("converts USD to integer micro-USD without drift", () => { + expect(usdToMicros(0.0006)).toBe(600); + expect(usdToMicros(0.02)).toBe(20_000); + expect(Number.isInteger(usdToMicros(0.1 + 0.2))).toBe(true); + }); + + it("projects month-end spend from the elapsed portion", () => { + // $0.50 by the 10th of a 31-day month. + expect( + projectMonthEndMicros(500_000, new Date("2026-08-10T00:00:00Z")), + ).toBe(1_550_000); + }); + + it("projects zero when nothing has been spent", () => { + expect(projectMonthEndMicros(0)).toBe(0); + }); +}); + +describe("unexpected spend detection", () => { + it("flags metered requests while paid calls are off", () => { + expect(detectUnexpectedSpend(false, 1, 0)).toBe(true); + expect(detectUnexpectedSpend(false, 0, 500)).toBe(true); + expect(detectUnexpectedSpend(false, 0, 0)).toBe(false); + expect(detectUnexpectedSpend(true, 5, 1000)).toBe(false); + }); +}); + +describe("alert thresholds", () => { + it("announces a threshold once and only on escalation", () => { + expect(crossedThreshold(50, null)).toBeNull(); + expect(crossedThreshold(72, null)).toBe(70); + expect(crossedThreshold(72, 70)).toBeNull(); + expect(crossedThreshold(88, 70)).toBe(85); + expect(crossedThreshold(101, 95)).toBe(100); + }); + + it("emits at most one threshold per evaluation", () => { + // Jumping from nothing to 100% announces 100, not 70+85+95+100. + expect(crossedThreshold(100, null)).toBe(100); + }); +}); + +function point( + snapshotDate: string, + traffic: number | null, + keywords: number | null = null, +): SnapshotPoint { + return { + snapshotDate, + organicTrafficEstimate: traffic, + organicKeywordCount: keywords, + backlinkCount: null, + referringDomainCount: null, + }; +} diff --git a/src/server/morgana/si/si-phase2.test.ts b/src/server/morgana/si/si-phase2.test.ts new file mode 100644 index 00000000..2787e6e5 --- /dev/null +++ b/src/server/morgana/si/si-phase2.test.ts @@ -0,0 +1,457 @@ +import { describe, expect, it } from "vitest"; +import { + classifyKeyword, + frequencyHoursFor, + isValidKeyword, + normalizeKeyword, + priorityWeight, + SEED_KEYWORDS, +} from "./keywords"; +import { + classifyGap, + computeShareOfSearch, + ctrFor, + CTR_MODEL_VERSION, + opportunityScore, + type Observation, +} from "./gap"; +import { detectRankingEvents, detectShareShift } from "./events"; + +/** Morgana Search Intelligence — phase 2 core tests (patch P7). */ + +const found = (entityId: string, rankGroup: number): Observation => ({ + entityId, + rankGroup, + isFound: true, +}); +const absent = (entityId: string): Observation => ({ + entityId, + rankGroup: null, + isFound: false, +}); + +describe("keyword normalisation", () => { + it("lowercases and collapses whitespace", () => { + expect(normalizeKeyword(" Custodia BITCOIN ")).toBe("custodia bitcoin"); + }); + + // Folding accents would merge two queries that rank differently in Italian. + it("preserves accents", () => { + expect(normalizeKeyword("Eredità Bitcoin")).toBe("eredità bitcoin"); + }); + + it("strips surrounding punctuation but keeps internal characters", () => { + expect(normalizeKeyword('"check-sig"!')).toBe("check-sig"); + }); + + it("rejects empty and over-long keywords", () => { + expect(isValidKeyword(" ")).toBe(false); + expect(isValidKeyword("a")).toBe(false); + expect(isValidKeyword("x".repeat(201))).toBe(false); + expect(isValidKeyword("custodia bitcoin")).toBe(true); + }); +}); + +describe("clustering", () => { + it("puts brand terms in the brand cluster", () => { + expect(classifyKeyword("CheckSig recensioni")?.slug).toBe("brand-checksig"); + }); + + // Order matters: a brand term inside a generic phrase stays brand. + it("prefers the brand cluster over the generic one", () => { + expect(classifyKeyword("custodia bitcoin checksig")?.slug).toBe( + "brand-checksig", + ); + }); + + it("recognises competitor brands", () => { + expect(classifyKeyword("conio commissioni")?.slug).toBe("competitor-brand"); + }); + + it("returns null rather than a catch-all bucket", () => { + // An unclustered keyword is a visible configuration gap, not something to + // silently give a default weight to. + expect(classifyKeyword("previsioni meteo domani")).toBeNull(); + }); + + it("assigns every seed keyword to a cluster", () => { + const unclustered = SEED_KEYWORDS.filter( + (k) => classifyKeyword(k.keyword) === null, + ); + expect(unclustered).toEqual([]); + }); +}); + +describe("priority", () => { + it("maps priority to the documented cadences", () => { + expect(frequencyHoursFor("critical")).toBe(24); + expect(frequencyHoursFor("high")).toBe(56); + expect(frequencyHoursFor("normal")).toBe(168); + expect(frequencyHoursFor("low")).toBe(336); + }); + + it("weights critical keywords above low ones", () => { + expect(priorityWeight("critical")).toBeGreaterThan(priorityWeight("low")); + }); +}); + +describe("keyword gap classification", () => { + const primary = "checksig"; + + it("reports strong when we lead every competitor", () => { + const r = classifyGap({ + primaryEntityId: primary, + current: [found(primary, 2), found("conio", 5)], + }); + expect(r.category).toBe("strong"); + expect(r.bestCompetitorRank).toBe(5); + }); + + it("reports weak when we are far behind the best competitor", () => { + const r = classifyGap({ + primaryEntityId: primary, + current: [found(primary, 22), found("conio", 4)], + }); + expect(r.category).toBe("weak"); + }); + + it("reports shared when close behind", () => { + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 7), found("conio", 4)], + }).category, + ).toBe("shared"); + }); + + it("distinguishes primary_only, competitor_only and missing", () => { + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 5), absent("conio")], + }).category, + ).toBe("primary_only"); + expect( + classifyGap({ + primaryEntityId: primary, + current: [absent(primary), found("conio", 5)], + }).category, + ).toBe("competitor_only"); + expect( + classifyGap({ + primaryEntityId: primary, + current: [absent(primary), absent("conio")], + }).category, + ).toBe("missing"); + }); + + it("reports new and lost against the previous observation", () => { + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 8)], + previous: [absent(primary)], + }).category, + ).toBe("new"); + expect( + classifyGap({ + primaryEntityId: primary, + current: [absent(primary)], + previous: [found(primary, 8)], + }).category, + ).toBe("lost"); + }); + + it("reports improved and declined past the movement threshold", () => { + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 5)], + previous: [found(primary, 20)], + }).category, + ).toBe("improved"); + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 25)], + previous: [found(primary, 5)], + }).category, + ).toBe("declined"); + }); + + it("ignores movement below the threshold", () => { + expect( + classifyGap({ + primaryEntityId: primary, + current: [found(primary, 6)], + previous: [found(primary, 8)], + }).category, + ).not.toBe("improved"); + }); + + // Absence must never become a number. + it("never substitutes a sentinel position for not ranking", () => { + const r = classifyGap({ + primaryEntityId: primary, + current: [absent(primary), found("conio", 3)], + }); + expect(r.primaryRank).toBeNull(); + }); +}); + +describe("opportunity score", () => { + it("is null when volume is unknown, not zero", () => { + expect(opportunityScore(null, 20, 3)).toBeNull(); + }); + + it("is zero when we already lead", () => { + expect(opportunityScore(1000, 2, 5)).toBe(0); + }); + + it("scales with volume and gap", () => { + expect(opportunityScore(1000, 20, 10)).toBe(10_000); + expect(opportunityScore(2000, 20, 10)).toBe(20_000); + }); + + it("treats not ranking as the full addressable gap", () => { + expect(opportunityScore(100, null, 5)).toBe(9500); + }); +}); + +describe("CTR model", () => { + it("is monotonically non-increasing across the head", () => { + for (let i = 1; i < 10; i += 1) { + expect(ctrFor(i)).toBeGreaterThanOrEqual(ctrFor(i + 1)); + } + }); + + it("gives nothing to a domain that does not rank", () => { + expect(ctrFor(null)).toBe(0); + }); + + it("gives a small non-zero value in the tail and nothing past it", () => { + expect(ctrFor(15)).toBeGreaterThan(0); + expect(ctrFor(50)).toBe(0); + }); + + it("is versioned so a curve change is visible in history", () => { + expect(CTR_MODEL_VERSION).toMatch(/^ctr-/); + }); +}); + +const kw = ( + id: string, + volume: number | null, + observations: Observation[], +) => ({ + trackedKeywordId: id, + searchVolume: volume, + clusterWeight: 1, + priorityWeight: 1, + observations, +}); + +describe("tracked keyword share of search", () => { + it("computes shares that sum to one", () => { + const r = computeShareOfSearch( + [ + kw("k1", 1000, [found("checksig", 1), found("conio", 5)]), + kw("k2", 500, [found("checksig", 2), found("conio", 3)]), + ], + ["checksig", "conio"], + ); + expect(r.status).toBe("ok"); + const sum = r.results.reduce((a, x) => a + (x.share ?? 0), 0); + expect(sum).toBeCloseTo(1, 6); + expect(r.results[0]?.share).toBeGreaterThan(r.results[1]?.share ?? 0); + }); + + it("refuses when coverage is too thin", () => { + const r = computeShareOfSearch( + [ + kw("k1", 1000, [absent("checksig"), absent("conio")]), + kw("k2", 1000, [absent("checksig"), absent("conio")]), + kw("k3", 1000, [found("checksig", 4), absent("conio")]), + ], + ["checksig", "conio"], + ); + expect(r.status).toBe("insufficient_data"); + expect(r.results.every((x) => x.share === null)).toBe(true); + }); + + // A long tail of unmeasured keywords must not quietly dominate. + it("skips keywords with unknown volume rather than assuming one", () => { + const r = computeShareOfSearch( + [ + kw("k1", 1000, [found("checksig", 1), found("conio", 8)]), + kw("k2", null, [absent("checksig"), found("conio", 1)]), + ], + ["checksig", "conio"], + ); + expect(r.status).toBe("ok"); + expect(r.keywordsConsidered).toBe(1); + }); + + it("refuses with no keywords or no domains", () => { + expect(computeShareOfSearch([], ["a", "b"]).status).toBe( + "insufficient_data", + ); + expect( + computeShareOfSearch([kw("k", 10, [found("a", 1)])], []).status, + ).toBe("insufficient_data"); + }); + + it("stamps the CTR model version on the result", () => { + expect( + computeShareOfSearch([kw("k", 10, [found("a", 1)])], ["a"]) + .ctrModelVersion, + ).toBe(CTR_MODEL_VERSION); + }); +}); + +describe("ranking event detection", () => { + const base = { + trackedKeywordId: "kw1", + primaryEntityId: "checksig", + snapshotDate: "2026-08-06", + }; + + it("emits nothing without a previous observation", () => { + expect( + detectRankingEvents({ + ...base, + priority: "normal", + current: [found("checksig", 3)], + }), + ).toEqual([]); + }); + + it("announces a top-3 entry immediately", () => { + const events = detectRankingEvents({ + ...base, + priority: "normal", + current: [found("checksig", 2)], + previous: [found("checksig", 8)], + }); + expect(events.map((e) => e.eventType)).toContain("entered_top_3"); + }); + + it("does not report both top-3 and top-10 for one move", () => { + const types = detectRankingEvents({ + ...base, + priority: "normal", + current: [found("checksig", 2)], + previous: [found("checksig", 30)], + }).map((e) => e.eventType); + expect(types).toContain("entered_top_3"); + expect(types).not.toContain("entered_top_10"); + }); + + // The noise-suppression rule: one bad reading is not an alert. + it("does not announce a loss on a single observation", () => { + const events = detectRankingEvents({ + ...base, + priority: "normal", + current: [absent("checksig")], + previous: [found("checksig", 5)], + // No beforePrevious: the loss is unconfirmed. + }); + expect(events).toEqual([]); + }); + + it("announces a loss once it is confirmed by the prior observation", () => { + const events = detectRankingEvents({ + ...base, + priority: "normal", + current: [absent("checksig")], + previous: [found("checksig", 5)], + beforePrevious: [found("checksig", 4)], + }); + expect(events.map((e) => e.eventType)).toContain("left_top_10"); + }); + + it("escalates a confirmed loss on a critical keyword", () => { + const events = detectRankingEvents({ + ...base, + priority: "critical", + current: [absent("checksig")], + previous: [found("checksig", 5)], + beforePrevious: [found("checksig", 4)], + }); + expect(events.map((e) => e.eventType)).toContain("critical_keyword_lost"); + }); + + it("announces a large gain immediately", () => { + expect( + detectRankingEvents({ + ...base, + priority: "normal", + current: [found("checksig", 4)], + previous: [found("checksig", 20)], + }).map((e) => e.eventType), + ).toContain("gained_10_plus"); + }); + + it("reports competitive overtakes only for critical keywords", () => { + const args = { + ...base, + current: [found("checksig", 6), found("conio", 2)], + previous: [found("checksig", 3), found("conio", 5)], + }; + expect( + detectRankingEvents({ ...args, priority: "critical" }).map( + (e) => e.eventType, + ), + ).toContain("overtaken_by_competitor"); + expect( + detectRankingEvents({ ...args, priority: "normal" }).map( + (e) => e.eventType, + ), + ).not.toContain("overtaken_by_competitor"); + }); + + it("produces a stable dedupe key per keyword, entity, type and day", () => { + const [event] = detectRankingEvents({ + ...base, + priority: "normal", + current: [found("checksig", 2)], + previous: [found("checksig", 8)], + }); + expect(event?.dedupeKey).toBe("kw1|checksig|entered_top_3|2026-08-06"); + }); +}); + +describe("share of search shift", () => { + it("ignores small moves", () => { + expect( + detectShareShift({ + entityId: "checksig", + previousShare: 0.4, + currentShare: 0.42, + snapshotDate: "2026-08-06", + }), + ).toBeNull(); + }); + + it("announces a move past three points", () => { + expect( + detectShareShift({ + entityId: "checksig", + previousShare: 0.4, + currentShare: 0.45, + snapshotDate: "2026-08-06", + })?.eventType, + ).toBe("share_of_search_shift"); + }); + + it("stays silent when either side is unknown", () => { + expect( + detectShareShift({ + entityId: "checksig", + previousShare: null, + currentShare: 0.9, + snapshotDate: "2026-08-06", + }), + ).toBeNull(); + }); +}); diff --git a/src/server/morgana/si/store.ts b/src/server/morgana/si/store.ts new file mode 100644 index 00000000..0a5d9f9b --- /dev/null +++ b/src/server/morgana/si/store.ts @@ -0,0 +1,451 @@ +import { and, desc, eq, gte } from "drizzle-orm"; +import { db } from "@/db"; +import { + domainRefreshJobs, + domainSnapshotKeywords, + domainSnapshotPages, + domainSnapshots, + searchEntities, +} from "@/db/schema"; +import { normalizeEntityDomain, normalizePageUrl } from "./domains"; +import type { SnapshotPoint } from "./metrics"; +import { newId, nowIso } from "./ids"; + +/** + * Morgana Search Intelligence — persistence. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P6). + * + * All D1 access for Search Intelligence lives here so the service layer stays + * testable and the SQL is reviewable in one place. Writes are idempotent by + * construction: every insert that could be retried carries a natural key with a + * UNIQUE index behind it, and uses `onConflictDoNothing`/`onConflictDoUpdate` + * rather than a read-then-write race. + */ + +type EntityType = "primary" | "competitor" | "watch"; +type Priority = "high" | "normal" | "low"; + +export type SearchEntityRow = typeof searchEntities.$inferSelect; +export type SnapshotKeywordRow = typeof domainSnapshotKeywords.$inferSelect; +export type SnapshotPageRow = typeof domainSnapshotPages.$inferSelect; + +interface CreateEntityInput { + displayName: string; + domain: string; + entityType: EntityType; + priority?: Priority; + includeSubdomains?: boolean; + locationCode?: number; + languageCode?: string; + refreshIntervalHours?: number; + backlinkIntervalHours?: number; +} + +/** UTC date bucket. Snapshots are keyed by day, not by instant. */ +export function snapshotDateFor(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} + +// --- entities --------------------------------------------------------------- + +export async function listEntities( + options: { + includeDisabled?: boolean; + } = {}, +): Promise { + const rows = await db + .select() + .from(searchEntities) + .orderBy(searchEntities.entityType, searchEntities.displayName); + const all = rows; + return options.includeDisabled ? all : all.filter((row) => row.enabled); +} + +export async function getEntity(id: string): Promise { + const rows = await db + .select() + .from(searchEntities) + .where(eq(searchEntities.id, id)) + .limit(1); + return rows[0] ?? null; +} + +export async function createEntity( + input: CreateEntityInput, +): Promise { + // Validation throws DomainValidationError, which the API maps to a 400. + const domain = normalizeEntityDomain(input.domain, { + includeSubdomains: input.includeSubdomains ?? false, + }); + const timestamp = nowIso(); + const row = { + id: newId("se"), + displayName: input.displayName.trim().slice(0, 200), + canonicalDomain: domain.display, + normalizedDomain: domain.normalized, + entityType: input.entityType, + enabled: true, + priority: input.priority ?? "normal", + includeSubdomains: input.includeSubdomains ?? false, + locationCode: input.locationCode ?? 2380, + languageCode: input.languageCode ?? "it", + refreshIntervalHours: input.refreshIntervalHours ?? 24, + backlinkIntervalHours: input.backlinkIntervalHours ?? 168, + lastRefreshedAt: null, + lastBacklinkRefreshedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + disabledAt: null, + }; + await db.insert(searchEntities).values(row); + return row as SearchEntityRow; +} + +interface UpdateEntityInput { + displayName?: string; + priority?: Priority; + includeSubdomains?: boolean; + locationCode?: number; + languageCode?: string; + refreshIntervalHours?: number; + backlinkIntervalHours?: number; + enabled?: boolean; +} + +export async function updateEntity( + id: string, + input: UpdateEntityInput, +): Promise { + const existing = await getEntity(id); + if (!existing) return null; + const timestamp = nowIso(); + const patch: Record = { updatedAt: timestamp }; + if (input.displayName !== undefined) + patch.displayName = input.displayName.trim().slice(0, 200); + if (input.priority !== undefined) patch.priority = input.priority; + if (input.includeSubdomains !== undefined) + patch.includeSubdomains = input.includeSubdomains; + if (input.locationCode !== undefined) patch.locationCode = input.locationCode; + if (input.languageCode !== undefined) patch.languageCode = input.languageCode; + if (input.refreshIntervalHours !== undefined) + patch.refreshIntervalHours = input.refreshIntervalHours; + if (input.backlinkIntervalHours !== undefined) + patch.backlinkIntervalHours = input.backlinkIntervalHours; + if (input.enabled !== undefined) { + patch.enabled = input.enabled; + // Disabling records WHEN, and never deletes: history outlives the entity's + // active life, which is the whole point of keeping snapshots. + patch.disabledAt = input.enabled ? null : timestamp; + } + await db.update(searchEntities).set(patch).where(eq(searchEntities.id, id)); + return getEntity(id); +} + +// --- snapshots -------------------------------------------------------------- + +interface SnapshotMetrics { + organicTrafficEstimate: number | null; + organicKeywordCount: number | null; + backlinkCount: number | null; + referringDomainCount: number | null; + rankSignal: number | null; +} + +interface KeywordRow { + keyword: string; + rankGroup: number | null; + rankAbsolute: number | null; + searchVolume: number | null; + estimatedTraffic: number | null; + cpc: number | null; + keywordDifficulty: number | null; + searchIntent: string | null; + rankingUrl: string | null; + serpUpdatedAt?: string | null; +} + +interface PageRow { + url: string; + estimatedTraffic: number | null; + keywordCount: number | null; + topKeyword: string | null; + topKeywordPosition: number | null; + pageTitle: string | null; + lastSeenAt?: string | null; +} + +interface PersistSnapshotInput { + entity: SearchEntityRow; + snapshotDate: string; + metrics: SnapshotMetrics; + keywords: KeywordRow[]; + pages: PageRow[]; + source: "dataforseo" | "fixture"; + providerRequestId?: string | null; + estimatedCostMicros: number; + actualCostMicros: number; +} + +interface PersistSnapshotResult { + snapshotId: string; + created: boolean; +} + +/** `entity|location|language|date` — the natural key for one day's snapshot. */ +export function snapshotDedupeKey( + entityId: string, + locationCode: number, + languageCode: string, + snapshotDate: string, +): string { + return `${entityId}|${String(locationCode)}|${languageCode}|${snapshotDate}`; +} + +/** + * Write a snapshot and its children, at most once per entity/market/day. + * + * Idempotency is enforced by the database, not by a prior read: two concurrent + * refreshes both attempt the insert and exactly one wins, because `dedupe_key` + * is UNIQUE. The loser returns the winner's id rather than an error, so a racing + * manual refresh sees the same snapshot the scheduler just produced. + */ +export async function persistSnapshot( + input: PersistSnapshotInput, +): Promise { + const dedupeKey = snapshotDedupeKey( + input.entity.id, + input.entity.locationCode, + input.entity.languageCode, + input.snapshotDate, + ); + const existing = await db + .select({ id: domainSnapshots.id }) + .from(domainSnapshots) + .where(eq(domainSnapshots.dedupeKey, dedupeKey)) + .limit(1); + if (existing[0]) { + return { snapshotId: existing[0].id, created: false }; + } + + const snapshotId = newId("ds"); + const timestamp = nowIso(); + try { + await db.insert(domainSnapshots).values({ + id: snapshotId, + entityId: input.entity.id, + organicTrafficEstimate: input.metrics.organicTrafficEstimate, + organicKeywordCount: input.metrics.organicKeywordCount, + backlinkCount: input.metrics.backlinkCount, + referringDomainCount: input.metrics.referringDomainCount, + rankSignal: input.metrics.rankSignal, + locationCode: input.entity.locationCode, + languageCode: input.entity.languageCode, + source: input.source, + providerRequestId: input.providerRequestId ?? null, + fetchedAt: timestamp, + snapshotDate: input.snapshotDate, + estimatedCostMicros: input.estimatedCostMicros, + actualCostMicros: input.actualCostMicros, + dedupeKey, + createdAt: timestamp, + }); + } catch (error) { + // Lost the race: another writer inserted the same dedupe key between the + // read above and this insert. Return theirs. + const winner = await db + .select({ id: domainSnapshots.id }) + .from(domainSnapshots) + .where(eq(domainSnapshots.dedupeKey, dedupeKey)) + .limit(1); + if (winner[0]) return { snapshotId: winner[0].id, created: false }; + throw error; + } + + if (input.keywords.length > 0) { + await db.insert(domainSnapshotKeywords).values( + input.keywords.map((kw, index) => ({ + id: newId("dk"), + snapshotId, + keyword: kw.keyword.slice(0, 500), + rankGroup: kw.rankGroup, + rankAbsolute: kw.rankAbsolute, + searchVolume: kw.searchVolume, + estimatedTraffic: kw.estimatedTraffic, + cpc: kw.cpc, + keywordDifficulty: kw.keywordDifficulty, + searchIntent: kw.searchIntent, + rankingUrl: kw.rankingUrl?.slice(0, 2000) ?? null, + serpUpdatedAt: kw.serpUpdatedAt ?? null, + position: index + 1, + })), + ); + } + + if (input.pages.length > 0) { + await db.insert(domainSnapshotPages).values( + input.pages.map((page, index) => ({ + id: newId("dp"), + snapshotId, + url: page.url.slice(0, 2000), + normalizedUrl: normalizePageUrl(page.url).slice(0, 2000), + estimatedTraffic: page.estimatedTraffic, + keywordCount: page.keywordCount, + topKeyword: page.topKeyword?.slice(0, 500) ?? null, + topKeywordPosition: page.topKeywordPosition, + pageTitle: page.pageTitle?.slice(0, 500) ?? null, + lastSeenAt: page.lastSeenAt ?? timestamp, + position: index + 1, + })), + ); + } + + return { snapshotId, created: true }; +} + +export async function latestSnapshot(entityId: string) { + const rows = await db + .select() + .from(domainSnapshots) + .where(eq(domainSnapshots.entityId, entityId)) + .orderBy(desc(domainSnapshots.snapshotDate)) + .limit(1); + return rows[0] ?? null; +} + +export async function snapshotHistory( + entityId: string, + sinceDate: string, +): Promise { + const rows = await db + .select({ + snapshotDate: domainSnapshots.snapshotDate, + organicTrafficEstimate: domainSnapshots.organicTrafficEstimate, + organicKeywordCount: domainSnapshots.organicKeywordCount, + backlinkCount: domainSnapshots.backlinkCount, + referringDomainCount: domainSnapshots.referringDomainCount, + }) + .from(domainSnapshots) + .where( + and( + eq(domainSnapshots.entityId, entityId), + gte(domainSnapshots.snapshotDate, sinceDate), + ), + ) + .orderBy(domainSnapshots.snapshotDate); + return rows as SnapshotPoint[]; +} + +export async function snapshotKeywords(snapshotId: string, limit = 100) { + return db + .select() + .from(domainSnapshotKeywords) + .where(eq(domainSnapshotKeywords.snapshotId, snapshotId)) + .orderBy(domainSnapshotKeywords.position) + .limit(limit); +} + +export async function snapshotPages(snapshotId: string, limit = 100) { + return db + .select() + .from(domainSnapshotPages) + .where(eq(domainSnapshotPages.snapshotId, snapshotId)) + .orderBy(domainSnapshotPages.position) + .limit(limit); +} + +// --- refresh jobs ----------------------------------------------------------- + +type JobStatus = "pending" | "running" | "succeeded" | "failed" | "skipped"; + +function jobDedupeKey( + entityId: string, + snapshotDate: string, + trigger: "scheduled" | "manual", +): string { + return `${entityId}|${snapshotDate}|${trigger}`; +} + +/** + * Claim a refresh job. Returns null when an equivalent job already exists — + * which is the dedup: the UNIQUE index on `dedupe_key` means a duplicated + * scheduler tick, a double-clicked manual refresh and a retry all collapse onto + * one job instead of one billable call each. + */ +export async function claimJob(input: { + entityId: string; + snapshotDate: string; + trigger: "scheduled" | "manual"; + requestedBy?: string | null; +}): Promise<{ id: string } | null> { + const id = newId("rj"); + try { + await db.insert(domainRefreshJobs).values({ + id, + entityId: input.entityId, + status: "running", + trigger: input.trigger, + requestedBy: input.requestedBy ?? null, + dedupeKey: jobDedupeKey( + input.entityId, + input.snapshotDate, + input.trigger, + ), + attempts: 1, + lastError: null, + skipReason: null, + estimatedCostMicros: 0, + actualCostMicros: 0, + snapshotId: null, + createdAt: nowIso(), + startedAt: nowIso(), + finishedAt: null, + }); + return { id }; + } catch { + return null; + } +} + +export async function finishJob( + id: string, + patch: { + status: JobStatus; + snapshotId?: string | null; + lastError?: string | null; + skipReason?: string | null; + estimatedCostMicros?: number; + actualCostMicros?: number; + }, +): Promise { + await db + .update(domainRefreshJobs) + .set({ + status: patch.status, + snapshotId: patch.snapshotId ?? null, + // Sanitised by the caller; never a stack trace or a credential. + lastError: patch.lastError?.slice(0, 500) ?? null, + skipReason: patch.skipReason ?? null, + estimatedCostMicros: patch.estimatedCostMicros ?? 0, + actualCostMicros: patch.actualCostMicros ?? 0, + finishedAt: nowIso(), + }) + .where(eq(domainRefreshJobs.id, id)); +} + +export async function getJob(id: string) { + const rows = await db + .select() + .from(domainRefreshJobs) + .where(eq(domainRefreshJobs.id, id)) + .limit(1); + return rows[0] ?? null; +} + +export async function recentJobs(limit = 50) { + return db + .select() + .from(domainRefreshJobs) + .orderBy(desc(domainRefreshJobs.createdAt)) + .limit(limit); +} diff --git a/vite.config.ts b/vite.config.ts index c5221eb0..d103fb0d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,7 +52,15 @@ export default defineConfig(({ mode }) => { }, }) : null, - cloudflare({ inspectorPort: false, viteEnvironment: { name: "ssr" } }), + // MORGANA LOCAL PATCH (UPSTREAM.md, patch P1). The Cloudflare plugin reads + // the wrangler config at build time, so selecting the isolated staging + // config has to happen here rather than at deploy time. Unset in every + // other context, which leaves upstream behaviour byte-identical. + cloudflare({ + configPath: env.WRANGLER_CONFIG_PATH || undefined, + inspectorPort: false, + viteEnvironment: { name: "ssr" }, + }), tsConfigPaths(), tanstackStart(), viteReact(), diff --git a/wrangler.staging.jsonc b/wrangler.staging.jsonc new file mode 100644 index 00000000..8cef759a --- /dev/null +++ b/wrangler.staging.jsonc @@ -0,0 +1,173 @@ +/** + * Morgana Search Intelligence Engine — STAGING. + * + * MORGANA LOCAL PATCH (see UPSTREAM.md, patch P1). Upstream deploys through + * Alchemy (`alchemy.run.ts`). This deployment deliberately does not: Alchemy is + * pinned to a prerelease (2.0.0-beta.61), keeps its own state store, is used + * elsewhere in the repo with `--adopt`, and auto-provisions Cloudflare Access. + * On a Cloudflare account that also holds Morgana production, an IaC tool that + * can adopt existing resources is the wrong risk to take for a staging engine. + * A hand-authored config is auditable line by line and provisions nothing. + * + * Build and deploy (see docs/search-intelligence/OPERATIONS.md in the Morgana + * repo): + * node scripts/assert-isolation.mjs wrangler.staging.jsonc + * WRANGLER_CONFIG_PATH=wrangler.staging.jsonc pnpm exec vite build + * node scripts/assert-isolation.mjs dist/server/wrangler.json + * wrangler deploy -c dist/server/wrangler.json + * + * The isolation guard runs twice on purpose: once on this file, and again on + * the config the build generates, because the deployed artifact is the one that + * actually matters. + * + * SECURITY POSTURE — read before changing anything here. + * + * This Worker has NO PUBLIC INGRESS. `workers_dev` and `preview_urls` are both + * false and no route is declared, so it is unreachable from the internet and + * callable only over Morgana's Service Binding. That is the compensating + * control for the absent Cloudflare Access application (creating one needs a + * Zero Trust API token that does not exist yet — see OPERATIONS.md). Enabling + * either ingress would publish an unauthenticated SEO application. Do not. + * + * `scripts/assert-isolation.mjs` enforces every invariant below and must pass + * before any migration or deploy. + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "morgana-search-intelligence-staging", + "main": "src/server.ts", + "compatibility_date": "2025-09-02", + // `global_fetch_strictly_public` is an upstream choice worth keeping: it + // blocks Worker fetches to private, loopback and link-local addresses, which + // is a real SSRF mitigation for the crawler paths that Phase 0 keeps off. + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + + // No public ingress. See the security note above. + "workers_dev": false, + "preview_urls": false, + + "observability": { + "enabled": true, + // Upstream enables traces. Spans bill as observability events, and Phase 0 + // must generate no new spend of any kind, so traces stay off until there is + // a reason to pay for them. + "traces": { + "enabled": false, + }, + }, + + // Upstream sets Smart Placement to move the fetch handler closer to external + // subrequests. Phase 0 makes no external subrequests, so it would buy nothing. + + // NO CRON. Upstream ships `*/15 * * * *`, which runs scheduled rank checks — + // billable DataForSEO work. Phase 0 performs no scheduled SEO collection, so + // the trigger is removed entirely. `src/server.ts` additionally refuses a + // scheduled run while SEARCH_INTELLIGENCE_ENABLED is false, so the trigger + // and the handler are two independent controls on the same risk. + + "d1_databases": [ + { + "binding": "DB", + "database_name": "morgana-search-intelligence-staging", + "database_id": "0bce5159-d388-4d0e-8213-aa96803d37f9", + "migrations_dir": "drizzle", + }, + ], + + "kv_namespaces": [ + { + "binding": "KV", + "id": "7992931f3abe4eebb156d7602d9ecd6b", + }, + { + "binding": "OAUTH_KV", + "id": "9a7b560aff694789a66d261c891988ee", + }, + ], + + "r2_buckets": [ + { + "binding": "R2", + "bucket_name": "morgana-search-intelligence-staging", + }, + ], + + // Durable Objects and Workflows are declared because the Worker's module + // exports them — omitting a declared class breaks the upload. They are + // unreachable in Phase 0: the agent routes sit behind an auth check that + // fails closed, and no workflow is ever started. + "durable_objects": { + "bindings": [ + { "name": "ONBOARDING_CHAT", "class_name": "OnboardingChatAgent" }, + { "name": "SAM_CHAT", "class_name": "SamChatAgent" }, + ], + }, + // SQLite-backed DO classes must keep their upstream migration tags. Dropping + // or renumbering these orphans the DO storage. + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["OnboardingChatAgent"] }, + { "tag": "v2", "new_sqlite_classes": ["SamChatAgent"] }, + ], + "workflows": [ + { + "name": "site-audit-workflow-si-staging", + "binding": "SITE_AUDIT_WORKFLOW", + "class_name": "SiteAuditWorkflow", + }, + { + "name": "rank-check-workflow-si-staging", + "binding": "RANK_CHECK_WORKFLOW", + "class_name": "RankCheckWorkflow", + }, + ], + + "vars": { + // Fail closed. `cloudflare_access` with TEAM_DOMAIN and POLICY_AUD both + // UNSET means every application route rejects, because there is no issuer + // or audience to verify a JWT against. That is deliberate for Phase 0: the + // SEO application must not be usable. It is the second layer under "no + // public ingress", not a substitute for it. + // + // `local_noauth` must never be set here — it injects an admin user. + "AUTH_MODE": "cloudflare_access", + "DATABASE_PROVIDER": "d1", + + // Upstream self-host telemetry is ON by default and posts an install + // heartbeat to a hardcoded PostHog project. Both opt-outs are set; any + // value other than 0/false/no/off disables it. + "OPENSEO_TELEMETRY_DISABLED": "1", + "DO_NOT_TRACK": "1", + "VITE_SHOW_DEVTOOLS": "false", + + // Phase-1 STAGING posture. The master flag is ON here so the private + // /internal/si/* surface answers and the integration can be verified end to + // end; Morgana's own production config keeps every flag false, and its env + // validation refuses to let these two be true in production. + // + // Turning this on cannot cause spending: paid calls stay off, both caps stay + // zero, and no DataForSEO credential exists, so the collector resolves to + // deterministic fixtures stamped source="fixture". + "SEARCH_INTELLIGENCE_ENABLED": "true", + "SEARCH_INTELLIGENCE_STAGING_ENABLED": "true", + "SEARCH_INTELLIGENCE_UI_ENABLED": "false", + "SEARCH_INTELLIGENCE_PAID_CALLS_ENABLED": "false", + "SEARCH_INTELLIGENCE_MCP_ENABLED": "false", + "SEARCH_INTELLIGENCE_AI_ENABLED": "false", + "SEARCH_INTELLIGENCE_SITE_AUDIT_ENABLED": "false", + + // Hard-zero spend. `src/server/morgana/phase0-env.ts` refuses to boot if + // paid calls are ever enabled while either cap is zero, so flipping one + // flag can never by itself authorise spending. + "SEO_DATAFORSEO_DAILY_COST_CAP_USD": "0", + "SEO_DATAFORSEO_MONTHLY_COST_CAP_USD": "0", + + "SEARCH_INTELLIGENCE_ENVIRONMENT": "staging", + "SEARCH_INTELLIGENCE_API_VERSION": "2026-08-05", + + // Provenance, surfaced by /internal/status so the deployed artifact can be + // tied to an upstream release without trusting a build log. + "ENGINE_UPSTREAM_REPOSITORY": "https://github.com/every-app/open-seo", + "ENGINE_UPSTREAM_RELEASE": "v0.1.3", + "ENGINE_UPSTREAM_COMMIT": "9d19e439905a9a954ccdefe22d9270d7c389695d", + }, +}