Prevent duplicate Default projects and add D1 cleanup tooling (#203)
This commit is contained in:
parent
fb911fe124
commit
74ba0c4fc8
70
docs/default-project-cleanup.md
Normal file
70
docs/default-project-cleanup.md
Normal file
@ -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`.
|
||||||
1
drizzle/0016_magical_the_fallen.sql
Normal file
1
drizzle/0016_magical_the_fallen.sql
Normal file
@ -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;
|
||||||
2167
drizzle/meta/0016_snapshot.json
Normal file
2167
drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -113,6 +113,13 @@
|
|||||||
"when": 1778750000001,
|
"when": 1778750000001,
|
||||||
"tag": "0015_huge_celestials",
|
"tag": "0015_huge_celestials",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1779138211145,
|
||||||
|
"tag": "0016_magical_the_fallen",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
// Package script entrypoints
|
// Package script entrypoints
|
||||||
"scripts/backlinks-cost-profile.ts",
|
"scripts/backlinks-cost-profile.ts",
|
||||||
"scripts/brand-lookup-cost-profile.ts",
|
"scripts/brand-lookup-cost-profile.ts",
|
||||||
|
"scripts/d1-default-project-cleanup.ts",
|
||||||
"scripts/release-notes.mjs",
|
"scripts/release-notes.mjs",
|
||||||
"scripts/seed-rank-tracking.ts",
|
"scripts/seed-rank-tracking.ts",
|
||||||
// DB schema — exports consumed via `import * as schema` / drizzle()
|
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||||
@ -21,6 +22,7 @@
|
|||||||
// Package scripts and one-off maintenance utilities
|
// Package scripts and one-off maintenance utilities
|
||||||
"scripts/backlinks-cost-profile.ts",
|
"scripts/backlinks-cost-profile.ts",
|
||||||
"scripts/brand-lookup-cost-profile.ts",
|
"scripts/brand-lookup-cost-profile.ts",
|
||||||
|
"scripts/d1-default-project-cleanup.ts",
|
||||||
"scripts/release-notes.mjs",
|
"scripts/release-notes.mjs",
|
||||||
"scripts/seed-rank-tracking.ts",
|
"scripts/seed-rank-tracking.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "open-seo",
|
"name": "open-seo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"version": "0.0.13",
|
"version": "0.0.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.30.1",
|
"packageManager": "pnpm@10.30.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -30,6 +30,7 @@
|
|||||||
"test:ci": "vitest run --reporter=dot",
|
"test:ci": "vitest run --reporter=dot",
|
||||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||||
"billing:brand-lookup": "tsx scripts/brand-lookup-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",
|
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
||||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||||
},
|
},
|
||||||
|
|||||||
21
release-notes/v0.0.14.md
Normal file
21
release-notes/v0.0.14.md
Normal file
@ -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
|
||||||
495
scripts/cleanup-default-projects.sql
Normal file
495
scripts/cleanup-default-projects.sql
Normal file
@ -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;
|
||||||
700
scripts/d1-default-project-cleanup.ts
Normal file
700
scripts/d1-default-project-cleanup.ts
Normal file
@ -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 <database-name>
|
||||||
|
pnpm cleanup:default-projects:d1 --database <database-name> --apply --confirm-remote-apply
|
||||||
|
pnpm cleanup:default-projects:d1 --database <database-name> --validate-only
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--database <name> 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<Record<string, unknown>>;
|
||||||
|
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 <name> 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();
|
||||||
@ -20,17 +20,29 @@ export const delegatedUsers = sqliteTable("delegated_users", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Projects for keyword research
|
// Projects for keyword research
|
||||||
export const projects = sqliteTable("projects", {
|
export const projects = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
"projects",
|
||||||
organizationId: text("organization_id")
|
{
|
||||||
.notNull()
|
id: text("id").primaryKey(),
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
organizationId: text("organization_id")
|
||||||
name: text("name").notNull(),
|
.notNull()
|
||||||
domain: text("domain"),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
createdAt: text("created_at")
|
name: text("name").notNull(),
|
||||||
.notNull()
|
domain: text("domain"),
|
||||||
.default(sql`(current_timestamp)`),
|
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.
|
// User-saved keywords within a project. This is the canonical saved list.
|
||||||
export const savedKeywords = sqliteTable(
|
export const savedKeywords = sqliteTable(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { projects } from "@/db/schema";
|
import { projects } from "@/db/schema";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
@ -6,7 +6,18 @@ import { AppError } from "@/server/lib/errors";
|
|||||||
async function listProjects(organizationId: string) {
|
async function listProjects(organizationId: string) {
|
||||||
return db.query.projects.findMany({
|
return db.query.projects.findMany({
|
||||||
where: eq(projects.organizationId, organizationId),
|
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 = {
|
export const ProjectRepository = {
|
||||||
listProjects,
|
listProjects,
|
||||||
|
getDefaultProjectForOrganization,
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
createProject,
|
createProject,
|
||||||
|
|||||||
58
src/server/features/projects/services/projects.test.ts
Normal file
58
src/server/features/projects/services/projects.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -45,23 +45,47 @@ export async function deleteProject(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateDefaultProject(organizationId: string) {
|
export async function getOrCreateDefaultProject(organizationId: string) {
|
||||||
const existing = await ProjectRepository.listProjects(organizationId);
|
const existing =
|
||||||
if (existing.length > 0) {
|
await ProjectRepository.getDefaultProjectForOrganization(organizationId);
|
||||||
return mapProject(existing[0]);
|
if (existing) {
|
||||||
|
return mapProject(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = await ProjectRepository.createProject(
|
try {
|
||||||
organizationId,
|
const id = await ProjectRepository.createProject(
|
||||||
"Default",
|
organizationId,
|
||||||
undefined,
|
"Default",
|
||||||
);
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: "Default",
|
name: "Default",
|
||||||
domain: null,
|
domain: null,
|
||||||
createdAt: new Date().toISOString(),
|
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) {
|
export async function getProject(projectId: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user