diff --git a/docs/default-project-cleanup.md b/docs/default-project-cleanup.md new file mode 100644 index 0000000..9272727 --- /dev/null +++ b/docs/default-project-cleanup.md @@ -0,0 +1,70 @@ +# Default Project Cleanup + +Most installs do not need this cleanup. Use it only if running the latest +migrations fails with a unique-constraint error for +`projects_one_default_per_organization_idx`. + +The cleanup keeps one canonical auto-created `Default` project per organization, +remaps supported child rows onto it, preserves rank-tracking history and keyword +metadata where possible, then removes duplicate Default projects. + +## Cloudflare D1 Database + +1. Preview the cleanup: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo + ``` + +2. If the output looks right, apply it: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo --apply --confirm-remote-apply + ``` + +3. Validate or re-run validation: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo --validate-only + ``` + +4. Re-run the normal migration/deploy. + +Before applying to production, make sure you have a recent D1 backup or +time-travel restore point. For hosted production, disable signups/writes for +roughly 60 seconds while the cleanup runs. + +## Local Docker / Local SQLite-Backed D1 Database + +1. Preview the cleanup: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo --local + ``` + +2. Apply it: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo --local --apply + ``` + +3. Validate or re-run validation: + + ```sh + pnpm cleanup:default-projects:d1 --database open-seo --local --validate-only + ``` + +4. Re-run the normal local migration. + +## What Happened + +Several simultaneous requests could initialize the same organization at once, +creating more than one auto-created `Default` project. + +## More Detail + +The runner in `scripts/d1-default-project-cleanup.ts` is the recommended entry +point because it includes dry-run output, active-run preflight checks, post-apply +validation, and an explicit confirmation flag for remote databases. + +The SQL implementation lives in `scripts/cleanup-default-projects.sql`. diff --git a/drizzle/0016_magical_the_fallen.sql b/drizzle/0016_magical_the_fallen.sql new file mode 100644 index 0000000..991867b --- /dev/null +++ b/drizzle/0016_magical_the_fallen.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `projects_one_default_per_organization_idx` ON `projects` (`organization_id`) WHERE "projects"."name" = 'Default' AND "projects"."domain" IS NULL; \ No newline at end of file diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..9461340 --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2167 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "03172742-ed12-4540-a6d2-89622d4f3a49", + "prevId": "feccfbbc-1ad4-4baa-89f9-a86841116083", + "tables": { + "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 + } + }, + "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_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 + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "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": {} + }, + "delegated_users": { + "name": "delegated_users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "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": { + "delegated_users_email_unique": { + "name": "delegated_users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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": {} + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "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" + } + }, + "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_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + }, + "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'" + }, + "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_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true + } + }, + "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_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + "saved_keyword_id" + ], + "isUnique": false + }, + "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": {} + }, + "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 + } + }, + "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 + } + }, + "foreignKeys": {}, + "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 f93396b..a922d10 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1778750000001, "tag": "0015_huge_celestials", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1779138211145, + "tag": "0016_magical_the_fallen", + "breakpoints": true } ] } diff --git a/knip.jsonc b/knip.jsonc index 995c515..3ea780a 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -12,6 +12,7 @@ // Package script entrypoints "scripts/backlinks-cost-profile.ts", "scripts/brand-lookup-cost-profile.ts", + "scripts/d1-default-project-cleanup.ts", "scripts/release-notes.mjs", "scripts/seed-rank-tracking.ts", // DB schema — exports consumed via `import * as schema` / drizzle() @@ -21,6 +22,7 @@ // Package scripts and one-off maintenance utilities "scripts/backlinks-cost-profile.ts", "scripts/brand-lookup-cost-profile.ts", + "scripts/d1-default-project-cleanup.ts", "scripts/release-notes.mjs", "scripts/seed-rank-tracking.ts", ], diff --git a/package.json b/package.json index a780839..6218039 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-seo", "private": true, "sideEffects": false, - "version": "0.0.13", + "version": "0.0.14", "type": "module", "packageManager": "pnpm@10.30.1", "scripts": { @@ -30,6 +30,7 @@ "test:ci": "vitest run --reporter=dot", "billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", "billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts", + "cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" }, diff --git a/release-notes/v0.0.14.md b/release-notes/v0.0.14.md new file mode 100644 index 0000000..b991a87 --- /dev/null +++ b/release-notes/v0.0.14.md @@ -0,0 +1,21 @@ +OpenSEO now prevents duplicate auto-created Default projects per organization. + +## Migration Note + +Most installs do not need manual cleanup. If running the latest migrations fails +with a unique-constraint error for +`projects_one_default_per_organization_idx`, follow +`docs/default-project-cleanup.md`. + +## What happened + +Several simultaneous requests could initialize the same organization at once, +creating more than one auto-created `Default` project. + +## What the cleanup does + +The cleanup keeps one canonical `Default` project per organization, remaps +supported child rows onto it, preserves rank-tracking history where possible, +then removes duplicate Default projects. + +Full Changelog: https://github.com/every-app/open-seo/compare/v0.0.13...v0.0.14 diff --git a/scripts/cleanup-default-projects.sql b/scripts/cleanup-default-projects.sql new file mode 100644 index 0000000..555560c --- /dev/null +++ b/scripts/cleanup-default-projects.sql @@ -0,0 +1,495 @@ +-- One-time migration helper for duplicate auto-created Default projects. +-- +-- Use only if the latest migrations fail with a unique-constraint error for +-- projects_one_default_per_organization_idx. Prefer the TypeScript runner in +-- scripts/d1-default-project-cleanup.ts; it adds dry-run output, active-run +-- preflight checks, validation, and remote confirmation. See +-- docs/default-project-cleanup.md for the full recovery runbook. +-- +-- "Newest" matches the app's default project selection. The id tie-breaker is +-- only here to keep this cleanup deterministic when several race-created rows +-- share the same second-level created_at value. +-- +-- Do not wrap this file in BEGIN/COMMIT. Remote D1 imports reject explicit +-- transaction statements. + +-- Build a small lookup table that describes every merge this script will make: +-- one canonical Default project, plus each duplicate Default project that +-- should be folded into it. Organizations with only one Default project are not +-- inserted here, so the rest of the script naturally ignores them. +DROP TABLE IF EXISTS __default_project_merge; +CREATE TABLE __default_project_merge ( + organization_id text NOT NULL, + canonical_project_id text NOT NULL, + duplicate_project_id text PRIMARY KEY NOT NULL +); + +INSERT INTO __default_project_merge ( + organization_id, + canonical_project_id, + duplicate_project_id +) +WITH ranked_default_projects AS ( + -- Rank Default/null-domain projects within each organization. keep_rank = 1 + -- is the canonical project that survives; keep_rank > 1 rows are duplicate + -- projects that will be remapped and deleted. + SELECT + id, + organization_id, + ROW_NUMBER() OVER ( + PARTITION BY organization_id + ORDER BY created_at DESC, id DESC + ) AS keep_rank, + FIRST_VALUE(id) OVER ( + PARTITION BY organization_id + ORDER BY created_at DESC, id DESC + ) AS canonical_project_id, + COUNT(*) OVER (PARTITION BY organization_id) AS project_count + FROM projects + WHERE name = 'Default' + AND domain IS NULL +) +SELECT + organization_id, + canonical_project_id, + id AS duplicate_project_id +FROM ranked_default_projects +WHERE project_count > 1 + AND keep_rank > 1; + +-- Materialize rank config collisions once before any rank tracking rows are +-- changed. This considers every rank config attached to any project in the +-- affected organization merge set. That catches both canonical-vs-duplicate +-- collisions and duplicate-vs-duplicate collisions before the generic project +-- remap can violate the unique index. +-- +-- The survivor keeps its config-level settings (devices, schedule, active +-- state, depth, last-check metadata). Duplicate configs contribute their +-- tracked keywords and historical runs, but not their config settings. +DROP TABLE IF EXISTS __rank_config_merge; +CREATE TABLE __rank_config_merge ( + canonical_project_id text NOT NULL, + duplicate_project_id text NOT NULL, + canonical_config_id text NOT NULL, + duplicate_config_id text PRIMARY KEY NOT NULL +); + +INSERT INTO __rank_config_merge ( + canonical_project_id, + duplicate_project_id, + canonical_config_id, + duplicate_config_id +) +WITH project_set AS ( + SELECT + organization_id, + canonical_project_id, + canonical_project_id AS project_id, + 1 AS is_canonical_project + FROM __default_project_merge + GROUP BY organization_id, canonical_project_id + + UNION ALL + + SELECT + organization_id, + canonical_project_id, + duplicate_project_id AS project_id, + 0 AS is_canonical_project + FROM __default_project_merge +), +ranked_configs AS ( + SELECT + project_set.organization_id, + project_set.canonical_project_id, + config.project_id, + config.id AS config_id, + FIRST_VALUE(config.id) OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ORDER BY project_set.is_canonical_project DESC, config.created_at DESC, config.id DESC + ) AS canonical_config_id, + COUNT(*) OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ) AS config_count, + ROW_NUMBER() OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ORDER BY project_set.is_canonical_project DESC, config.created_at DESC, config.id DESC + ) AS keep_rank + FROM rank_tracking_configs config + JOIN project_set + ON project_set.project_id = config.project_id +) +SELECT + canonical_project_id, + project_id AS duplicate_project_id, + canonical_config_id, + config_id AS duplicate_config_id +FROM ranked_configs +WHERE config_count > 1 + AND keep_rank > 1; + +-- Merge saved keyword tags by normalized name. +-- +-- If duplicate and canonical projects both have a tag with the same +-- normalized_name, the canonical tag should survive. If the canonical tag has +-- no explicit color but the duplicate tag does, preserve that color before +-- deleting the duplicate row. Then delete duplicate tag assignments that would +-- become exact assignment duplicates after the tag id is remapped. +UPDATE saved_keyword_tags +SET color = COALESCE( + color, + ( + SELECT duplicate_tag.color + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + WHERE merge_map.canonical_project_id = saved_keyword_tags.project_id + AND duplicate_tag.normalized_name = saved_keyword_tags.normalized_name + AND duplicate_tag.color IS NOT NULL + ORDER BY duplicate_tag.created_at DESC, duplicate_tag.id DESC + LIMIT 1 + ) +) +WHERE color IS NULL + AND EXISTS ( + SELECT 1 + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + WHERE merge_map.canonical_project_id = saved_keyword_tags.project_id + AND duplicate_tag.normalized_name = saved_keyword_tags.normalized_name + AND duplicate_tag.color IS NOT NULL + ); + +DELETE FROM saved_keyword_tag_assignments +WHERE tag_id IN ( + SELECT duplicate_tag.id + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name +) +AND EXISTS ( + SELECT 1 + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name + JOIN saved_keyword_tag_assignments existing_assignment + ON existing_assignment.saved_keyword_id = + saved_keyword_tag_assignments.saved_keyword_id + AND existing_assignment.tag_id = canonical_tag.id + WHERE duplicate_tag.id = saved_keyword_tag_assignments.tag_id +); + +-- Move the remaining assignments from duplicate tag ids to the matching +-- canonical tag ids. +UPDATE saved_keyword_tag_assignments +SET tag_id = ( + SELECT canonical_tag.id + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name + WHERE duplicate_tag.id = saved_keyword_tag_assignments.tag_id +) +WHERE tag_id IN ( + SELECT duplicate_tag.id + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name +); + +-- Delete duplicate tag rows that have now either had their assignments moved or +-- had duplicate assignments removed. +DELETE FROM saved_keyword_tags +WHERE id IN ( + SELECT duplicate_tag.id + FROM saved_keyword_tags duplicate_tag + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name +); + +-- Tags that do not collide by normalized_name can simply move to the canonical +-- project. +UPDATE saved_keyword_tags +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = saved_keyword_tags.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- Merge saved keywords by keyword/location/language. +-- +-- If duplicate and canonical projects both saved the same keyword in the same +-- location/language, the canonical saved keyword should survive. First delete +-- tag assignments that would become duplicates after remapping the saved +-- keyword id. +DELETE FROM saved_keyword_tag_assignments +WHERE saved_keyword_id IN ( + SELECT duplicate_keyword.id + FROM saved_keywords duplicate_keyword + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code +) +AND EXISTS ( + SELECT 1 + FROM saved_keywords duplicate_keyword + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code + JOIN saved_keyword_tag_assignments existing_assignment + ON existing_assignment.saved_keyword_id = canonical_keyword.id + AND existing_assignment.tag_id = saved_keyword_tag_assignments.tag_id + WHERE duplicate_keyword.id = + saved_keyword_tag_assignments.saved_keyword_id +); + +-- Move the remaining tag assignments from duplicate saved keyword ids to the +-- matching canonical saved keyword ids. +UPDATE saved_keyword_tag_assignments +SET saved_keyword_id = ( + SELECT canonical_keyword.id + FROM saved_keywords duplicate_keyword + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code + WHERE duplicate_keyword.id = + saved_keyword_tag_assignments.saved_keyword_id +) +WHERE saved_keyword_id IN ( + SELECT duplicate_keyword.id + FROM saved_keywords duplicate_keyword + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code +); + +-- Delete duplicate saved keyword rows that have now had their tag assignments +-- handled. +DELETE FROM saved_keywords +WHERE id IN ( + SELECT duplicate_keyword.id + FROM saved_keywords duplicate_keyword + JOIN __default_project_merge merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code +); + +-- Saved keywords that do not collide by keyword/location/language can simply +-- move to the canonical project. +UPDATE saved_keywords +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = saved_keywords.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- Merge cached keyword metrics by keyword/location/language. +-- +-- keyword_metrics has the same natural key shape as saved_keywords for this +-- cleanup. If the canonical project already has the same metric row, delete the +-- duplicate project's copy before updating project_id. +-- +-- These rows are cache/enrichment data for keyword research and saved keywords. +-- Colliding metric rows are derived/cache data and can be refetched, so the +-- canonical row wins and the duplicate row is removed. +DELETE FROM keyword_metrics +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +) +AND EXISTS ( + SELECT 1 + FROM __default_project_merge merge_map + JOIN keyword_metrics canonical_metric + ON canonical_metric.project_id = merge_map.canonical_project_id + AND canonical_metric.keyword = keyword_metrics.keyword + AND canonical_metric.location_code = keyword_metrics.location_code + AND canonical_metric.language_code = keyword_metrics.language_code + WHERE merge_map.duplicate_project_id = keyword_metrics.project_id +); + +-- Move all remaining metric rows to the canonical project. +UPDATE keyword_metrics +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = keyword_metrics.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- Merge rank tracking configs that collide by domain/location. +-- +-- If duplicate and canonical projects both track the same domain/location, the +-- canonical config should survive. First delete duplicate tracked-keyword rows +-- where the same keyword already exists on the canonical config. + +DROP TABLE IF EXISTS __rank_keyword_delete; +CREATE TABLE __rank_keyword_delete ( + duplicate_keyword_id text PRIMARY KEY NOT NULL +); + +INSERT INTO __rank_keyword_delete (duplicate_keyword_id) +SELECT duplicate_keyword.id +FROM rank_tracking_keywords duplicate_keyword +JOIN __rank_config_merge config_merge + ON config_merge.duplicate_config_id = duplicate_keyword.config_id +JOIN rank_tracking_keywords canonical_keyword + ON canonical_keyword.config_id = config_merge.canonical_config_id + AND canonical_keyword.keyword = duplicate_keyword.keyword; + +-- Historical snapshots intentionally do not FK to rank_tracking_keywords, but +-- the rank-tracking results page groups snapshots by tracking_keyword_id and +-- then maps them back to active keyword rows. Remap snapshots from duplicate +-- keyword ids to canonical keyword ids before deleting duplicate keyword rows +-- so historical positions remain visible after the config merge. +UPDATE rank_snapshots +SET tracking_keyword_id = ( + SELECT canonical_keyword.id + FROM __rank_config_merge config_merge + JOIN rank_tracking_keywords duplicate_keyword + ON duplicate_keyword.config_id = config_merge.duplicate_config_id + JOIN rank_tracking_keywords canonical_keyword + ON canonical_keyword.config_id = config_merge.canonical_config_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + WHERE duplicate_keyword.id = rank_snapshots.tracking_keyword_id +) +WHERE tracking_keyword_id IN ( + SELECT duplicate_keyword_id FROM __rank_keyword_delete +); + +DELETE FROM rank_tracking_keywords +WHERE id IN ( + SELECT duplicate_keyword_id FROM __rank_keyword_delete +); + +-- Move non-overlapping tracked keywords from duplicate configs to the matching +-- canonical configs. +UPDATE rank_tracking_keywords +SET config_id = ( + SELECT canonical_config_id + FROM __rank_config_merge + WHERE duplicate_config_id = rank_tracking_keywords.config_id +) +WHERE config_id IN ( + SELECT duplicate_config_id FROM __rank_config_merge +); + +-- Move historical runs from duplicate configs to the matching canonical configs +-- and canonical project. rank_snapshots stay attached to run_id, so no snapshot +-- rows need to be changed. +UPDATE rank_check_runs +SET + config_id = ( + SELECT canonical_config_id + FROM __rank_config_merge + WHERE duplicate_config_id = rank_check_runs.config_id + ), + project_id = ( + SELECT canonical_project_id + FROM __rank_config_merge + WHERE duplicate_config_id = rank_check_runs.config_id + ) +WHERE config_id IN ( + SELECT duplicate_config_id FROM __rank_config_merge +); + +-- Delete duplicate config rows after their keywords and runs have moved. +-- At this point any useful child history has been moved or remains reachable +-- through rank_check_runs; only the duplicate config row/settings are removed. +DELETE FROM rank_tracking_configs +WHERE id IN ( + SELECT duplicate_config_id FROM __rank_config_merge +); + +-- Any remaining rank configs on duplicate projects did not collide by +-- domain/location, so they can move directly to the canonical project. +UPDATE rank_tracking_configs +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = rank_tracking_configs.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- rank_check_runs stores project_id directly, so move those runs to the +-- canonical project. rank_snapshots are linked through run/config ids and do +-- not need a project_id update. +UPDATE rank_check_runs +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = rank_check_runs.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- Audits store project_id directly, so move them to the canonical project. +-- audit_pages and audit_lighthouse_results are linked through audit/page ids, +-- so they do not need direct updates here. +UPDATE audits +SET project_id = ( + SELECT canonical_project_id + FROM __default_project_merge + WHERE duplicate_project_id = audits.project_id +) +WHERE project_id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- At this point no supported child rows should point at the duplicate projects, +-- so the extra Default project rows can be removed. +DELETE FROM projects +WHERE id IN ( + SELECT duplicate_project_id FROM __default_project_merge +); + +-- Drop the temporary merge map so the database is left with only application +-- tables. +DROP TABLE __rank_keyword_delete; +DROP TABLE __rank_config_merge; +DROP TABLE __default_project_merge; diff --git a/scripts/d1-default-project-cleanup.ts b/scripts/d1-default-project-cleanup.ts new file mode 100644 index 0000000..b750115 --- /dev/null +++ b/scripts/d1-default-project-cleanup.ts @@ -0,0 +1,700 @@ +/** + * One-time migration helper for duplicate auto-created Default projects. + * + * Use only if the latest migrations fail with a unique-constraint error for + * projects_one_default_per_organization_idx. See + * docs/default-project-cleanup.md for the full Cloudflare D1 and local D1 + * recovery runbook. + */ + +import { execFileSync } from "node:child_process"; +import process from "node:process"; +import { parseArgs } from "./cli-utils"; + +const args = parseArgs(process.argv.slice(2)); +const databaseName = args.database; +const shouldApply = args.apply === "true"; +const confirmedRemoteApply = args["confirm-remote-apply"] === "true"; +const validateOnly = args["validate-only"] === "true"; +const isLocal = args.local === "true"; + +const CLEANUP_SQL_FILE = "scripts/cleanup-default-projects.sql"; + +async function main() { + console.log( + `Target: ${databaseName} (${isLocal ? "local" : "remote"} D1 database)`, + ); + + if (validateOnly) { + runValidation(); + return; + } + + if (!shouldApply) { + runDryRun(); + return; + } + + runPreflightBlockers(); + + console.log("\nApplying cleanup SQL..."); + runWrangler(["--yes", "--file", CLEANUP_SQL_FILE]); + runValidation(); +} + +function printUsage() { + console.log(`Usage: + pnpm cleanup:default-projects:d1 --database + pnpm cleanup:default-projects:d1 --database --apply --confirm-remote-apply + pnpm cleanup:default-projects:d1 --database --validate-only + +Options: + --database Required D1 database binding/name to execute against. + --apply Run scripts/cleanup-default-projects.sql, then validate. + --confirm-remote-apply + Required with --apply for remote D1 databases. + --validate-only Run post-cleanup validation checks only. + --local Target local D1 instead of remote D1. + +Dry run is the default. It prints the rows that would be deleted, remapped, or +deduped without mutating application tables. + +See docs/default-project-cleanup.md for the full recovery runbook.`); +} + +function runDryRun() { + console.log("\nDry run: duplicate Default project summary"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ) + SELECT + COUNT(DISTINCT organization_id) AS organizations_affected, + COUNT(*) AS duplicate_default_projects_to_delete + FROM merge_map; + `); + + console.log("\nDry run: duplicate project rows that would be deleted"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ) + SELECT + projects.organization_id, + projects.id AS duplicate_project_id, + merge_map.canonical_project_id, + projects.created_at + FROM projects + JOIN merge_map + ON merge_map.duplicate_project_id = projects.id + ORDER BY projects.organization_id, projects.created_at DESC, projects.id DESC + LIMIT 100; + `); + + console.log("\nDry run: rows that would be deleted as duplicates"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ) + SELECT + (SELECT COUNT(*) FROM merge_map) AS duplicate_default_projects, + ( + SELECT COUNT(*) + FROM saved_keyword_tags duplicate_tag + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name + ) AS duplicate_saved_keyword_tags, + ( + SELECT COUNT(*) + FROM saved_keywords duplicate_keyword + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code + ) AS duplicate_saved_keywords, + ( + SELECT COUNT(*) + FROM keyword_metrics duplicate_metric + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_metric.project_id + JOIN keyword_metrics canonical_metric + ON canonical_metric.project_id = merge_map.canonical_project_id + AND canonical_metric.keyword = duplicate_metric.keyword + AND canonical_metric.location_code = duplicate_metric.location_code + AND canonical_metric.language_code = duplicate_metric.language_code + ) AS duplicate_keyword_metrics, + ( + SELECT COUNT(*) + FROM saved_keyword_tag_assignments assignment + JOIN saved_keyword_tags duplicate_tag + ON duplicate_tag.id = assignment.tag_id + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name + JOIN saved_keyword_tag_assignments existing_assignment + ON existing_assignment.saved_keyword_id = assignment.saved_keyword_id + AND existing_assignment.tag_id = canonical_tag.id + ) AS duplicate_tag_assignments_from_tag_merge, + ( + SELECT COUNT(*) + FROM saved_keyword_tag_assignments assignment + JOIN saved_keywords duplicate_keyword + ON duplicate_keyword.id = assignment.saved_keyword_id + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code + JOIN saved_keyword_tag_assignments existing_assignment + ON existing_assignment.saved_keyword_id = canonical_keyword.id + AND existing_assignment.tag_id = assignment.tag_id + ) AS duplicate_tag_assignments_from_keyword_merge; + `); + + console.log("\nDry run: rows that would be remapped without deletion"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ), + rank_config_merge AS ( + ${rankConfigMergeSql} + ) + SELECT + ( + SELECT COUNT(*) + FROM saved_keyword_tags + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + AND id NOT IN ( + SELECT duplicate_tag.id + FROM saved_keyword_tags duplicate_tag + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_tag.project_id + JOIN saved_keyword_tags canonical_tag + ON canonical_tag.project_id = merge_map.canonical_project_id + AND canonical_tag.normalized_name = duplicate_tag.normalized_name + ) + ) AS saved_keyword_tags_remapped, + ( + SELECT COUNT(*) + FROM saved_keywords + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + AND id NOT IN ( + SELECT duplicate_keyword.id + FROM saved_keywords duplicate_keyword + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_keyword.project_id + JOIN saved_keywords canonical_keyword + ON canonical_keyword.project_id = merge_map.canonical_project_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + AND canonical_keyword.location_code = duplicate_keyword.location_code + AND canonical_keyword.language_code = duplicate_keyword.language_code + ) + ) AS saved_keywords_remapped, + ( + SELECT COUNT(*) + FROM keyword_metrics + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + AND id NOT IN ( + SELECT duplicate_metric.id + FROM keyword_metrics duplicate_metric + JOIN merge_map + ON merge_map.duplicate_project_id = duplicate_metric.project_id + JOIN keyword_metrics canonical_metric + ON canonical_metric.project_id = merge_map.canonical_project_id + AND canonical_metric.keyword = duplicate_metric.keyword + AND canonical_metric.location_code = duplicate_metric.location_code + AND canonical_metric.language_code = duplicate_metric.language_code + ) + ) AS keyword_metrics_remapped, + ( + SELECT COUNT(*) + FROM rank_tracking_configs + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + AND id NOT IN (SELECT duplicate_config_id FROM rank_config_merge) + ) AS rank_tracking_configs_remapped, + ( + SELECT COUNT(*) + FROM rank_check_runs + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + ) AS rank_check_runs_remapped, + ( + SELECT COUNT(*) + FROM audits + WHERE project_id IN (SELECT duplicate_project_id FROM merge_map) + ) AS audits_remapped; + `); + + console.log("\nDry run: rank tracking config merges"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ), + rank_config_merge AS ( + ${rankConfigMergeSql} + ) + SELECT + duplicate_config.project_id AS duplicate_project_id, + rank_config_merge.canonical_project_id, + duplicate_config.id AS duplicate_config_id, + canonical_config.id AS canonical_config_id, + duplicate_config.domain, + duplicate_config.location_code, + duplicate_config.created_at AS duplicate_created_at, + canonical_config.created_at AS canonical_created_at + FROM rank_config_merge + JOIN rank_tracking_configs duplicate_config + ON duplicate_config.id = rank_config_merge.duplicate_config_id + JOIN rank_tracking_configs canonical_config + ON canonical_config.id = rank_config_merge.canonical_config_id + ORDER BY duplicate_config.domain, duplicate_config.location_code + LIMIT 100; + `); + + console.log("\nDry run: rank tracking keyword merge counts"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ), + rank_config_merge AS ( + ${rankConfigMergeSql} + ) + SELECT + ( + SELECT COUNT(*) + FROM rank_tracking_keywords duplicate_keyword + JOIN rank_config_merge + ON rank_config_merge.duplicate_config_id = duplicate_keyword.config_id + JOIN rank_tracking_keywords canonical_keyword + ON canonical_keyword.config_id = rank_config_merge.canonical_config_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + ) AS rank_tracking_keywords_deleted_as_duplicate, + ( + SELECT COUNT(*) + FROM rank_tracking_keywords duplicate_keyword + JOIN rank_config_merge + ON rank_config_merge.duplicate_config_id = duplicate_keyword.config_id + WHERE NOT EXISTS ( + SELECT 1 + FROM rank_tracking_keywords canonical_keyword + WHERE canonical_keyword.config_id = rank_config_merge.canonical_config_id + AND canonical_keyword.keyword = duplicate_keyword.keyword + ) + ) AS rank_tracking_keywords_moved_without_delete, + ( + SELECT COUNT(*) + FROM rank_check_runs + JOIN rank_config_merge + ON rank_config_merge.duplicate_config_id = rank_check_runs.config_id + ) AS rank_check_runs_moved_to_canonical_config; + `); + + console.log("\nDry run: active rank run merge blockers"); + runQuery(` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ), + rank_config_merge AS ( + ${rankConfigMergeSql} + ) + SELECT + config_merge.duplicate_config_id, + config_merge.canonical_config_id, + duplicate_run.id AS duplicate_active_run_id, + canonical_run.id AS canonical_active_run_id, + duplicate_run.status AS duplicate_status, + canonical_run.status AS canonical_status + FROM rank_config_merge config_merge + JOIN rank_check_runs duplicate_run + ON duplicate_run.config_id = config_merge.duplicate_config_id + AND duplicate_run.status IN ('pending', 'running') + JOIN rank_check_runs canonical_run + ON canonical_run.config_id = config_merge.canonical_config_id + AND canonical_run.status IN ('pending', 'running') + LIMIT 100; + `); + + console.log("\nDry run: Default project predicate outliers"); + runQuery(defaultProjectOutlierSql); + + console.log("\nDry run complete. Re-run with --apply to mutate the DB."); +} + +function runPreflightBlockers() { + assertNoRows( + "active rank run merge blockers", + ` + WITH ranked_default_projects AS ( + ${rankedDefaultProjectsSql} + ), + merge_map AS ( + ${mergeMapSql} + ), + rank_config_merge AS ( + ${rankConfigMergeSql} + ) + SELECT + config_merge.duplicate_config_id, + config_merge.canonical_config_id, + duplicate_run.id AS duplicate_active_run_id, + canonical_run.id AS canonical_active_run_id, + duplicate_run.status AS duplicate_status, + canonical_run.status AS canonical_status + FROM rank_config_merge config_merge + JOIN rank_check_runs duplicate_run + ON duplicate_run.config_id = config_merge.duplicate_config_id + AND duplicate_run.status IN ('pending', 'running') + JOIN rank_check_runs canonical_run + ON canonical_run.config_id = config_merge.canonical_config_id + AND canonical_run.status IN ('pending', 'running'); + `, + ); +} + +function runValidation() { + assertAllZero( + "duplicate Default projects", + ` + SELECT COUNT(*) AS organizations_with_duplicate_default_projects + FROM ( + SELECT organization_id + FROM projects + WHERE name = 'Default' AND domain IS NULL + GROUP BY organization_id + HAVING COUNT(*) > 1 + ); + `, + ); + + assertAllZero("Default project predicate outliers", defaultProjectOutlierSql); + + assertAllZero( + "project-owned orphan rows", + ` + SELECT + (SELECT COUNT(*) FROM saved_keywords LEFT JOIN projects ON projects.id = saved_keywords.project_id WHERE projects.id IS NULL) AS orphaned_saved_keywords, + (SELECT COUNT(*) FROM saved_keyword_tags LEFT JOIN projects ON projects.id = saved_keyword_tags.project_id WHERE projects.id IS NULL) AS orphaned_saved_keyword_tags, + (SELECT COUNT(*) FROM keyword_metrics LEFT JOIN projects ON projects.id = keyword_metrics.project_id WHERE projects.id IS NULL) AS orphaned_keyword_metrics, + (SELECT COUNT(*) FROM rank_tracking_configs LEFT JOIN projects ON projects.id = rank_tracking_configs.project_id WHERE projects.id IS NULL) AS orphaned_rank_tracking_configs, + (SELECT COUNT(*) FROM rank_check_runs LEFT JOIN projects ON projects.id = rank_check_runs.project_id WHERE projects.id IS NULL) AS orphaned_rank_check_runs, + (SELECT COUNT(*) FROM audits LEFT JOIN projects ON projects.id = audits.project_id WHERE projects.id IS NULL) AS orphaned_audits; + `, + ); + + assertAllZero( + "uniqueness conflicts", + ` + SELECT + ( + SELECT COUNT(*) + FROM ( + SELECT project_id, keyword, location_code, language_code + FROM saved_keywords + GROUP BY project_id, keyword, location_code, language_code + HAVING COUNT(*) > 1 + ) + ) AS duplicate_saved_keyword_keys, + ( + SELECT COUNT(*) + FROM ( + SELECT project_id, normalized_name + FROM saved_keyword_tags + GROUP BY project_id, normalized_name + HAVING COUNT(*) > 1 + ) + ) AS duplicate_saved_keyword_tag_keys, + ( + SELECT COUNT(*) + FROM ( + SELECT project_id, keyword, location_code, language_code + FROM keyword_metrics + GROUP BY project_id, keyword, location_code, language_code + HAVING COUNT(*) > 1 + ) + ) AS duplicate_keyword_metric_keys, + ( + SELECT COUNT(*) + FROM ( + SELECT project_id, domain, location_code + FROM rank_tracking_configs + GROUP BY project_id, domain, location_code + HAVING COUNT(*) > 1 + ) + ) AS duplicate_rank_config_keys, + ( + SELECT COUNT(*) + FROM ( + SELECT config_id, keyword + FROM rank_tracking_keywords + GROUP BY config_id, keyword + HAVING COUNT(*) > 1 + ) + ) AS duplicate_rank_tracking_keyword_keys; + `, + ); + + assertAllZero( + "saved keyword tag assignment integrity", + ` + SELECT + ( + SELECT COUNT(*) + FROM saved_keyword_tag_assignments assignment + LEFT JOIN saved_keywords + ON saved_keywords.id = assignment.saved_keyword_id + WHERE saved_keywords.id IS NULL + ) AS assignments_with_missing_saved_keyword, + ( + SELECT COUNT(*) + FROM saved_keyword_tag_assignments assignment + LEFT JOIN saved_keyword_tags + ON saved_keyword_tags.id = assignment.tag_id + WHERE saved_keyword_tags.id IS NULL + ) AS assignments_with_missing_tag, + ( + SELECT COUNT(*) + FROM saved_keyword_tag_assignments assignment + JOIN saved_keywords + ON saved_keywords.id = assignment.saved_keyword_id + JOIN saved_keyword_tags + ON saved_keyword_tags.id = assignment.tag_id + WHERE saved_keywords.project_id != saved_keyword_tags.project_id + ) AS cross_project_tag_assignments; + `, + ); + + assertAllZero( + "rank tracking references", + ` + SELECT + ( + SELECT COUNT(*) + FROM rank_check_runs + LEFT JOIN rank_tracking_configs + ON rank_tracking_configs.id = rank_check_runs.config_id + WHERE rank_tracking_configs.id IS NULL + ) AS runs_with_missing_config, + ( + SELECT COUNT(*) + FROM rank_snapshots + LEFT JOIN rank_check_runs + ON rank_check_runs.id = rank_snapshots.run_id + WHERE rank_check_runs.id IS NULL + ) AS snapshots_with_missing_run; + `, + ); + + assertNoRows("PRAGMA foreign_key_check", "PRAGMA foreign_key_check;"); + console.log("\nValidation passed."); +} + +const rankedDefaultProjectsSql = ` + SELECT + id, + organization_id, + ROW_NUMBER() OVER ( + PARTITION BY organization_id + ORDER BY created_at DESC, id DESC + ) AS keep_rank, + FIRST_VALUE(id) OVER ( + PARTITION BY organization_id + ORDER BY created_at DESC, id DESC + ) AS canonical_project_id, + COUNT(*) OVER (PARTITION BY organization_id) AS project_count + FROM projects + WHERE name = 'Default' + AND domain IS NULL +`; + +const mergeMapSql = ` + SELECT + organization_id, + canonical_project_id, + id AS duplicate_project_id + FROM ranked_default_projects + WHERE project_count > 1 + AND keep_rank > 1 +`; + +const rankConfigMergeSql = ` + WITH project_set AS ( + SELECT + organization_id, + canonical_project_id, + canonical_project_id AS project_id, + 1 AS is_canonical_project + FROM merge_map + GROUP BY organization_id, canonical_project_id + + UNION ALL + + SELECT + organization_id, + canonical_project_id, + duplicate_project_id AS project_id, + 0 AS is_canonical_project + FROM merge_map + ), + ranked_configs AS ( + SELECT + project_set.organization_id, + project_set.canonical_project_id, + config.project_id, + config.id AS config_id, + FIRST_VALUE(config.id) OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ORDER BY project_set.is_canonical_project DESC, config.created_at DESC, config.id DESC + ) AS canonical_config_id, + COUNT(*) OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ) AS config_count, + ROW_NUMBER() OVER ( + PARTITION BY project_set.organization_id, config.domain, config.location_code + ORDER BY project_set.is_canonical_project DESC, config.created_at DESC, config.id DESC + ) AS keep_rank + FROM rank_tracking_configs config + JOIN project_set + ON project_set.project_id = config.project_id + ) + SELECT + canonical_project_id, + project_id AS duplicate_project_id, + canonical_config_id, + config_id AS duplicate_config_id + FROM ranked_configs + WHERE config_count > 1 + AND keep_rank > 1 +`; + +const defaultProjectOutlierSql = ` + SELECT + ( + SELECT COUNT(*) + FROM projects + WHERE lower(name) = 'default' + AND name != 'Default' + ) AS lowercase_default_name_rows, + ( + SELECT COUNT(*) + FROM projects + WHERE name = 'Default' + AND domain = '' + ) AS empty_domain_default_rows; +`; + +function runQuery(sql: string) { + runWrangler(["--command", sql]); +} + +type WranglerJsonResult = Array<{ + results?: Array>; + success?: boolean; +}>; + +function assertAllZero(label: string, sql: string) { + const rows = runJsonQuery(sql); + const row = rows[0] ?? {}; + console.log(`\nValidation: ${label}`); + console.table([row]); + + const failures = Object.entries(row).filter(([, value]) => value !== 0); + if (failures.length > 0) { + throw new Error( + `Validation failed for ${label}: ${failures + .map(([key, value]) => `${key}=${String(value)}`) + .join(", ")}`, + ); + } +} + +function assertNoRows(label: string, sql: string) { + const rows = runJsonQuery(sql); + console.log(`\nValidation: ${label}`); + if (rows.length > 0) { + console.table(rows); + throw new Error(`Validation failed for ${label}: expected no rows.`); + } + console.log("No rows."); +} + +function runJsonQuery(sql: string) { + const scopeArg = isLocal ? "--local" : "--remote"; + const output = execFileSync( + "wrangler", + ["d1", "execute", databaseName, scopeArg, "--json", "--command", sql], + { + encoding: "utf8", + env: process.env, + }, + ); + const parsed = JSON.parse(output) as WranglerJsonResult; + const failed = parsed.find((result) => result.success === false); + if (failed) { + throw new Error(`Wrangler query failed for ${databaseName}.`); + } + return parsed.flatMap((result) => result.results ?? []); +} + +function runWrangler(extraArgs: string[]) { + const scopeArg = isLocal ? "--local" : "--remote"; + execFileSync( + "wrangler", + ["d1", "execute", databaseName, scopeArg, ...extraArgs], + { + stdio: "inherit", + env: process.env, + }, + ); +} + +if (args.help === "true") { + printUsage(); + process.exit(0); +} + +if (!databaseName) { + printUsage(); + throw new Error("Missing required --database argument."); +} + +if (shouldApply && validateOnly) { + throw new Error("Use either --apply or --validate-only, not both."); +} + +if (shouldApply && !isLocal && !confirmedRemoteApply) { + throw new Error( + "Remote apply requires --confirm-remote-apply. Run a dry run and make sure you have a D1 backup/time-travel restore point first.", + ); +} + +await main(); diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index 902cbf5..20c3a88 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -20,17 +20,29 @@ export const delegatedUsers = sqliteTable("delegated_users", { }); // Projects for keyword research -export const projects = sqliteTable("projects", { - id: text("id").primaryKey(), - organizationId: text("organization_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - name: text("name").notNull(), - domain: text("domain"), - createdAt: text("created_at") - .notNull() - .default(sql`(current_timestamp)`), -}); +export const projects = sqliteTable( + "projects", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + name: text("name").notNull(), + domain: text("domain"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + // Only the auto-created Default/null-domain project is a singleton. This + // guards the get-or-create race that can happen when several requests enter + // a new organization at once, without forbidding users from manually + // creating multiple projects with the same name or domain later. + uniqueIndex("projects_one_default_per_organization_idx") + .on(table.organizationId) + .where(sql`${table.name} = 'Default' AND ${table.domain} IS NULL`), + ], +); // User-saved keywords within a project. This is the canonical saved list. export const savedKeywords = sqliteTable( diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts index 629e9c2..f8078c5 100644 --- a/src/server/features/projects/repositories/ProjectRepository.ts +++ b/src/server/features/projects/repositories/ProjectRepository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; import { projects } from "@/db/schema"; import { AppError } from "@/server/lib/errors"; @@ -6,7 +6,18 @@ import { AppError } from "@/server/lib/errors"; async function listProjects(organizationId: string) { return db.query.projects.findMany({ where: eq(projects.organizationId, organizationId), - orderBy: desc(projects.createdAt), + orderBy: [desc(projects.createdAt), desc(projects.id)], + }); +} + +async function getDefaultProjectForOrganization(organizationId: string) { + return db.query.projects.findFirst({ + where: and( + eq(projects.organizationId, organizationId), + eq(projects.name, "Default"), + isNull(projects.domain), + ), + orderBy: [desc(projects.createdAt), desc(projects.id)], }); } @@ -61,6 +72,7 @@ async function deleteProject(projectId: string, organizationId: string) { export const ProjectRepository = { listProjects, + getDefaultProjectForOrganization, getProjectForOrganization, getProjectById, createProject, diff --git a/src/server/features/projects/services/projects.test.ts b/src/server/features/projects/services/projects.test.ts new file mode 100644 index 0000000..2145261 --- /dev/null +++ b/src/server/features/projects/services/projects.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createProject: vi.fn(), + deleteProject: vi.fn(), + getDefaultProjectForOrganization: vi.fn(), + getProjectById: vi.fn(), + getProjectForOrganization: vi.fn(), + listProjects: vi.fn(), +})); + +vi.mock("@/server/features/projects/repositories/ProjectRepository", () => ({ + ProjectRepository: mocks, +})); + +const defaultProject = { + id: "project_default", + name: "Default", + domain: null, + createdAt: "2026-05-19 12:00:00", +}; + +describe("project service", () => { + beforeEach(() => { + vi.resetModules(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("recovers from the default project unique constraint race", async () => { + mocks.getDefaultProjectForOrganization + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(defaultProject); + mocks.createProject.mockRejectedValue( + new Error("UNIQUE constraint failed: projects.organization_id"), + ); + const { getOrCreateDefaultProject } = await import("./projects"); + + await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual( + defaultProject, + ); + expect(mocks.createProject).toHaveBeenCalledWith( + "org_1", + "Default", + undefined, + ); + expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2); + }); + + it("does not swallow unrelated default project create failures", async () => { + const error = new Error("D1 unavailable"); + mocks.getDefaultProjectForOrganization.mockResolvedValue(null); + mocks.createProject.mockRejectedValue(error); + const { getOrCreateDefaultProject } = await import("./projects"); + + await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error); + expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/features/projects/services/projects.ts b/src/server/features/projects/services/projects.ts index 952ed1a..027b8b1 100644 --- a/src/server/features/projects/services/projects.ts +++ b/src/server/features/projects/services/projects.ts @@ -45,23 +45,47 @@ export async function deleteProject( } export async function getOrCreateDefaultProject(organizationId: string) { - const existing = await ProjectRepository.listProjects(organizationId); - if (existing.length > 0) { - return mapProject(existing[0]); + const existing = + await ProjectRepository.getDefaultProjectForOrganization(organizationId); + if (existing) { + return mapProject(existing); } - const id = await ProjectRepository.createProject( - organizationId, - "Default", - undefined, - ); + try { + const id = await ProjectRepository.createProject( + organizationId, + "Default", + undefined, + ); - return { - id, - name: "Default", - domain: null, - createdAt: new Date().toISOString(), - }; + return { + id, + name: "Default", + domain: null, + createdAt: new Date().toISOString(), + }; + } catch (error) { + if (!isDefaultProjectUniqueConstraintError(error)) { + throw error; + } + + const createdProject = + await ProjectRepository.getDefaultProjectForOrganization(organizationId); + if (createdProject) { + return mapProject(createdProject); + } + + throw error; + } +} + +function isDefaultProjectUniqueConstraintError(error: unknown) { + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + return ( + message.includes("unique constraint failed") && + message.includes("projects.organization_id") + ); } export async function getProject(projectId: string) {