Prevent duplicate Default projects and add D1 cleanup tooling (#203)

This commit is contained in:
Ben Senescu 2026-05-19 20:53:13 -04:00 committed by GitHub
parent fb911fe124
commit 74ba0c4fc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 3598 additions and 28 deletions

View 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`.

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

File diff suppressed because it is too large Load Diff

View File

@ -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
} }
] ]
} }

View File

@ -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",
], ],

View File

@ -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
View 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

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

View 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();

View File

@ -20,7 +20,9 @@ export const delegatedUsers = sqliteTable("delegated_users", {
}); });
// Projects for keyword research // Projects for keyword research
export const projects = sqliteTable("projects", { export const projects = sqliteTable(
"projects",
{
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
@ -30,7 +32,17 @@ export const projects = sqliteTable("projects", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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(

View File

@ -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,

View 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);
});
});

View File

@ -45,11 +45,13 @@ 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);
} }
try {
const id = await ProjectRepository.createProject( const id = await ProjectRepository.createProject(
organizationId, organizationId,
"Default", "Default",
@ -62,6 +64,28 @@ export async function getOrCreateDefaultProject(organizationId: string) {
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) {