← back to Open Seo

scripts/cleanup-default-projects.sql

496 lines

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